mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-24 23:10:39 +01:00
Compare commits
10 Commits
main
...
querier/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5bd7416d4 | ||
|
|
85f1bf29ce | ||
|
|
6d72efde81 | ||
|
|
f66ea90b35 | ||
|
|
9f34bcf5b3 | ||
|
|
e0a8f41ac6 | ||
|
|
63e890a20e | ||
|
|
1783b942a2 | ||
|
|
ab3ed06fc0 | ||
|
|
b483cb3545 |
@@ -1592,6 +1592,13 @@ components:
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
CommonDisplay:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
CommonJSONRef:
|
||||
properties:
|
||||
$ref:
|
||||
@@ -2725,6 +2732,11 @@ components:
|
||||
type: object
|
||||
DashboardtypesDashboardSpec:
|
||||
properties:
|
||||
datasources:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
|
||||
nullable: true
|
||||
type: object
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
duration:
|
||||
@@ -2736,7 +2748,6 @@ components:
|
||||
links:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesLink'
|
||||
nullable: true
|
||||
type: array
|
||||
panels:
|
||||
additionalProperties:
|
||||
@@ -2753,6 +2764,7 @@ components:
|
||||
- variables
|
||||
- panels
|
||||
- layouts
|
||||
- links
|
||||
type: object
|
||||
DashboardtypesDashboardView:
|
||||
properties:
|
||||
@@ -2789,6 +2801,39 @@ components:
|
||||
required:
|
||||
- version
|
||||
type: object
|
||||
DashboardtypesDatasourcePlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/Datasource: '#/components/schemas/DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec'
|
||||
type: object
|
||||
DashboardtypesDatasourcePluginKind:
|
||||
enum:
|
||||
- signoz/Datasource
|
||||
type: string
|
||||
DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/Datasource
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesSigNozDatasourceSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesDatasourceSpec:
|
||||
properties:
|
||||
default:
|
||||
type: boolean
|
||||
display:
|
||||
$ref: '#/components/schemas/CommonDisplay'
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesDatasourcePlugin'
|
||||
type: object
|
||||
DashboardtypesDisplay:
|
||||
properties:
|
||||
description:
|
||||
@@ -3356,7 +3401,6 @@ components:
|
||||
links:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesLink'
|
||||
nullable: true
|
||||
type: array
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelPlugin'
|
||||
@@ -3368,6 +3412,7 @@ components:
|
||||
- display
|
||||
- plugin
|
||||
- queries
|
||||
- links
|
||||
type: object
|
||||
DashboardtypesPatchOp:
|
||||
enum:
|
||||
@@ -3565,6 +3610,8 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesSigNozDatasourceSpec:
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
|
||||
@@ -3236,6 +3236,17 @@ export interface CloudintegrationtypesUpdatableServiceDTO {
|
||||
config: CloudintegrationtypesServiceConfigDTO;
|
||||
}
|
||||
|
||||
export interface CommonDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface CommonJSONRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3979,6 +3990,44 @@ export interface DashboardtypesDashboardPanelRefDTO {
|
||||
panelName: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind {
|
||||
'signoz/Datasource' = 'signoz/Datasource',
|
||||
}
|
||||
export interface DashboardtypesSigNozDatasourceSpecDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO {
|
||||
/**
|
||||
* @enum signoz/Datasource
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind;
|
||||
spec: DashboardtypesSigNozDatasourceSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesDatasourcePluginDTO =
|
||||
DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO;
|
||||
|
||||
export interface DashboardtypesDatasourceSpecDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
default?: boolean;
|
||||
display?: CommonDisplayDTO;
|
||||
plugin?: DashboardtypesDatasourcePluginDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesDashboardSpecDTODatasourcesAnyOf = {
|
||||
[key: string]: DashboardtypesDatasourceSpecDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesDashboardSpecDTODatasources =
|
||||
DashboardtypesDashboardSpecDTODatasourcesAnyOf | null;
|
||||
|
||||
export enum DashboardtypesPanelKindDTO {
|
||||
Panel = 'Panel',
|
||||
}
|
||||
@@ -4577,9 +4626,9 @@ export interface DashboardtypesQueryDTO {
|
||||
export interface DashboardtypesPanelSpecDTO {
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
links?: DashboardtypesLinkDTO[] | null;
|
||||
links: DashboardtypesLinkDTO[];
|
||||
plugin: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
* @type array
|
||||
@@ -4758,6 +4807,10 @@ export type DashboardtypesVariableDTO =
|
||||
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
|
||||
|
||||
export interface DashboardtypesDashboardSpecDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
datasources?: DashboardtypesDashboardSpecDTODatasources;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -4768,9 +4821,9 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
*/
|
||||
layouts: DashboardtypesLayoutDTO[];
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
links?: DashboardtypesLinkDTO[] | null;
|
||||
links: DashboardtypesLinkDTO[];
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
@@ -4833,6 +4886,9 @@ export interface DashboardtypesDashboardViewDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginKindDTO {
|
||||
'signoz/Datasource' = 'signoz/Datasource',
|
||||
}
|
||||
export interface TagtypesGettableTagDTO {
|
||||
/**
|
||||
* @type string
|
||||
|
||||
@@ -713,19 +713,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Row buttons select without letting the wrapping checkbox also toggle:
|
||||
// stop propagation, run the selection, then drop the active/chip focus.
|
||||
const selectFromButton = (
|
||||
e: React.MouseEvent,
|
||||
source: 'option' | 'checkbox',
|
||||
): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection(source);
|
||||
setActiveChipIndex(-1);
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value || `option-${index}`}
|
||||
@@ -739,6 +726,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection('option');
|
||||
setActiveChipIndex(-1);
|
||||
setActiveIndex(-1);
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
|
||||
e.stopPropagation();
|
||||
@@ -758,7 +752,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
<Checkbox
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection('checkbox');
|
||||
setActiveChipIndex(-1);
|
||||
setActiveIndex(-1);
|
||||
}}
|
||||
>
|
||||
<div className="option-content">
|
||||
<Typography.Text truncate={1} className="option-label-text">
|
||||
@@ -768,19 +768,11 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
{option.value && ensureValidOption(option.value) && (
|
||||
<Button
|
||||
type="text"
|
||||
className="only-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'option')}
|
||||
>
|
||||
<Button type="text" className="only-btn">
|
||||
{currentToggleTagValue({ option: option.value })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
className="toggle-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -656,28 +656,6 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('UI-03b: clicking "Only" selects just that value', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
onChange={mockOnChange}
|
||||
value={['frontend']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
await user.click(combobox);
|
||||
|
||||
// "Only" renders for each non-selected row (hidden until hover via CSS,
|
||||
// which jsdom doesn't apply, so it's clickable here).
|
||||
const onlyButtons = await screen.findAllByText('Only');
|
||||
mockOnChange.mockClear();
|
||||
await user.click(onlyButtons[0]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnChange.mock.calls[0][0]).toStrictEqual(['backend']);
|
||||
});
|
||||
|
||||
it('UI-04: Should display values with loading info at bottom', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} loading />,
|
||||
@@ -1471,7 +1449,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
['custom-value'],
|
||||
[{ label: 'custom-value', value: 'custom-value', type: 'custom' }],
|
||||
[{ label: 'custom-value', value: 'custom-value' }],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { prioritizeOrAddOptionForMultiSelect } from '../utils';
|
||||
|
||||
describe('prioritizeOrAddOptionForMultiSelect ordering', () => {
|
||||
it('hoists selected then preserves the given (sorted) order in each group', () => {
|
||||
const sorted = ['apple', 'banana', 'cherry', 'date', 'elderberry'].map(
|
||||
(v) => ({ label: v, value: v }),
|
||||
);
|
||||
// selection given in a non-sorted order on purpose
|
||||
const result = prioritizeOrAddOptionForMultiSelect(sorted, [
|
||||
'date',
|
||||
'banana',
|
||||
]);
|
||||
expect(result.map((o) => o.value)).toStrictEqual([
|
||||
'banana',
|
||||
'date',
|
||||
'apple',
|
||||
'cherry',
|
||||
'elderberry',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -458,9 +458,7 @@ $custom-border-color: #2c3044;
|
||||
|
||||
.option-item {
|
||||
padding: 8px 12px;
|
||||
// Not the whole row — only the checkbox and the action buttons get the
|
||||
// pointer (set below), so inert areas don't look clickable.
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -496,13 +494,6 @@ $custom-border-color: #2c3044;
|
||||
|
||||
.option-checkbox {
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
|
||||
// The checkbox button is the only pointer target on the left; the label
|
||||
// still toggles on click but keeps a default cursor.
|
||||
> button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// @signozhq/ui Checkbox renders children inside a <label> that is
|
||||
// content-sized by default. Make it fill the row (min-width: 0 lets it
|
||||
@@ -544,9 +535,9 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
|
||||
// "Only"/"All" is the primary action — a filled pill that reads as a
|
||||
// button; "Toggle" is a secondary hint in plain text. Sized to the row's
|
||||
// resting height so revealing them on hover never shifts it.
|
||||
// Size the buttons to the row's resting content height (20px) and fully
|
||||
// override antd's default 32px Button box, so revealing them on hover
|
||||
// never changes the row height.
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
@@ -554,39 +545,14 @@ $custom-border-color: #2c3044;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.only-btn {
|
||||
padding: 4px 8px;
|
||||
// Black interior + a visible border so the pill stands out clearly
|
||||
// against the near-black row when revealed on hover.
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 3px;
|
||||
background-color: var(--bg-ink-500, #0b0c0e);
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
box-shadow: none;
|
||||
|
||||
// Toggle appears over the checkbox area; "Only/All" takes over the row
|
||||
// content and hides Toggle there (higher specificity wins).
|
||||
&:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
display: none;
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,7 +560,6 @@ $custom-border-color: #2c3044;
|
||||
.only-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
@@ -604,6 +569,15 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-checkbox:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
}
|
||||
.option-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
|
||||
@@ -770,34 +770,6 @@ export const removeVariableFromExpression = (
|
||||
return removeKeysFromExpression(expression, keysToRemove, `$${variableName}`);
|
||||
};
|
||||
|
||||
// Appends `clause` as a top-level AND term, parenthesising the base only when it
|
||||
// has a top-level OR (AND binds tighter, so `a OR b AND c` would misbind).
|
||||
export const appendAndClause = (
|
||||
expression: string | undefined,
|
||||
clause: string,
|
||||
): string => {
|
||||
const base = expression?.trim();
|
||||
if (!base) {
|
||||
return clause;
|
||||
}
|
||||
|
||||
const chars = CharStreams.fromString(base);
|
||||
const lexer = new FilterQueryLexer(chars);
|
||||
lexer.removeErrorListeners();
|
||||
const tokenStream = new CommonTokenStream(lexer);
|
||||
const parser = new FilterQueryParser(tokenStream);
|
||||
parser.removeErrorListeners();
|
||||
const tree = parser.query();
|
||||
|
||||
if (parser.syntaxErrorsCount > 0) {
|
||||
return `(${base}) AND ${clause}`;
|
||||
}
|
||||
|
||||
const hasTopLevelOr =
|
||||
tree.expression().orExpression().andExpression_list().length > 1;
|
||||
return hasTopLevelOr ? `(${base}) AND ${clause}` : `${base} AND ${clause}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert old having format to new having format
|
||||
* @param having - Array of old having objects with columnName, op, and value
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.static {
|
||||
// Tag chips are not interactive, but the @signozhq Badge darkens outline
|
||||
// variants on hover — which reads as clickable. Pin the hover background to the
|
||||
// resting background so hovering a plain tag produces no change.
|
||||
--badge-outline-hover-background-color: var(
|
||||
--badge-outline-background-color,
|
||||
color-mix(in oklab, var(--badge-background) 10%, transparent)
|
||||
);
|
||||
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { type MouseEvent, type ReactNode } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TagBadge.module.scss';
|
||||
|
||||
interface TagBadgeProps {
|
||||
children: ReactNode;
|
||||
@@ -25,7 +22,7 @@ function TagBadge({
|
||||
<Badge
|
||||
color="sienna"
|
||||
variant="outline"
|
||||
className={cx(styles.static, className)}
|
||||
className={className}
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
>
|
||||
|
||||
@@ -55,6 +55,7 @@ export function useCreateExportDashboard({
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
links: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -101,10 +101,8 @@ function DashboardActions({
|
||||
|
||||
const handleCreateSection = useCallback(
|
||||
async (title: string): Promise<void> => {
|
||||
const ok = await addSection(title);
|
||||
if (ok) {
|
||||
setIsNewSectionOpen(false);
|
||||
}
|
||||
await addSection(title);
|
||||
setIsNewSectionOpen(false);
|
||||
},
|
||||
[addSection],
|
||||
);
|
||||
|
||||
@@ -100,11 +100,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Fixed footprint (2 tags + `+N`): never shrinks, so a long title ellipsizes
|
||||
around it rather than collapsing the tags. */
|
||||
/* Flexes into the remaining space and clips so the ResizeObserver can measure
|
||||
how many tags fit before collapsing the rest into a `+N` badge. */
|
||||
.dashboardTags {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex: 1 1 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -20,13 +20,10 @@ import { linkifyText } from 'utils/linkifyText';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useVisibleTagCount } from './useVisibleTagCount';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
|
||||
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
|
||||
const MAX_VISIBLE_TAGS = 2;
|
||||
|
||||
interface DashboardInfoProps {
|
||||
title: string;
|
||||
image: string;
|
||||
@@ -71,8 +68,10 @@ function DashboardInfo({
|
||||
const hasTags = tags.length > 0;
|
||||
const hasDescription = !isEmpty(description);
|
||||
|
||||
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
|
||||
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
|
||||
const { containerRef, visibleCount } = useVisibleTagCount(tags);
|
||||
const needsOverflow = tags.length > visibleCount;
|
||||
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
|
||||
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
|
||||
|
||||
let lockTooltip: string;
|
||||
if (onToggleLock) {
|
||||
@@ -226,7 +225,11 @@ function DashboardInfo({
|
||||
{hasTags && (
|
||||
<>
|
||||
<span className={styles.divider} />
|
||||
<div className={styles.dashboardTags} data-testid="dashboard-tags">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.dashboardTags}
|
||||
data-testid="dashboard-tags"
|
||||
>
|
||||
{visibleTags.map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BADGE_GAP,
|
||||
estimateBadgeWidth,
|
||||
OVERFLOW_BADGE_WIDTH,
|
||||
} from 'components/Alerts/LabelColumn/utils';
|
||||
|
||||
interface Result {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
visibleCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures how many tags fit in the container and returns the visible count,
|
||||
* reserving room for the `+N` overflow badge. Reuses the badge-width estimation
|
||||
* from the alerts LabelColumn so dashboards and alerts overflow identically.
|
||||
*/
|
||||
export function useVisibleTagCount(tags: string[]): Result {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(tags.length);
|
||||
|
||||
const calculateVisible = useCallback(
|
||||
(width: number): number => {
|
||||
if (width <= 0) {
|
||||
return 1;
|
||||
}
|
||||
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
|
||||
let usedWidth = 0;
|
||||
let count = 0;
|
||||
for (const tag of tags) {
|
||||
const badgeWidth = estimateBadgeWidth(tag) + BADGE_GAP;
|
||||
if (usedWidth + badgeWidth > availableWidth && count > 0) {
|
||||
break;
|
||||
}
|
||||
usedWidth += badgeWidth;
|
||||
count += 1;
|
||||
}
|
||||
return Math.max(1, count);
|
||||
},
|
||||
[tags],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry && entry.contentRect.width > 0) {
|
||||
setVisibleCount(calculateVisible(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
if (container.clientWidth > 0) {
|
||||
setVisibleCount(calculateVisible(container.clientWidth));
|
||||
}
|
||||
return (): void => observer.disconnect();
|
||||
}, [calculateVisible]);
|
||||
|
||||
return { containerRef, visibleCount };
|
||||
}
|
||||
@@ -78,12 +78,6 @@ function JsonEditorDrawer({
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (!readOnly) {
|
||||
@@ -91,7 +85,7 @@ function JsonEditorDrawer({
|
||||
}
|
||||
}
|
||||
},
|
||||
[apply, readOnly, onClose],
|
||||
[apply, readOnly],
|
||||
);
|
||||
|
||||
const applyDisabled = readOnly || !isDirty || !validity.valid || isSaving;
|
||||
|
||||
@@ -44,12 +44,8 @@ const dashboard = {
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
// The editor exposes `spec`, `tags`, `image` (in that order); every other key is redacted.
|
||||
const redacted = {
|
||||
spec: dashboard.spec,
|
||||
tags: dashboard.tags,
|
||||
image: dashboard.image,
|
||||
};
|
||||
// The editor only exposes `tags` and `spec`; every other key is redacted.
|
||||
const redacted = { tags: dashboard.tags, spec: dashboard.spec };
|
||||
const serialized = JSON.stringify(redacted, null, 2);
|
||||
|
||||
describe('useJsonEditor', () => {
|
||||
@@ -69,18 +65,17 @@ describe('useJsonEditor', () => {
|
||||
expect(result.current.validity.lineCount).toBe(serialized.split('\n').length);
|
||||
});
|
||||
|
||||
it('exposes spec/tags/image (in order) and redacts server-owned keys', () => {
|
||||
it('redacts server-owned keys from the editable draft', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.current.draft);
|
||||
// Key order is intentional: spec, then tags, then image.
|
||||
expect(Object.keys(parsed)).toStrictEqual(['spec', 'tags', 'image']);
|
||||
expect(parsed.image).toBe('icon.png');
|
||||
expect(Object.keys(parsed).sort()).toStrictEqual(['spec', 'tags']);
|
||||
expect(parsed.id).toBeUndefined();
|
||||
expect(parsed.name).toBeUndefined();
|
||||
expect(parsed.schemaVersion).toBeUndefined();
|
||||
expect(parsed.image).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flags invalid JSON with a line number and marks the draft dirty', () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { dashboardToUpdatable } from './dashboardToUpdatable';
|
||||
import { findPanelLayoutIssues } from './danglingPanels';
|
||||
import { compactSpecLayouts } from '../../layoutCompaction';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
export interface JsonValidity {
|
||||
@@ -47,16 +46,15 @@ interface Result {
|
||||
}
|
||||
|
||||
/**
|
||||
* The editable, user-facing view: `spec`, `tags` and `image`, in that key order.
|
||||
* Everything else (id, orgId, name, timestamps, locked, schemaVersion, …) is redacted
|
||||
* so it can't be seen, copied, exported or edited; those keys are preserved on save.
|
||||
* The editable, user-facing view: only `tags` and `spec`. Everything else
|
||||
* (id, orgId, name, timestamps, locked, schemaVersion, image, …) is redacted so it
|
||||
* can't be seen, copied, exported or edited; those keys are preserved on save (see `apply`).
|
||||
*/
|
||||
const redact = (
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): Pick<DashboardtypesGettableDashboardV2DTO, 'spec' | 'tags' | 'image'> => ({
|
||||
spec: dashboard.spec,
|
||||
): Pick<DashboardtypesGettableDashboardV2DTO, 'tags' | 'spec'> => ({
|
||||
tags: dashboard.tags,
|
||||
image: dashboard.image,
|
||||
spec: dashboard.spec,
|
||||
});
|
||||
|
||||
const serialize = (dashboard: DashboardtypesGettableDashboardV2DTO): string =>
|
||||
@@ -169,18 +167,7 @@ export function useJsonEditor({
|
||||
setIsSaving(true);
|
||||
// The draft only carries name/tags/spec; overlay it on the current dashboard
|
||||
// so the redacted fields (schemaVersion, image, …) are preserved on save.
|
||||
const edited = JSON.parse(draft) as Pick<
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
'spec' | 'tags' | 'image'
|
||||
>;
|
||||
// Snap hand-edited panel geometry to a non-overlapping layout so a JSON edit
|
||||
// can't be rejected by the backend's no-overlap check (matches drag/resize).
|
||||
if (edited.spec?.layouts) {
|
||||
edited.spec = {
|
||||
...edited.spec,
|
||||
layouts: compactSpecLayouts(edited.spec.layouts),
|
||||
};
|
||||
}
|
||||
const edited = JSON.parse(draft) as Record<string, unknown>;
|
||||
await updateDashboardV2(
|
||||
{ id: dashboardId },
|
||||
dashboardToUpdatable({ ...dashboard, ...edited }),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -27,28 +26,11 @@ function DashboardImagePicker({
|
||||
onChange,
|
||||
triggerClassName,
|
||||
}: Props): JSX.Element {
|
||||
// A custom image (pasted URL / base64 data-URI, not in the preset set) is kept as
|
||||
// a selectable option for the picker's lifetime — without a matching option the
|
||||
// trigger renders the raw value string and the image can't be re-selected.
|
||||
const [customImages, setCustomImages] = useState<string[]>(() =>
|
||||
image && !Base64Icons.includes(image) ? [image] : [],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (image && !Base64Icons.includes(image)) {
|
||||
setCustomImages((prev) => (prev.includes(image) ? prev : [...prev, image]));
|
||||
}
|
||||
}, [image]);
|
||||
|
||||
const options = useMemo(
|
||||
() => [...customImages, ...Base64Icons],
|
||||
[customImages],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select value={image} onChange={(value): void => onChange(value as string)}>
|
||||
<SelectTrigger className={cx(styles.trigger, triggerClassName)} />
|
||||
<SelectContent className={styles.options} withPortal={false}>
|
||||
{options.map((icon) => (
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem key={icon} value={icon} className={styles.item}>
|
||||
<img src={icon} alt="dashboard-icon" className={styles.image} />
|
||||
</SelectItem>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -27,7 +27,6 @@ function QueryVariableFields({
|
||||
onError,
|
||||
}: QueryVariableFieldsProps): JSX.Element {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const hasAutoRun = useRef(false);
|
||||
|
||||
const runTest = async (): Promise<void> => {
|
||||
setIsRunning(true);
|
||||
@@ -60,16 +59,6 @@ function QueryVariableFields({
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch options on load so the Default Value dropdown is populated without a
|
||||
// manual Test Run — once, and only when there's a query to run.
|
||||
useEffect(() => {
|
||||
if (!hasAutoRun.current && queryValue) {
|
||||
hasAutoRun.current = true;
|
||||
void runTest();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queryValue]);
|
||||
|
||||
return (
|
||||
<div className={styles.queryContainer}>
|
||||
<div className={styles.labelContainer}>
|
||||
|
||||
@@ -160,26 +160,6 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* The full "Not Recommended" pill — hidden once the tab row gets tight so it
|
||||
never forces horizontal scroll; the amber info icon takes over below. */
|
||||
.notRecommendedBadge {
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Amber info icon: always present, but the sole "not recommended" cue on small
|
||||
screens (its tooltip carries the message + learn-more link). */
|
||||
.notRecommendedInfo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Query */
|
||||
.queryContainer {
|
||||
display: flex;
|
||||
|
||||
@@ -130,9 +130,6 @@ function VariableForm({
|
||||
value={selectedPanelIds}
|
||||
onChange={(value): void => setSelectedPanelIds(value as string[])}
|
||||
data-testid="variable-apply-panels"
|
||||
// Resolve the closed-state tags to panel names (else they show the id).
|
||||
showLabels
|
||||
placement="topRight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,7 +215,7 @@ function VariableForm({
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
disabled={!!nameError || !!attributeError || isSaving}
|
||||
disabled={!!nameError || !!attributeError}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
testId="variable-save"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
@@ -68,23 +67,20 @@ function VariableTypeTabs(): JSX.Element {
|
||||
>
|
||||
<DatabaseZap size={14} />
|
||||
Query
|
||||
{/* Wide screens: the full "Not Recommended" pill. */}
|
||||
<Badge color="amber" className={styles.notRecommendedBadge}>
|
||||
<Badge color="amber" className={styles.betaTag}>
|
||||
Not Recommended
|
||||
</Badge>
|
||||
{/* Small screens: an amber info icon stands in for the pill (keeps the
|
||||
tab row from overflowing), its tooltip carries the same message + link. */}
|
||||
<span
|
||||
className={styles.notRecommendedInfo}
|
||||
className={styles.betaTag}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<TextToolTip
|
||||
text="Query variables can be slow and brittle, so they aren't recommended. Learn why"
|
||||
text="Learn why we don't recommend"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} color={Color.BG_AMBER_600} />}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
DYNAMIC_SIGNALS,
|
||||
emptyVariableFormModel,
|
||||
VARIABLE_SORT,
|
||||
type VariableFormModel,
|
||||
} from '../variableFormModel';
|
||||
import { useVariableForm, type UseVariableForm } from './useVariableForm';
|
||||
|
||||
// Mock the store (its full slice graph is huge to transform and irrelevant here;
|
||||
// the hook only reads dashboardId + variableValues for the Test-Run payload).
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: undefined, variableValues: {} }),
|
||||
}));
|
||||
|
||||
function initial(overrides?: Partial<VariableFormModel>): VariableFormModel {
|
||||
return {
|
||||
...emptyVariableFormModel(),
|
||||
type: 'QUERY',
|
||||
name: 'svc',
|
||||
defaultValue: 'foo',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const args = (
|
||||
init: VariableFormModel,
|
||||
): Parameters<typeof useVariableForm>[0] => ({
|
||||
initial: init,
|
||||
siblings: [],
|
||||
isNew: false,
|
||||
onSave: jest.fn(),
|
||||
});
|
||||
|
||||
// The hook resets its form state whenever the `initial` reference changes (open a
|
||||
// different variable), so the args must be stable across re-renders — otherwise a
|
||||
// fresh `initial` each render would loop the reset effect. Real callers memoize
|
||||
// `initial`; mirror that here by building the props once and closing over them.
|
||||
const renderForm = (
|
||||
props: Parameters<typeof useVariableForm>[0],
|
||||
): RenderHookResult<UseVariableForm, unknown> =>
|
||||
renderHook(() => useVariableForm(props));
|
||||
|
||||
describe('useVariableForm default reset — QUERY (on Test Run)', () => {
|
||||
it('keeps the default when only the sort order changes', () => {
|
||||
const { result } = renderForm(args(initial()));
|
||||
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
|
||||
// order-only change doesn't touch the preview values, so it must not reset
|
||||
act(() => result.current.set({ sort: VARIABLE_SORT.DESC }));
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
});
|
||||
|
||||
it('resets the default when a Test Run returns values that no longer contain it', () => {
|
||||
const { result } = renderForm(args(initial()));
|
||||
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
|
||||
act(() => result.current.setRawPreview(['bar', 'baz']));
|
||||
expect(result.current.defaultValue).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the default when a Test Run still contains it', () => {
|
||||
const { result } = renderForm(args(initial()));
|
||||
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
act(() => result.current.setRawPreview(['foo', 'baz', 'qux']));
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
});
|
||||
|
||||
it('keeps the default when a re-run yields the same values (no actual change)', () => {
|
||||
const { result } = renderForm(args(initial({ defaultValue: 'bar' })));
|
||||
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
// same set again — re-running an unchanged query must not disturb the default
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
expect(result.current.defaultValue).toBe('bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVariableForm default reset — DYNAMIC (on attribute/signal change)', () => {
|
||||
const dynamic = (overrides?: Partial<VariableFormModel>): VariableFormModel =>
|
||||
initial({
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: 'service.name',
|
||||
dynamicSignal: DYNAMIC_SIGNALS[1],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('does not reset on the passive edit-open auto-fetch', () => {
|
||||
// The auto-fetch populates the preview without going through onDynamicChange,
|
||||
// so opening an existing variable must never clear its saved default.
|
||||
const { result } = renderForm(args(dynamic({ defaultValue: 'zzz' })));
|
||||
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
expect(result.current.defaultValue).toBe('zzz');
|
||||
});
|
||||
|
||||
it('resets when the attribute changes', () => {
|
||||
const { result } = renderForm(args(dynamic()));
|
||||
|
||||
act(() => result.current.onDynamicChange({ dynamicAttribute: 'host.name' }));
|
||||
expect(result.current.defaultValue).toBe('');
|
||||
});
|
||||
|
||||
it('resets when the signal changes', () => {
|
||||
const { result } = renderForm(args(dynamic()));
|
||||
|
||||
act(() =>
|
||||
result.current.onDynamicChange({ dynamicSignal: DYNAMIC_SIGNALS[2] }),
|
||||
);
|
||||
expect(result.current.defaultValue).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the default when the attribute is set to the same value', () => {
|
||||
const { result } = renderForm(args(dynamic()));
|
||||
|
||||
act(() =>
|
||||
result.current.onDynamicChange({ dynamicAttribute: 'service.name' }),
|
||||
);
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVariableForm default reset — CUSTOM (on options edit)', () => {
|
||||
const custom = (overrides?: Partial<VariableFormModel>): VariableFormModel =>
|
||||
initial({ type: 'CUSTOM', ...overrides });
|
||||
|
||||
it('resets when the edited options no longer contain the default', () => {
|
||||
const { result } = renderForm(args(custom()));
|
||||
|
||||
act(() => result.current.onCustomChange('bar, baz'));
|
||||
expect(result.current.defaultValue).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the default when the edited options still contain it', () => {
|
||||
const { result } = renderForm(args(custom()));
|
||||
|
||||
act(() => result.current.onCustomChange('foo, bar, baz'));
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
@@ -92,35 +92,6 @@ export function useVariableForm({
|
||||
[rawPreview, model.sort],
|
||||
);
|
||||
|
||||
// QUERY: drop a now-invalid default when the user re-runs the query and the
|
||||
// returned values actually change. The query preview is populated only by the
|
||||
// manual "Test Run" (never on edit-open), so keying off `rawPreview` here is
|
||||
// effectively "on run"; the signature guard skips a re-run that yields the same
|
||||
// values so a still-valid default is left untouched. DYNAMIC/CUSTOM resets are
|
||||
// handled in their change handlers instead (see below).
|
||||
const lastQueryPreviewRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
lastQueryPreviewRef.current = null;
|
||||
}, [initial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (model.type !== 'QUERY' || rawPreview.length === 0) {
|
||||
return;
|
||||
}
|
||||
const optionValues = rawPreview.map(String);
|
||||
const signature = JSON.stringify(optionValues);
|
||||
if (signature === lastQueryPreviewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastQueryPreviewRef.current = signature;
|
||||
|
||||
// Clear a now-invalid default; resolution falls back to the first option/ALL.
|
||||
setDefaultValue((current) =>
|
||||
current && !optionValues.includes(current) ? '' : current,
|
||||
);
|
||||
}, [rawPreview, model.type]);
|
||||
|
||||
const existingNames = useMemo(() => siblings.map((v) => v.name), [siblings]);
|
||||
|
||||
const existingDynamicAttributes = useMemo(
|
||||
@@ -169,28 +140,12 @@ export function useVariableForm({
|
||||
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
const parsed = commaValuesParser(value);
|
||||
setRawPreview(parsed);
|
||||
|
||||
const optionValues = parsed.map(String);
|
||||
setDefaultValue((current) =>
|
||||
current && !optionValues.includes(current) ? '' : current,
|
||||
);
|
||||
setRawPreview(commaValuesParser(value));
|
||||
};
|
||||
|
||||
// In add mode, mirror the selected attribute into the name until the user
|
||||
// edits the name themselves (matches the V1 dynamic-variable behaviour).
|
||||
const onDynamicChange = (patch: Partial<VariableFormModel>): void => {
|
||||
const attributeChanged =
|
||||
patch.dynamicAttribute !== undefined &&
|
||||
patch.dynamicAttribute !== model.dynamicAttribute;
|
||||
const signalChanged =
|
||||
patch.dynamicSignal !== undefined &&
|
||||
patch.dynamicSignal !== model.dynamicSignal;
|
||||
if (attributeChanged || signalChanged) {
|
||||
setDefaultValue('');
|
||||
}
|
||||
|
||||
if (isNew && !nameTouched && patch.dynamicAttribute) {
|
||||
set({ ...patch, name: patch.dynamicAttribute });
|
||||
} else {
|
||||
|
||||
@@ -54,18 +54,11 @@ function VariableImpactDialog({
|
||||
useVariableImpactState(usages, open);
|
||||
|
||||
const isRename = mode === 'rename';
|
||||
const isDelete = mode === 'delete';
|
||||
const count = usages.length;
|
||||
const plural = count === 1 ? '' : 's';
|
||||
let intro: string;
|
||||
if (isRename) {
|
||||
intro = `$${variableName} is used in ${count} place${plural}. Review the updated queries before renaming to $${newName}.`;
|
||||
} else if (isDelete) {
|
||||
intro = `$${variableName} is used in ${count} place${plural}. Edit or remove each usage before deleting.`;
|
||||
} else {
|
||||
intro = `Applying $${variableName} can update upto ${count} panel quer${count === 1 ? 'y' : 'ies'}. Review the changes before applying.`;
|
||||
}
|
||||
const confirmLabel = isRename ? 'Rename' : isDelete ? 'Delete' : 'Apply';
|
||||
const intro = isRename
|
||||
? `$${variableName} is used in ${count} place${plural}. Review the updated queries before renaming to $${newName}.`
|
||||
: `$${variableName} is used in ${count} place${plural}. Edit or remove each usage before deleting.`;
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
@@ -80,13 +73,13 @@ function VariableImpactDialog({
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color={isDelete ? 'destructive' : 'primary'}
|
||||
color={isRename ? 'primary' : 'destructive'}
|
||||
loading={isLoading}
|
||||
onClick={(): void => onConfirm(resolvedUsages)}
|
||||
testId="variable-impact-confirm"
|
||||
>
|
||||
<Check size={12} />
|
||||
{confirmLabel}
|
||||
{isRename ? 'Rename' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -99,14 +92,7 @@ function VariableImpactDialog({
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
isRename
|
||||
? `Rename $${variableName}`
|
||||
: isDelete
|
||||
? `Delete $${variableName}`
|
||||
: `Apply $${variableName} to panels`
|
||||
}
|
||||
title={isRename ? `Rename $${variableName}` : `Delete $${variableName}`}
|
||||
width="wide"
|
||||
showCloseButton={false}
|
||||
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
|
||||
@@ -118,9 +104,7 @@ function VariableImpactDialog({
|
||||
<Typography.Text className={styles.intro}>{intro}</Typography.Text>
|
||||
<div className={styles.rows}>
|
||||
{rows.map((row) => {
|
||||
// Only warn on delete: an apply result is meant to reference the variable.
|
||||
const stillReferences =
|
||||
isDelete &&
|
||||
row.included &&
|
||||
textContainsVariableReference(row.resultingText, variableName);
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { DashboardtypesDashboardSpecDTOPanels } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTOPanels,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getPanelIdsReferencingVariable } from '../utils/applyVariableToPanelsPatch';
|
||||
import {
|
||||
buildApplyVariableToPanelsPatch,
|
||||
buildSyncVariableToPanelsPatch,
|
||||
getPanelIdsReferencingVariable,
|
||||
} from '../utils/applyVariableToPanelsPatch';
|
||||
|
||||
/** Minimal builder-query panel wrapped in a CompositeQuery, with a given filter. */
|
||||
function compositePanel(filterExpression: string): unknown {
|
||||
@@ -61,12 +68,153 @@ function listPanel(filterExpression: string): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
/** A PromQL panel — no builder filter, must be skipped. */
|
||||
function promqlPanel(): unknown {
|
||||
return {
|
||||
kind: 'panel',
|
||||
spec: {
|
||||
display: { title: '' },
|
||||
plugin: { kind: 'signoz/TimeSeries', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'promql',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/PromQLQuery', spec: { query: 'up' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the first builder query's filter expression out of a patch op's value. */
|
||||
function expressionOf(
|
||||
op: DashboardtypesJSONPatchOperationDTO,
|
||||
): string | undefined {
|
||||
const queries = op.value as Array<{
|
||||
spec: { plugin: { kind: string; spec: any } };
|
||||
}>;
|
||||
const { plugin } = queries[0].spec;
|
||||
const builderSpec =
|
||||
plugin.kind === 'signoz/CompositeQuery'
|
||||
? plugin.spec.queries[0].spec
|
||||
: plugin.spec;
|
||||
return builderSpec.filter?.expression;
|
||||
}
|
||||
|
||||
function panels(
|
||||
map: Record<string, unknown>,
|
||||
): DashboardtypesDashboardSpecDTOPanels {
|
||||
return map as DashboardtypesDashboardSpecDTOPanels;
|
||||
}
|
||||
|
||||
describe('buildApplyVariableToPanelsPatch', () => {
|
||||
it('appends the clause to an existing filter (AND-joined)', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('env = "prod"') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0]).toMatchObject({
|
||||
op: 'replace',
|
||||
path: '/spec/panels/p1/spec/queries',
|
||||
});
|
||||
expect(expressionOf(ops[0])).toBe('env = "prod" AND service.name IN $svc');
|
||||
});
|
||||
|
||||
it('sets the clause when the filter is empty', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('service.name IN $svc');
|
||||
});
|
||||
|
||||
it('applies to a bare BuilderQuery (LIST) panel', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: listPanel('') }),
|
||||
'k8s.pod.name',
|
||||
'pod',
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('k8s.pod.name IN $pod');
|
||||
});
|
||||
|
||||
it('is idempotent — re-applying does not duplicate the clause', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('service.name IN $svc') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('only targets the requested panels', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel(''), p2: compositePanel('') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
['p2'],
|
||||
);
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0].path).toBe('/spec/panels/p2/spec/queries');
|
||||
});
|
||||
|
||||
it('skips PromQL panels (no builder filter)', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: promqlPanel() }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns nothing when attribute or name is missing', () => {
|
||||
expect(
|
||||
buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('') }),
|
||||
'',
|
||||
'svc',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSyncVariableToPanelsPatch', () => {
|
||||
it('adds to selected panels and removes from the rest', () => {
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
panels({
|
||||
p1: compositePanel('service.name IN $svc'), // has it, not selected → remove
|
||||
p2: compositePanel(''), // selected → add
|
||||
p3: compositePanel('service.name IN $svc'), // has it, selected → unchanged
|
||||
}),
|
||||
'service.name',
|
||||
'svc',
|
||||
['p2', 'p3'],
|
||||
);
|
||||
const byPath = Object.fromEntries(ops.map((op) => [op.path, op]));
|
||||
expect(Object.keys(byPath).sort()).toStrictEqual([
|
||||
'/spec/panels/p1/spec/queries',
|
||||
'/spec/panels/p2/spec/queries',
|
||||
]);
|
||||
expect(expressionOf(byPath['/spec/panels/p1/spec/queries'])).toBe('');
|
||||
expect(expressionOf(byPath['/spec/panels/p2/spec/queries'])).toBe(
|
||||
'service.name IN $svc',
|
||||
);
|
||||
});
|
||||
|
||||
it('removing keeps other clauses intact', () => {
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('env = "prod" AND service.name IN $svc') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
[],
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('env = "prod"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPanelIdsReferencingVariable', () => {
|
||||
it('returns only panels whose filter references the variable', () => {
|
||||
const ids = getPanelIdsReferencingVariable(
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useVariableListActions } from '../hooks/useVariableListActions';
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../variableFormModel';
|
||||
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'd1' }),
|
||||
}));
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
function builderPanel(name: string, expression: string): unknown {
|
||||
return {
|
||||
spec: {
|
||||
display: { name },
|
||||
queries: [
|
||||
{
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/BuilderQuery', spec: { filter: { expression } } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dashboard(
|
||||
panels: Record<string, unknown>,
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return {
|
||||
spec: { panels, variables: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function dynamicVar(name: string, attribute: string): VariableFormModel {
|
||||
return {
|
||||
...emptyVariableFormModel(),
|
||||
name,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: attribute,
|
||||
};
|
||||
}
|
||||
|
||||
function renderActions(
|
||||
dash: DashboardtypesGettableDashboardV2DTO,
|
||||
variables: VariableFormModel[],
|
||||
) {
|
||||
return renderHook(() =>
|
||||
useVariableListActions({
|
||||
dashboard: dash,
|
||||
variables,
|
||||
setVariables: jest.fn(),
|
||||
isEditing: null,
|
||||
setIsEditing: jest.fn(),
|
||||
save: jest.fn().mockResolvedValue(true),
|
||||
patchAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('useVariableListActions — apply to all', () => {
|
||||
it('marks a variable applied-to-all only when every panel already references it', () => {
|
||||
const notApplied = renderActions(dashboard({ p1: builderPanel('P1', '') }), [
|
||||
dynamicVar('pod', 'k8s.pod.name'),
|
||||
]);
|
||||
expect(notApplied.result.current.appliedToAllNames.has('pod')).toBe(false);
|
||||
|
||||
const applied = renderActions(
|
||||
dashboard({ p1: builderPanel('P1', 'k8s.pod.name IN $pod') }),
|
||||
[dynamicVar('pod', 'k8s.pod.name')],
|
||||
);
|
||||
expect(applied.result.current.appliedToAllNames.has('pod')).toBe(true);
|
||||
});
|
||||
|
||||
it('requestApplyToAll opens an apply-mode impact for all panels', () => {
|
||||
const { result } = renderActions(
|
||||
dashboard({ p1: builderPanel('P1', ''), p2: builderPanel('P2', '') }),
|
||||
[dynamicVar('pod', 'k8s.pod.name')],
|
||||
);
|
||||
|
||||
act(() => result.current.requestApplyToAll(0));
|
||||
|
||||
const { impact } = result.current;
|
||||
expect(impact?.mode).toBe('apply');
|
||||
expect(impact?.origin).toBe('applyToAll');
|
||||
expect(impact?.variableName).toBe('pod');
|
||||
expect(impact?.usages.map((u) => u.sourceId).sort()).toStrictEqual([
|
||||
'p1',
|
||||
'p2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requestApplyToAll is a no-op when nothing is left to apply', () => {
|
||||
const { result } = renderActions(
|
||||
dashboard({ p1: builderPanel('P1', 'k8s.pod.name IN $pod') }),
|
||||
[dynamicVar('pod', 'k8s.pod.name')],
|
||||
);
|
||||
|
||||
act(() => result.current.requestApplyToAll(0));
|
||||
expect(result.current.impact).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../variableFormModel';
|
||||
import {
|
||||
findApplyUsages,
|
||||
findVariableUsages,
|
||||
isVariableAppliedToAllPanels,
|
||||
} from '../utils/variableUsages';
|
||||
import { findVariableUsages } from '../utils/variableUsages';
|
||||
|
||||
// Identity adapter so `spec.variables` can be plain form models in the test.
|
||||
jest.mock('../variableAdapters', () => ({
|
||||
@@ -100,98 +96,3 @@ describe('findVariableUsages', () => {
|
||||
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findApplyUsages', () => {
|
||||
const dash = dashboard(
|
||||
{
|
||||
empty: builderPanel('Empty', ''),
|
||||
ored: builderPanel('Ored', "a = 'x' OR b = 'y'"),
|
||||
has: builderPanel('Has it', 'k8s.pod.name IN $pod'),
|
||||
prom: promqlPanel('Prom', 'up'),
|
||||
promRef: promqlPanel('Prom Ref', 'up{pod="$pod"}'),
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
it('appends the clause to selected builder panels, parenthesising an OR', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', [
|
||||
'empty',
|
||||
'ored',
|
||||
]);
|
||||
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
|
||||
expect(byId['panel:empty:0']).toBe('k8s.pod.name IN $pod');
|
||||
expect(byId['panel:ored:0']).toBe(
|
||||
"(a = 'x' OR b = 'y') AND k8s.pod.name IN $pod",
|
||||
);
|
||||
});
|
||||
|
||||
it('skips a selected panel that already carries the clause (idempotent)', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['has']);
|
||||
expect(usages).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('removes the clause from an unselected builder panel that has it', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['empty']);
|
||||
const has = usages.find((u) => u.id === 'panel:has:0');
|
||||
expect(has?.resultingText).toBe('');
|
||||
});
|
||||
|
||||
it('lists a selected PromQL panel as an editable, unchanged row', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['prom']);
|
||||
const prom = usages.find((u) => u.id === 'panel:prom:0');
|
||||
expect(prom?.kind).toBe('promql');
|
||||
// Never auto-injected — the row defaults to the current text for manual edits.
|
||||
expect(prom?.currentText).toBe('up');
|
||||
expect(prom?.resultingText).toBe('up');
|
||||
});
|
||||
|
||||
it('skips a selected non-builder panel that already references the variable', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', [
|
||||
'promRef',
|
||||
]);
|
||||
expect(usages.some((u) => u.sourceId === 'promRef')).toBe(false);
|
||||
});
|
||||
|
||||
it('never touches unselected non-builder panels', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['empty']);
|
||||
expect(usages.some((u) => u.sourceId === 'prom')).toBe(false);
|
||||
expect(usages.some((u) => u.sourceId === 'promRef')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns nothing when every selected query already references the variable', () => {
|
||||
// The "applied to all" signal: builder carries the clause, PromQL references it.
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', [
|
||||
'has',
|
||||
'promRef',
|
||||
]);
|
||||
expect(usages).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVariableAppliedToAllPanels', () => {
|
||||
it('is true only when every panel query references the variable', () => {
|
||||
const covered = dashboard(
|
||||
{
|
||||
b: builderPanel('B', 'k8s.pod.name IN $pod'),
|
||||
p: promqlPanel('P', 'up{pod="$pod"}'),
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(isVariableAppliedToAllPanels(covered, 'k8s.pod.name', 'pod')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is false when any panel query is missing the reference', () => {
|
||||
const missing = dashboard(
|
||||
{
|
||||
b: builderPanel('B', 'k8s.pod.name IN $pod'),
|
||||
p: promqlPanel('P', 'up'),
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(isVariableAppliedToAllPanels(missing, 'k8s.pod.name', 'pod')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.body {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.variableName {
|
||||
font-family: 'Space Mono', monospace;
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
|
||||
import styles from './ApplyToAllDialog.module.scss';
|
||||
|
||||
interface ApplyToAllDialogProps {
|
||||
open: boolean;
|
||||
variableName: string;
|
||||
isLoading: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Confirms applying a dynamic variable as a filter to every panel. */
|
||||
function ApplyToAllDialog({
|
||||
open,
|
||||
variableName,
|
||||
isLoading,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ApplyToAllDialogProps): JSX.Element {
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isLoading}
|
||||
onClick={onConfirm}
|
||||
testId="confirm-apply-to-all"
|
||||
>
|
||||
<Check size={12} />
|
||||
Apply to all
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Apply variable to all panels"
|
||||
width="narrow"
|
||||
showCloseButton={false}
|
||||
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
|
||||
style={{ zIndex: 1100 }}
|
||||
showOverlay={false}
|
||||
footer={footer}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
Add <span className={styles.variableName}>${variableName}</span> as a filter
|
||||
to every panel on this dashboard. Panels that already reference it are left
|
||||
unchanged.
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApplyToAllDialog;
|
||||
@@ -64,7 +64,7 @@ $grid-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
|
||||
.rowActions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
top: 8px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -22,8 +22,6 @@ interface VariableRowProps {
|
||||
onCancelDelete: () => void;
|
||||
/** Apply this variable's filter to all panels. Dynamic variables only. */
|
||||
onApplyToAll: (index: number) => void;
|
||||
/** True when this dynamic variable is already applied to every panel. */
|
||||
isAppliedToAll: boolean;
|
||||
}
|
||||
|
||||
/** A single draggable variable row in the two-column (name / description) table. */
|
||||
@@ -37,7 +35,6 @@ function VariableRow({
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onApplyToAll,
|
||||
isAppliedToAll,
|
||||
}: VariableRowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
@@ -124,17 +121,12 @@ function VariableRow({
|
||||
{variable.type === 'DYNAMIC' ? (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
title={
|
||||
isAppliedToAll
|
||||
? 'Already applied to all panels'
|
||||
: 'Add this variable as a filter to every panel'
|
||||
}
|
||||
title="Add this variable as a filter to every panel"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={isAppliedToAll}
|
||||
className={styles.applyAllButton}
|
||||
onClick={(): void => onApplyToAll(index)}
|
||||
testId={`variable-apply-all-${variable.name}`}
|
||||
|
||||
@@ -26,8 +26,6 @@ interface VariablesListProps {
|
||||
onCancelDelete: () => void;
|
||||
onMove: (from: number, to: number) => void;
|
||||
onApplyToAll: (index: number) => void;
|
||||
/** Names of dynamic variables already applied to every panel. */
|
||||
appliedToAllNames: Set<string>;
|
||||
}
|
||||
|
||||
function VariablesList({
|
||||
@@ -40,7 +38,6 @@ function VariablesList({
|
||||
onCancelDelete,
|
||||
onMove,
|
||||
onApplyToAll,
|
||||
appliedToAllNames,
|
||||
}: VariablesListProps): JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
|
||||
@@ -85,7 +82,6 @@ function VariablesList({
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onCancelDelete={onCancelDelete}
|
||||
onApplyToAll={onApplyToAll}
|
||||
isAppliedToAll={appliedToAllNames.has(variable.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -14,6 +13,7 @@ import type {
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { buildSyncVariableToPanelsPatch } from '../utils/applyVariableToPanelsPatch';
|
||||
import {
|
||||
VARIABLE_TYPE_EVENT_LABEL,
|
||||
type VariableFormModel,
|
||||
@@ -23,9 +23,7 @@ import {
|
||||
buildVariableImpactPatch,
|
||||
} from '../utils/variableImpactPatch';
|
||||
import {
|
||||
findApplyUsages,
|
||||
findVariableUsages,
|
||||
isVariableAppliedToAllPanels,
|
||||
type VariableImpactMode,
|
||||
type VariableUsage,
|
||||
} from '../utils/variableUsages';
|
||||
@@ -42,8 +40,6 @@ export interface VariableImpact {
|
||||
newName?: string;
|
||||
usages: VariableUsage[];
|
||||
nextVariables: VariableFormModel[];
|
||||
/** Where an `apply` impact came from — drives the confirm analytics event. */
|
||||
origin?: 'form' | 'applyToAll';
|
||||
}
|
||||
|
||||
interface UseVariableListActionsParams {
|
||||
@@ -68,9 +64,6 @@ interface UseVariableListActions {
|
||||
handleMove: (from: number, to: number) => void;
|
||||
requestDelete: (index: number) => void;
|
||||
handleConfirmDelete: (index: number) => void;
|
||||
requestApplyToAll: (index: number) => void;
|
||||
/** Names of dynamic variables already applied to every panel (button disabled). */
|
||||
appliedToAllNames: Set<string>;
|
||||
handleImpactConfirm: (resolvedUsages: VariableUsage[]) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -114,52 +107,60 @@ export function useVariableListActions({
|
||||
next[editingIndex] = formModel;
|
||||
}
|
||||
|
||||
const isRename = !!oldName && oldName !== formModel.name;
|
||||
// Both rename and apply-to-panels edits are reviewed in the impact dialog
|
||||
// before persisting — never applied silently.
|
||||
const renameUsages = isRename
|
||||
? findVariableUsages(dashboard, oldName as string, 'rename', formModel.name)
|
||||
: [];
|
||||
const applyUsages =
|
||||
formModel.type === 'DYNAMIC' && formModel.dynamicAttribute
|
||||
? findApplyUsages(
|
||||
dashboard,
|
||||
formModel.dynamicAttribute,
|
||||
formModel.name,
|
||||
oldName ?? formModel.name,
|
||||
selectedPanelIds,
|
||||
)
|
||||
: [];
|
||||
|
||||
// Apply usages win per (panel, envelope) over a plain rename rewrite.
|
||||
const byId = new Map<string, VariableUsage>();
|
||||
renameUsages.forEach((usage) => byId.set(usage.id, usage));
|
||||
applyUsages.forEach((usage) => byId.set(usage.id, usage));
|
||||
const usages = [...byId.values()];
|
||||
|
||||
if (usages.length > 0) {
|
||||
setIsEditing(null);
|
||||
setImpact({
|
||||
mode: isRename ? 'rename' : 'apply',
|
||||
variableName: oldName ?? formModel.name,
|
||||
newName: formModel.name,
|
||||
usages,
|
||||
nextVariables: next,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No cross-query impact — persist directly; keep the form open on failure.
|
||||
void (async (): Promise<void> => {
|
||||
const saved = await save(next);
|
||||
if (!saved) {
|
||||
// A rename that other queries/variables reference must be reviewed first, so
|
||||
// the references are rewritten alongside the rename (never left dangling).
|
||||
if (oldName && oldName !== formModel.name) {
|
||||
const usages = findVariableUsages(
|
||||
dashboard,
|
||||
oldName,
|
||||
'rename',
|
||||
formModel.name,
|
||||
);
|
||||
if (usages.length > 0) {
|
||||
setIsEditing(null);
|
||||
setImpact({
|
||||
mode: 'rename',
|
||||
variableName: oldName,
|
||||
newName: formModel.name,
|
||||
usages,
|
||||
nextVariables: next,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsEditing(null);
|
||||
setVariables(next);
|
||||
}
|
||||
|
||||
setIsEditing(null);
|
||||
setVariables(next);
|
||||
void (async (): Promise<void> => {
|
||||
const saved = await save(next);
|
||||
if (!saved || formModel.type !== 'DYNAMIC') {
|
||||
return;
|
||||
}
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
dashboard.spec.panels,
|
||||
formModel.dynamicAttribute,
|
||||
formModel.name,
|
||||
selectedPanelIds,
|
||||
);
|
||||
if (ops.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
} catch {
|
||||
toast.error('Could not update panels');
|
||||
}
|
||||
})();
|
||||
},
|
||||
[dashboard, isEditing, save, setIsEditing, setVariables, variables],
|
||||
[
|
||||
dashboard,
|
||||
isEditing,
|
||||
patchAsync,
|
||||
save,
|
||||
setIsEditing,
|
||||
setVariables,
|
||||
variables,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMove = useCallback(
|
||||
@@ -217,57 +218,6 @@ export function useVariableListActions({
|
||||
[dashboard, variables],
|
||||
);
|
||||
|
||||
// "Apply to all": review the additive changes across every panel before applying.
|
||||
const requestApplyToAll = useCallback(
|
||||
(index: number): void => {
|
||||
const variable = variables[index];
|
||||
if (!variable || variable.type !== 'DYNAMIC' || !variable.dynamicAttribute) {
|
||||
return;
|
||||
}
|
||||
const allPanelIds = Object.keys(dashboard.spec.panels ?? {});
|
||||
const usages = findApplyUsages(
|
||||
dashboard,
|
||||
variable.dynamicAttribute,
|
||||
variable.name,
|
||||
variable.name,
|
||||
allPanelIds,
|
||||
);
|
||||
if (usages.length === 0) {
|
||||
return;
|
||||
}
|
||||
setImpact({
|
||||
mode: 'apply',
|
||||
variableName: variable.name,
|
||||
newName: variable.name,
|
||||
usages,
|
||||
nextVariables: variables,
|
||||
origin: 'applyToAll',
|
||||
});
|
||||
},
|
||||
[dashboard, variables],
|
||||
);
|
||||
|
||||
// A dynamic variable is "applied to all" when every panel query already
|
||||
// references it — i.e. the apply review would be empty. Disables the button.
|
||||
const appliedToAllNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
variables.forEach((variable) => {
|
||||
if (variable.type !== 'DYNAMIC' || !variable.dynamicAttribute) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isVariableAppliedToAllPanels(
|
||||
dashboard,
|
||||
variable.dynamicAttribute,
|
||||
variable.name,
|
||||
)
|
||||
) {
|
||||
names.add(variable.name);
|
||||
}
|
||||
});
|
||||
return names;
|
||||
}, [dashboard, variables]);
|
||||
|
||||
// Applies a resolved rename/delete: the variables array (rename/delete + edited
|
||||
// variable queries) and each touched panel's queries, in one atomic patch.
|
||||
const handleImpactConfirm = useCallback(
|
||||
@@ -287,15 +237,11 @@ export function useVariableListActions({
|
||||
setVariables(nextVariables);
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
let message: string;
|
||||
if (impact.mode === 'rename') {
|
||||
message = `Renamed to $${impact.newName}`;
|
||||
} else if (impact.mode === 'apply') {
|
||||
message = `Applied $${impact.variableName} to panels`;
|
||||
} else {
|
||||
message = `Deleted $${impact.variableName}`;
|
||||
}
|
||||
toast.success(message);
|
||||
toast.success(
|
||||
impact.mode === 'rename'
|
||||
? `Renamed to $${impact.newName}`
|
||||
: `Deleted $${impact.variableName}`,
|
||||
);
|
||||
if (impact.mode === 'delete') {
|
||||
const deleted = variables.find((v) => v.name === impact.variableName);
|
||||
void logEvent(DashboardDetailEvents.VariableDeleted, {
|
||||
@@ -305,22 +251,13 @@ export function useVariableListActions({
|
||||
hadReferences: true,
|
||||
dashboardId,
|
||||
});
|
||||
} else if (impact.mode === 'apply' && impact.origin === 'applyToAll') {
|
||||
void logEvent(DashboardDetailEvents.ApplyToAllConfirmed, {
|
||||
variableType: 'dynamic',
|
||||
dashboardId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
let message: string;
|
||||
if (impact.mode === 'rename') {
|
||||
message = 'Could not rename the variable';
|
||||
} else if (impact.mode === 'apply') {
|
||||
message = 'Could not apply the variable to panels';
|
||||
} else {
|
||||
message = 'Could not delete the variable';
|
||||
}
|
||||
toast.error(message);
|
||||
toast.error(
|
||||
impact.mode === 'rename'
|
||||
? 'Could not rename the variable'
|
||||
: 'Could not delete the variable',
|
||||
);
|
||||
}
|
||||
setImpact(null);
|
||||
},
|
||||
@@ -336,8 +273,6 @@ export function useVariableListActions({
|
||||
handleMove,
|
||||
requestDelete,
|
||||
handleConfirmDelete,
|
||||
requestApplyToAll,
|
||||
appliedToAllNames,
|
||||
handleImpactConfirm,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import settingsStyles from '../DashboardSettings.module.scss';
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { getPanelIdsReferencingVariable } from './utils/applyVariableToPanelsPatch';
|
||||
import {
|
||||
buildApplyVariableToPanelsPatch,
|
||||
getPanelIdsReferencingVariable,
|
||||
} from './utils/applyVariableToPanelsPatch';
|
||||
import { useSaveVariables } from './hooks/useSaveVariables';
|
||||
import { useVariableListActions } from './hooks/useVariableListActions';
|
||||
import { dtoToFormModel } from './variableAdapters';
|
||||
@@ -18,6 +24,7 @@ import VariableImpactDialog from './VariableImpactDialog/VariableImpactDialog';
|
||||
import VariablesList from './components/VariablesList/VariablesList';
|
||||
import styles from './Variables.module.scss';
|
||||
import AddVariableButton from './components/AddVariableButton';
|
||||
import ApplyToAllDialog from './components/ApplyToAllDialog/ApplyToAllDialog';
|
||||
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
|
||||
import { EditingState } from './types';
|
||||
|
||||
@@ -27,6 +34,7 @@ interface VariablesSettingsProps {
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
// The drawer destroys on close, so reading this once on mount is enough to
|
||||
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
|
||||
const openAddOnMount = useDashboardStore(
|
||||
@@ -51,6 +59,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const [isEditing, setIsEditing] = useState<EditingState>(
|
||||
openAddOnMount && isEditable ? { type: 'new' } : null,
|
||||
);
|
||||
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
|
||||
|
||||
const {
|
||||
confirmDeleteIndex,
|
||||
@@ -61,8 +70,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
handleMove,
|
||||
requestDelete,
|
||||
handleConfirmDelete,
|
||||
requestApplyToAll,
|
||||
appliedToAllNames,
|
||||
handleImpactConfirm,
|
||||
} = useVariableListActions({
|
||||
dashboard,
|
||||
@@ -109,6 +116,36 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
);
|
||||
}, [editingFormModel, dashboard.spec.panels]);
|
||||
|
||||
const applyToAllVariable =
|
||||
applyToAllIndex === null ? null : variables[applyToAllIndex];
|
||||
|
||||
const handleConfirmApplyToAll = async (): Promise<void> => {
|
||||
if (!applyToAllVariable) {
|
||||
return;
|
||||
}
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
dashboard.spec.panels,
|
||||
applyToAllVariable.dynamicAttribute,
|
||||
applyToAllVariable.name,
|
||||
);
|
||||
if (ops.length === 0) {
|
||||
toast.info('No panels needed this filter.');
|
||||
setApplyToAllIndex(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
toast.success(`Applied $${applyToAllVariable.name} to all panels`);
|
||||
void logEvent(DashboardDetailEvents.ApplyToAllConfirmed, {
|
||||
variableType: 'dynamic',
|
||||
dashboardId,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Could not apply the variable to panels');
|
||||
}
|
||||
setApplyToAllIndex(null);
|
||||
};
|
||||
|
||||
if (editingFormModel) {
|
||||
return (
|
||||
<VariableForm
|
||||
@@ -139,14 +176,20 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
onApplyToAll={requestApplyToAll}
|
||||
appliedToAllNames={appliedToAllNames}
|
||||
onApplyToAll={(index): void => setApplyToAllIndex(index)}
|
||||
/>
|
||||
<div className={styles.footer}>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ApplyToAllDialog
|
||||
open={applyToAllVariable !== null}
|
||||
variableName={applyToAllVariable?.name ?? ''}
|
||||
isLoading={isPatching}
|
||||
onConfirm={(): void => void handleConfirmApplyToAll()}
|
||||
onClose={(): void => setApplyToAllIndex(null)}
|
||||
/>
|
||||
<VariableImpactDialog
|
||||
open={impact !== null}
|
||||
mode={impact?.mode ?? 'delete'}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTOPanels,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
// Injects/removes a dynamic variable's filter (`attribute IN $name`) in panel
|
||||
// builder queries as JSON-Patch ops. Only builder queries carry a filter.
|
||||
|
||||
function clauseFor(attribute: string, variableName: string): string {
|
||||
return `${attribute} IN $${variableName}`;
|
||||
}
|
||||
|
||||
/** Runs `fn` on every builder-query spec in a panel's single query (Composite or bare Builder). */
|
||||
function forEachBuilderSpec(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
fn: (spec: Querybuildertypesv5BuilderQuerySpecDTO) => void,
|
||||
@@ -33,6 +39,48 @@ function forEachBuilderSpec(
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends the clause to every builder query's filter. Returns whether anything changed. */
|
||||
function addClause(queries: DashboardtypesQueryDTO[], clause: string): boolean {
|
||||
let changed = false;
|
||||
forEachBuilderSpec(queries, (spec) => {
|
||||
const existing = spec.filter?.expression?.trim();
|
||||
// Idempotent: a repeated apply must not stack duplicate clauses.
|
||||
if (existing?.includes(clause)) {
|
||||
return;
|
||||
}
|
||||
spec.filter = {
|
||||
expression: existing ? `${existing} AND ${clause}` : clause,
|
||||
};
|
||||
changed = true;
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Removes the managed clause (an ` AND `-joined part) from every builder filter. */
|
||||
function removeClause(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
clause: string,
|
||||
): boolean {
|
||||
let changed = false;
|
||||
forEachBuilderSpec(queries, (spec) => {
|
||||
const existing = spec.filter?.expression;
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
const parts = existing
|
||||
.split(' AND ')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const kept = parts.filter((part) => part !== clause);
|
||||
if (kept.length !== parts.length) {
|
||||
spec.filter = { expression: kept.join(' AND ') };
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Whether any builder query in the panel already carries the clause. */
|
||||
function panelHasClause(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
clause: string,
|
||||
@@ -46,6 +94,74 @@ function panelHasClause(
|
||||
return has;
|
||||
}
|
||||
|
||||
function replaceQueriesOp(
|
||||
panelId: string,
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: `/spec/panels/${panelId}/spec/queries`,
|
||||
value: queries,
|
||||
};
|
||||
}
|
||||
|
||||
/** Add-only: inject the variable's filter into the given panels (default: all). */
|
||||
export function buildApplyVariableToPanelsPatch(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
targetPanelIds?: string[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
if (!attribute || !variableName) {
|
||||
return [];
|
||||
}
|
||||
const clause = clauseFor(attribute, variableName);
|
||||
const ids = targetPanelIds ?? Object.keys(panels);
|
||||
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
ids.forEach((id) => {
|
||||
const panel = panels[id];
|
||||
if (!panel?.spec?.queries?.length) {
|
||||
return;
|
||||
}
|
||||
const queries = cloneDeep(panel.spec.queries);
|
||||
if (addClause(queries, clause)) {
|
||||
ops.push(replaceQueriesOp(id, queries));
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Full sync: selected panels get the clause; every other panel has it removed. */
|
||||
export function buildSyncVariableToPanelsPatch(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
selectedPanelIds: string[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
if (!attribute || !variableName) {
|
||||
return [];
|
||||
}
|
||||
const clause = clauseFor(attribute, variableName);
|
||||
const selected = new Set(selectedPanelIds);
|
||||
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
Object.keys(panels).forEach((id) => {
|
||||
const panel = panels[id];
|
||||
if (!panel?.spec?.queries?.length) {
|
||||
return;
|
||||
}
|
||||
const queries = cloneDeep(panel.spec.queries);
|
||||
const changed = selected.has(id)
|
||||
? addClause(queries, clause)
|
||||
: removeClause(queries, clause);
|
||||
if (changed) {
|
||||
ops.push(replaceQueriesOp(id, queries));
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Panel ids whose queries currently reference the variable — pre-populates the picker. */
|
||||
export function getPanelIdsReferencingVariable(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
|
||||
@@ -2,10 +2,7 @@ import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
appendAndClause,
|
||||
removeVariableFromExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { removeVariableFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
rewriteVariableReferences,
|
||||
textContainsVariableReference,
|
||||
@@ -21,7 +18,8 @@ export type VariableUsageKind =
|
||||
| 'clickhouse'
|
||||
| 'variable';
|
||||
|
||||
export type VariableImpactMode = 'rename' | 'delete' | 'apply';
|
||||
/** Whether the impact is a rename (rewrite refs) or a delete (remove refs). */
|
||||
export type VariableImpactMode = 'rename' | 'delete';
|
||||
|
||||
/**
|
||||
* One place a variable is referenced — a panel query's builder filter expression,
|
||||
@@ -163,118 +161,3 @@ export function findVariableUsages(
|
||||
|
||||
return usages;
|
||||
}
|
||||
|
||||
// `matchName` is the name in the current query text (the old name during a
|
||||
// simultaneous rename); `injectName` is written into newly added clauses. Equal
|
||||
// outside of a rename.
|
||||
export function findApplyUsages(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
attribute: string,
|
||||
injectName: string,
|
||||
matchName: string,
|
||||
selectedPanelIds: string[],
|
||||
): VariableUsage[] {
|
||||
if (!attribute || !injectName) {
|
||||
return [];
|
||||
}
|
||||
const clause = `${attribute} IN $${injectName}`;
|
||||
const existingClause = `${attribute} IN $${matchName}`;
|
||||
const selected = new Set(selectedPanelIds);
|
||||
const usages: VariableUsage[] = [];
|
||||
|
||||
Object.entries(dashboard.spec.panels ?? {}).forEach(([panelId, panel]) => {
|
||||
const queries = panel?.spec?.queries;
|
||||
if (!queries?.length) {
|
||||
return;
|
||||
}
|
||||
toQueryEnvelopes(queries).forEach((envelope, index) => {
|
||||
const pushUsage = (
|
||||
kind: VariableUsageKind,
|
||||
currentText: string,
|
||||
resultingText: string,
|
||||
): void => {
|
||||
usages.push({
|
||||
id: `panel:${panelId}:${index}`,
|
||||
sourceType: 'panel',
|
||||
sourceId: panelId,
|
||||
sourceLabel: panel.spec?.display?.name || panelId,
|
||||
kind,
|
||||
envelopeIndex: index,
|
||||
currentText,
|
||||
resultingText,
|
||||
});
|
||||
};
|
||||
|
||||
if (envelope.type === 'builder_query') {
|
||||
const spec = envelope.spec as
|
||||
| { filter?: { expression?: string } }
|
||||
| undefined;
|
||||
const current = spec?.filter?.expression ?? '';
|
||||
if (selected.has(panelId)) {
|
||||
// Already carries the clause (under either name) — a rename usage, if
|
||||
// any, fixes the name; don't append a duplicate.
|
||||
if (current.includes(clause) || current.includes(existingClause)) {
|
||||
return;
|
||||
}
|
||||
pushUsage('builder', current, appendAndClause(current, clause));
|
||||
} else {
|
||||
const next = removeVariableFromExpression(current, matchName);
|
||||
if (next !== current) {
|
||||
pushUsage('builder', current, next);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// PromQL/ClickHouse can't carry the managed clause — never auto-inject or
|
||||
// remove. Selected panels still get an editable row defaulting to the
|
||||
// current text, unless the query already references the variable.
|
||||
if (!selected.has(panelId)) {
|
||||
return;
|
||||
}
|
||||
const ref = envelopeReferenceText(envelope);
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
textContainsVariableReference(ref.text, injectName) ||
|
||||
textContainsVariableReference(ref.text, matchName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pushUsage(ref.kind, ref.text, ref.text);
|
||||
});
|
||||
});
|
||||
|
||||
return usages;
|
||||
}
|
||||
|
||||
// Cheap yes/no check for the "Apply to all" disabled state: does every panel query
|
||||
// already reference the variable? Plain string checks — no ANTLR, no rewriting.
|
||||
export function isVariableAppliedToAllPanels(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
): boolean {
|
||||
if (!attribute || !variableName) {
|
||||
return false;
|
||||
}
|
||||
const clause = `${attribute} IN $${variableName}`;
|
||||
const panels = dashboard.spec.panels ?? {};
|
||||
return Object.values(panels).every((panel) => {
|
||||
const queries = panel?.spec?.queries;
|
||||
if (!queries?.length) {
|
||||
return true;
|
||||
}
|
||||
return toQueryEnvelopes(queries).every((envelope) => {
|
||||
if (envelope.type === 'builder_query') {
|
||||
const spec = envelope.spec as
|
||||
| { filter?: { expression?: string } }
|
||||
| undefined;
|
||||
return (spec?.filter?.expression ?? '').includes(clause);
|
||||
}
|
||||
const ref = envelopeReferenceText(envelope);
|
||||
return ref ? textContainsVariableReference(ref.text, variableName) : true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export const SECTION_REGISTRY: {
|
||||
[SectionKind.ContextLinks]: {
|
||||
Component: ContextLinksSection,
|
||||
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
|
||||
get: (spec): DashboardtypesLinkDTO[] => spec.links || [],
|
||||
get: (spec): DashboardtypesLinkDTO[] => spec.links,
|
||||
update: (spec, links): PanelSpec => ({ ...spec, links }),
|
||||
},
|
||||
// One editor for every threshold variant (label / comparison / table); the kind's
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
|
||||
|
||||
import { resolveContextLinkUrl } from './resolveContextLinkUrl';
|
||||
@@ -15,7 +15,7 @@ export interface ResolvedDrilldownLink {
|
||||
* label + URL, drops links without a URL, and skips substitution when `renderVariables === false`.
|
||||
*/
|
||||
export function resolvePanelContextLinks(
|
||||
links: DashboardtypesPanelSpecDTO['links'],
|
||||
links: DashboardtypesLinkDTO[] | undefined,
|
||||
processedVariables: Record<string, string>,
|
||||
): ResolvedDrilldownLink[] {
|
||||
const usable = (links ?? []).filter((link) => !!link.url);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getAggregateColumnHeader } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
@@ -29,7 +29,7 @@ interface DrilldownAggregateMenuProps {
|
||||
/** While dashboard variables resolve, the actions show a spinner and are disabled. */
|
||||
isResolving?: boolean;
|
||||
/** Panel's context links; resolved against the clicked point + variables here. */
|
||||
links: DashboardtypesPanelSpecDTO['links'];
|
||||
links: DashboardtypesLinkDTO[] | undefined;
|
||||
/** Whether the clicked point exposes group-by fields to bind to dashboard variables. */
|
||||
canSetDashboardVariables: boolean;
|
||||
onViewLogs: () => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
padding: 12px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Modal } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import styles from './SectionTitleModal.module.scss';
|
||||
|
||||
interface SectionTitleModalProps {
|
||||
open: boolean;
|
||||
@@ -19,7 +17,7 @@ interface SectionTitleModalProps {
|
||||
onSubmit: (title: string) => void;
|
||||
}
|
||||
|
||||
/** Title-entry modal shared by section create and rename (mirrors RenameDashboardModal). */
|
||||
/** Title-entry modal shared by section create and rename. */
|
||||
function SectionTitleModal({
|
||||
open,
|
||||
heading,
|
||||
@@ -39,51 +37,22 @@ function SectionTitleModal({
|
||||
}
|
||||
}, [open, initialValue]);
|
||||
|
||||
// `!isSaving` also guards a second submit (e.g. a double Enter) while a request
|
||||
// is in flight — otherwise two sections would be created.
|
||||
const canSave = value.trim().length > 0 && !isSaving;
|
||||
|
||||
const submit = (): void => {
|
||||
if (!canSave) {
|
||||
return;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
onSubmit(trimmed);
|
||||
}
|
||||
onSubmit(value.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title={heading}
|
||||
<Modal
|
||||
open={open}
|
||||
width="narrow"
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="section-title-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={!canSave}
|
||||
loading={isSaving}
|
||||
onClick={submit}
|
||||
testId="section-title-submit"
|
||||
>
|
||||
{okText}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
title={heading}
|
||||
onCancel={onClose}
|
||||
onOk={submit}
|
||||
okText={okText}
|
||||
okButtonProps={{ disabled: isSaving || !value.trim() }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
testId="section-title-input"
|
||||
@@ -93,13 +62,13 @@ function SectionTitleModal({
|
||||
placeholder={placeholder}
|
||||
onChange={(e): void => setValue(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' && canSave) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogWrapper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ interface Params {
|
||||
}
|
||||
|
||||
interface Result {
|
||||
addSection: (title: string) => Promise<boolean>;
|
||||
addSection: (title: string) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
@@ -38,10 +38,10 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
|
||||
const addSection = useCallback(
|
||||
async (title: string): Promise<boolean> => {
|
||||
async (title: string): Promise<void> => {
|
||||
const trimmed = title.trim();
|
||||
if (!dashboardId || !trimmed) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
const isFirstSection = !layouts || layouts.length === 0;
|
||||
const op = isFirstSection
|
||||
@@ -58,10 +58,8 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
// key it the way `getSectionStableId` does so it reveals itself on render.
|
||||
const newIndex = isFirstSection ? 0 : layouts.length;
|
||||
setScrollTargetId(getSectionStableId([], newIndex));
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { compactGridItems } from '../../../layoutCompaction';
|
||||
import { replaceSectionItemsOp } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { GridItem } from '../../../utils';
|
||||
@@ -77,9 +76,7 @@ export function usePersistLayout({ layoutIndex, items }: Params): Result {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
// Compact to the snapped, non-overlapping layout RGL shows on-screen, so
|
||||
// the persisted geometry can never trip the backend's no-overlap check.
|
||||
const nextItems = compactGridItems(mergeRglLayout(rglLayout, items));
|
||||
const nextItems = mergeRglLayout(rglLayout, items);
|
||||
if (!hasGeometryChanged(nextItems, items)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,47 +86,6 @@
|
||||
:global(.ant-select-selection-overflow) {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
// The shared NewSelect selector scrolls (max-height: 200px; overflow: auto),
|
||||
// which surfaces horizontal + vertical scrollbars inside the fixed-width
|
||||
// variable pill. Clip instead — the value is a single line (maxTagCount + "+N"),
|
||||
// so there is nothing to scroll to.
|
||||
:global(.ant-select-selector) {
|
||||
max-height: none !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
// Let the first tag shrink and truncate so the layout stays [tag…][+N]. Without
|
||||
// this the "+N" overflow item wins the row's space and the (longer) first tag
|
||||
// gets offset/clipped — visible after switching a value from ALL to a subset.
|
||||
:global(.ant-select-selection-overflow-item) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// The shared .custom-multiselect forces the search box to a 60px floor
|
||||
// (min-width: 60px !important). In this narrow variable pill that reserved
|
||||
// width is what offsets/clips the tag row, so collapse it here — the extra
|
||||
// selectors out-specify the shared rule's !important. It still expands to fit
|
||||
// what the user types while the dropdown is open.
|
||||
:global(.ant-select-selector .ant-select-selection-search) {
|
||||
min-width: 0 !important;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
:global(
|
||||
.ant-select-selector
|
||||
.ant-select-selection-search
|
||||
.ant-select-selection-search-input
|
||||
) {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.overflowTooltip {
|
||||
|
||||
@@ -107,18 +107,6 @@ describe('reconcileWithOptions', () => {
|
||||
).toStrictEqual({ value: ['a', 'b'], allSelected: false });
|
||||
});
|
||||
|
||||
it('preserves a user-entered single value not in the options (freeform)', () => {
|
||||
// A typed value that isn't among the fetched options must survive a refetch
|
||||
// (e.g. a time-range change) rather than being reset to the default.
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
model({ type: 'QUERY' }),
|
||||
{ value: 'typed-value', allSelected: false },
|
||||
['a', 'b'],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the configured default (else first) when invalid', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
.addVariableNameWithIcon {
|
||||
flex: none;
|
||||
border-style: dashed !important;
|
||||
border-color: var(--l3-border) !important;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.addVariableIcon {
|
||||
.addVariable {
|
||||
flex: none;
|
||||
border-style: dashed !important;
|
||||
border-color: var(--l3-border) !important;
|
||||
|
||||
@@ -16,7 +16,7 @@ function AddVariableFull(): JSX.Element {
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className={styles.addVariableNameWithIcon}
|
||||
className={styles.addVariable}
|
||||
prefix={<Plus size={14} />}
|
||||
testId="dashboard-variables-add"
|
||||
onClick={(): void =>
|
||||
|
||||
@@ -19,7 +19,7 @@ function AddVariableIcon(): JSX.Element {
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.addVariableIcon}
|
||||
className={styles.addVariable}
|
||||
aria-label="Add variable"
|
||||
testId="dashboard-variables-add"
|
||||
onClick={(): void =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.variableItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 180px;
|
||||
width: 160px;
|
||||
flex-direction: column;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { SolidInfoCircle } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
|
||||
import { Tooltip } from 'antd';
|
||||
@@ -99,7 +99,7 @@ function VariableSelector({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Info className={styles.infoIcon} size={12} />
|
||||
<SolidInfoCircle className={styles.infoIcon} size={12} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import type { OptionData } from 'components/NewSelect/types';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import type { VariableSelection } from '../../selectionTypes';
|
||||
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
|
||||
interface ValueSelectorProps {
|
||||
@@ -17,13 +16,17 @@ interface ValueSelectorProps {
|
||||
loading?: boolean;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
emptyFallback: VariableSelection;
|
||||
testId?: string;
|
||||
/** Option-fetch error surfaced in the dropdown, with a retry action. */
|
||||
errorMessage?: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
|
||||
* shared NewSelect components, which provide search, the "ALL" option and
|
||||
* apply-on-close batching (so multi-select edits don't cascade per toggle).
|
||||
*/
|
||||
function ValueSelector({
|
||||
options,
|
||||
variableType,
|
||||
@@ -32,7 +35,6 @@ function ValueSelector({
|
||||
loading,
|
||||
selection,
|
||||
onChange,
|
||||
emptyFallback,
|
||||
testId,
|
||||
errorMessage,
|
||||
onRetry,
|
||||
@@ -42,92 +44,56 @@ function ValueSelector({
|
||||
[options],
|
||||
);
|
||||
|
||||
// All-selected → the full option set so CustomMultiSelect engages its "all"
|
||||
// path (overlay when closed, every option checked when open). The scalar
|
||||
// sentinel would instead render a literal `__ALL__` row.
|
||||
const committedValues = useMemo<string[]>(
|
||||
() =>
|
||||
selection.allSelected
|
||||
? options
|
||||
: (Array.isArray(selection.value) ? selection.value : []).map(String),
|
||||
[selection, options],
|
||||
);
|
||||
|
||||
// Buffer edits while the dropdown is open; the committed selection is shown
|
||||
// when closed. This defers the dependent cascade to a single commit-on-close.
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string[]>(committedValues);
|
||||
|
||||
const commit = (values: string[]): void => {
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
const next: VariableSelection =
|
||||
values.length === 0 ? emptyFallback : { value: values, allSelected: isAll };
|
||||
|
||||
// Closing without actually changing the selection must not re-fire onChange —
|
||||
// that would needlessly re-cascade to dependent variables/panels.
|
||||
if (areSelectionsEqual(next, selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent(
|
||||
DashboardDetailEvents.VariableValueSelected,
|
||||
{ variableType, multiSelect: true, selectionCount: values.length },
|
||||
'track',
|
||||
true,
|
||||
);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
if (multiSelect) {
|
||||
// All-selected → hand CustomMultiSelect the full option set so it engages its
|
||||
// "all" path (overlay when closed, every option checked when open). Passing the
|
||||
// scalar sentinel instead makes it render a literal `__ALL__` row.
|
||||
const value = selection.allSelected
|
||||
? options
|
||||
: (Array.isArray(selection.value) ? selection.value : []).map(String);
|
||||
return (
|
||||
<CustomMultiSelect
|
||||
className={styles.control}
|
||||
data-testid={testId}
|
||||
options={optionData}
|
||||
value={isOpen ? draft : committedValues}
|
||||
value={value}
|
||||
loading={loading}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={onRetry}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select value"
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={10}
|
||||
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
|
||||
maxTagCount={2}
|
||||
maxTagTextLength={20}
|
||||
// Offer ALL only once options load, else a concrete value reads as "all".
|
||||
enableAllSelection={showAllOption && options.length > 0}
|
||||
onDropdownVisibleChange={(open): void => {
|
||||
if (open) {
|
||||
setDraft(committedValues);
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
commit(draft);
|
||||
}}
|
||||
onChange={(next): void => {
|
||||
const values = Array.isArray(next)
|
||||
? next.map(String)
|
||||
: next
|
||||
? [String(next)]
|
||||
: [];
|
||||
setDraft(values);
|
||||
void logEvent(
|
||||
DashboardDetailEvents.VariableValueSelected,
|
||||
{ variableType, multiSelect: true, selectionCount: values.length },
|
||||
'track',
|
||||
true,
|
||||
);
|
||||
if (values.length === 0) {
|
||||
onChange({ value: [], allSelected: false });
|
||||
return;
|
||||
}
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
onChange({ value: values, allSelected: isAll });
|
||||
}}
|
||||
onClear={(): void => {
|
||||
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
|
||||
variableType,
|
||||
});
|
||||
setDraft([]);
|
||||
// A clear on the closed control falls back to the default immediately;
|
||||
// while open it just empties the draft (committed on close).
|
||||
if (!isOpen) {
|
||||
onChange(emptyFallback);
|
||||
}
|
||||
onChange({ value: [], allSelected: false });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
VARIABLE_TYPE_EVENT_LABEL,
|
||||
type VariableFormModel,
|
||||
@@ -8,10 +6,6 @@ import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../../selectionTypes';
|
||||
import {
|
||||
reconcileWithOptions,
|
||||
resolveDefaultSelection,
|
||||
} from '../../utils/resolveVariableSelection';
|
||||
import { useAutoSelect } from '../../hooks/useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
import { useVariableOptions } from '../../hooks/useVariableOptions';
|
||||
@@ -50,12 +44,6 @@ function VariableValueControl({
|
||||
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
|
||||
// The selection to fall back to when the multi-select closes empty
|
||||
const emptyFallback = useMemo<VariableSelection>(() => {
|
||||
const seed = resolveDefaultSelection(variable);
|
||||
return reconcileWithOptions(variable, seed, options) ?? seed;
|
||||
}, [variable, options]);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
@@ -67,7 +55,6 @@ function VariableValueControl({
|
||||
onRetry={onRetry}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
emptyFallback={emptyFallback}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -53,6 +53,19 @@ function isAllDefault(
|
||||
);
|
||||
}
|
||||
|
||||
function isValidSingle(
|
||||
value: SelectedVariableValue,
|
||||
options: string[],
|
||||
): boolean {
|
||||
return (
|
||||
!Array.isArray(value) &&
|
||||
value !== '' &&
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
options.includes(String(value))
|
||||
);
|
||||
}
|
||||
|
||||
/** The configured default (or first option) as a fresh selection. */
|
||||
function fillDefault(
|
||||
model: VariableFormModel,
|
||||
@@ -154,17 +167,8 @@ export function reconcileWithOptions(
|
||||
: fillDefault(model, options);
|
||||
}
|
||||
|
||||
if (!model.multiSelect) {
|
||||
// Preserve any non-empty single value across a refetch — including a user-typed
|
||||
// value that isn't in the fetched options (freeform). Only fall back to the
|
||||
// default/first option when there is no value yet, so e.g. a time-range change
|
||||
// (which refetches options) never wipes a value the user didn't change.
|
||||
const hasValue =
|
||||
!Array.isArray(current.value) &&
|
||||
current.value !== '' &&
|
||||
current.value !== null &&
|
||||
current.value !== undefined;
|
||||
return hasValue ? null : fillDefault(model, options);
|
||||
if (!model.multiSelect && isValidSingle(current.value, options)) {
|
||||
return null;
|
||||
}
|
||||
return fillDefault(model, options);
|
||||
}
|
||||
@@ -184,20 +188,3 @@ export function configuredDefaultValue(
|
||||
}
|
||||
return configuredDefault(model.defaultValue);
|
||||
}
|
||||
|
||||
/** Normalize a selection's value to an order-independent key of its members. */
|
||||
function valueSetKey(value: SelectedVariableValue): string {
|
||||
const list = Array.isArray(value) ? value : value == null ? [] : [value];
|
||||
return list.map(String).sort().join('||');
|
||||
}
|
||||
|
||||
/** True when two selections carry the same ALL flag and the same value set. */
|
||||
export function areSelectionsEqual(
|
||||
a: VariableSelection,
|
||||
b: VariableSelection,
|
||||
): boolean {
|
||||
return (
|
||||
!!a.allSelected === !!b.allSelected &&
|
||||
valueSetKey(a.value) === valueSetKey(b.value)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { compactGridItems, compactSpecLayouts } from '../layoutCompaction';
|
||||
import type { GridItem } from '../utils';
|
||||
|
||||
const item = (
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width = 6,
|
||||
height = 2,
|
||||
): GridItem => ({ id, x, y, width, height, panel: undefined });
|
||||
|
||||
describe('compactGridItems', () => {
|
||||
it('pulls a floating item up to the top', () => {
|
||||
const [a] = compactGridItems([item('a', 0, 5)]);
|
||||
expect(a.y).toBe(0);
|
||||
});
|
||||
|
||||
it('resolves an overlap by pushing the colliding item down', () => {
|
||||
// Order is preserved, so [0] is 'a' and [1] is 'b'.
|
||||
const result = compactGridItems([item('a', 0, 0), item('b', 0, 1)]);
|
||||
// a occupies rows 0-1 (height 2), so b must sit at row 2 — no overlap.
|
||||
expect(result[0].y).toBe(0);
|
||||
expect(result[1].y).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves item order and the panel reference', () => {
|
||||
const panel = { kind: 'panel' } as unknown as GridItem['panel'];
|
||||
const result = compactGridItems([
|
||||
{ ...item('a', 0, 0), panel },
|
||||
item('b', 6, 0),
|
||||
]);
|
||||
expect(result.map((i) => i.id)).toStrictEqual(['a', 'b']);
|
||||
expect(result[0].panel).toBe(panel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactSpecLayouts', () => {
|
||||
const grid = (
|
||||
items: { x: number; y: number; width: number; height: number; ref: string }[],
|
||||
): DashboardtypesLayoutDTO =>
|
||||
({
|
||||
kind: 'Grid',
|
||||
spec: {
|
||||
items: items.map((i) => ({
|
||||
x: i.x,
|
||||
y: i.y,
|
||||
width: i.width,
|
||||
height: i.height,
|
||||
content: { $ref: i.ref },
|
||||
})),
|
||||
},
|
||||
}) as unknown as DashboardtypesLayoutDTO;
|
||||
|
||||
it('compacts overlapping items and keeps their panel refs', () => {
|
||||
const [layout] = compactSpecLayouts([
|
||||
grid([
|
||||
{ x: 0, y: 0, width: 6, height: 2, ref: '#/spec/panels/a' },
|
||||
{ x: 0, y: 1, width: 6, height: 2, ref: '#/spec/panels/b' },
|
||||
]),
|
||||
]);
|
||||
const items = layout.spec?.items ?? [];
|
||||
expect(items[0]?.y).toBe(0);
|
||||
expect(items[1]?.y).toBe(2);
|
||||
expect(items[1]?.content?.$ref).toBe('#/spec/panels/b');
|
||||
});
|
||||
|
||||
it('passes a non-Grid layout through untouched', () => {
|
||||
const other = { kind: 'Other' } as unknown as DashboardtypesLayoutDTO;
|
||||
expect(compactSpecLayouts([other])[0]).toBe(other);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
// Cap the dashboard-name segment so a long title ellipsizes on one line instead
|
||||
// of stretching the breadcrumb row.
|
||||
.title {
|
||||
display: inline-block;
|
||||
max-width: 480px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@signozhq/ui/breadcrumb';
|
||||
|
||||
import styles from './DashboardPageBreadcrumbs.module.scss';
|
||||
|
||||
interface DashboardPageBreadcrumbsProps {
|
||||
title: string;
|
||||
image: string;
|
||||
@@ -62,9 +60,7 @@ function DashboardPageBreadcrumbs({
|
||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink icon={<img src={image} alt="dashboard-icon" />}>
|
||||
<span className={styles.title} title={title}>
|
||||
{title}
|
||||
</span>
|
||||
{title}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import * as ReactGridLayout from 'react-grid-layout';
|
||||
import type { Layout } from 'react-grid-layout';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { GRID_COLS } from './patchOps';
|
||||
import type { GridItem } from './utils';
|
||||
|
||||
// `utils.compact` is exported by react-grid-layout at runtime — it is the exact
|
||||
// vertical compaction the grid applies on-screen while dragging — but it is absent
|
||||
// from the package's TypeScript types, so it is reached through a typed cast.
|
||||
const { compact } = (
|
||||
ReactGridLayout as unknown as {
|
||||
utils: {
|
||||
compact: (
|
||||
layout: Layout[],
|
||||
compactType: 'vertical' | 'horizontal',
|
||||
cols: number,
|
||||
) => Layout[];
|
||||
};
|
||||
}
|
||||
).utils;
|
||||
|
||||
/** Vertically compact geometry so no two items overlap (mirrors on-screen RGL). */
|
||||
function compactVertically(layout: Layout[]): Layout[] {
|
||||
return compact(layout, 'vertical', GRID_COLS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a section's grid items to a non-overlapping, vertically-compacted layout —
|
||||
* the same normalization RGL applies mid-drag — so a persisted layout can never be
|
||||
* rejected by the backend's no-overlap validation. Panel refs and every other item
|
||||
* field are preserved; item order is unchanged (only geometry updates).
|
||||
*/
|
||||
export function compactGridItems(items: GridItem[]): GridItem[] {
|
||||
const compacted = compactVertically(
|
||||
items.map((item) => ({
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})),
|
||||
);
|
||||
const byId = new Map(compacted.map((entry) => [entry.i, entry]));
|
||||
return items.map((item) => {
|
||||
const entry = byId.get(item.id);
|
||||
return entry
|
||||
? { ...item, x: entry.x, y: entry.y, width: entry.w, height: entry.h }
|
||||
: item;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact every Grid section in a `spec.layouts` array — used on JSON-editor save,
|
||||
* where hand-edited items can overlap. Items are keyed by index (spec items carry
|
||||
* no id of their own); non-Grid or empty layouts pass through untouched.
|
||||
*/
|
||||
export function compactSpecLayouts(
|
||||
layouts: DashboardtypesLayoutDTO[],
|
||||
): DashboardtypesLayoutDTO[] {
|
||||
return layouts.map((layout) => {
|
||||
const items = layout?.kind === 'Grid' ? (layout.spec?.items ?? []) : [];
|
||||
if (items.length === 0) {
|
||||
return layout;
|
||||
}
|
||||
const compacted = compactVertically(
|
||||
items.map((item, index) => ({
|
||||
i: String(index),
|
||||
x: item.x ?? 0,
|
||||
y: item.y ?? 0,
|
||||
w: item.width ?? 6,
|
||||
h: item.height ?? 6,
|
||||
})),
|
||||
);
|
||||
const byIndex = new Map(compacted.map((entry) => [entry.i, entry]));
|
||||
const nextItems = items.map((item, index) => {
|
||||
const entry = byIndex.get(String(index));
|
||||
return entry
|
||||
? { ...item, x: entry.x, y: entry.y, width: entry.w, height: entry.h }
|
||||
: item;
|
||||
});
|
||||
return { ...layout, spec: { ...layout.spec, items: nextItems } };
|
||||
});
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export function createDefaultPanel(
|
||||
spec: pluginSpec,
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
links: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -166,7 +167,7 @@ interface CreatePanelOpsArgs {
|
||||
const NEW_PANEL_SIZE = { width: 6, height: 6 };
|
||||
|
||||
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
|
||||
export const GRID_COLS = 12;
|
||||
const GRID_COLS = 12;
|
||||
|
||||
/** Minimal placement fields shared by grid-item DTOs and flattened `GridItem`s. */
|
||||
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;
|
||||
|
||||
@@ -42,8 +42,3 @@
|
||||
.menuItemWrap button:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.deleteName {
|
||||
color: var(--danger-background);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,10 @@ import { useCopyToClipboard } from 'react-use';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
cloneDashboardV2,
|
||||
getGetDashboardV2QueryKey,
|
||||
invalidateListDashboardsForUserV2,
|
||||
lockDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
@@ -111,17 +109,6 @@ function ActionsPopover({
|
||||
action: isLocked ? 'unlock' : 'lock',
|
||||
dashboardId,
|
||||
});
|
||||
// Patch the detail-page cache too: it uses staleTime:Infinity +
|
||||
// refetchOnMount:false, so without this, returning to the dashboard would
|
||||
// still show the stale (pre-toggle) lock state.
|
||||
const key = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
|
||||
if (cached) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(key, {
|
||||
...cached,
|
||||
data: { ...cached.data, locked: !isLocked },
|
||||
});
|
||||
}
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Modal, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
import { CircleAlert, Trash2 } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -18,7 +18,6 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { useDeleteConfirm } from '../DeleteConfirmModal/useDeleteConfirm';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
interface Props {
|
||||
@@ -42,7 +41,7 @@ function DeleteActionItem({
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const queryClient = useQueryClient();
|
||||
const { contextHolder, confirmDelete } = useDeleteConfirm();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
const isAuthor = user?.email === createdBy;
|
||||
const isDisabled = isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor);
|
||||
@@ -50,12 +49,14 @@ function DeleteActionItem({
|
||||
const { mutate: runDelete } = useMutation({
|
||||
mutationFn: () => deleteDashboardV2({ id: dashboardId }),
|
||||
onSuccess: async () => {
|
||||
toast.success(
|
||||
t('dashboard:delete_dashboard_success', { name: dashboardName }),
|
||||
);
|
||||
void logEvent(DashboardListEvents.RowAction, {
|
||||
action: 'delete',
|
||||
dashboardId,
|
||||
});
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
toast.success('Dashboard deleted successfully');
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
@@ -63,24 +64,39 @@ function DeleteActionItem({
|
||||
});
|
||||
|
||||
const openConfirm = useCallback((): void => {
|
||||
confirmDelete({
|
||||
modal.confirm({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Are you sure you want to delete the
|
||||
<Typography.Text className={styles.deleteName}>
|
||||
<span style={{ color: 'var(--danger-background)', fontWeight: 500 }}>
|
||||
{' '}
|
||||
{dashboardName}{' '}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
dashboard?
|
||||
</Typography.Title>
|
||||
),
|
||||
// Keeps the Delete button loading until the mutation settles, then closes.
|
||||
onConfirm: () =>
|
||||
icon: (
|
||||
<CircleAlert
|
||||
style={{ color: 'var(--danger-background)', marginInlineEnd: '12px' }}
|
||||
size="3xl"
|
||||
/>
|
||||
),
|
||||
okText: 'Delete',
|
||||
okButtonProps: { danger: true },
|
||||
// Returning a promise keeps the Delete button in a loading state and blocks
|
||||
// re-clicks until the mutation settles, then closes the confirm.
|
||||
onOk: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
runDelete(undefined, { onSettled: () => resolve() });
|
||||
}),
|
||||
cancelButtonProps: {
|
||||
onClick: (e): void => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
},
|
||||
centered: true,
|
||||
});
|
||||
}, [confirmDelete, dashboardName, runDelete]);
|
||||
}, [modal, dashboardName, runDelete]);
|
||||
|
||||
const tooltip = ((): string => {
|
||||
if (!isLocked) {
|
||||
|
||||
@@ -59,6 +59,14 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tagsWithActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -130,10 +138,11 @@
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
// Cap the name tooltip so a long name wraps instead of spanning the page.
|
||||
.nameTooltip {
|
||||
max-width: 480px;
|
||||
overflow-wrap: break-word;
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.details {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
@@ -20,7 +21,6 @@ import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import { lastUpdatedLabel, tagsToStrings } from '../../utils/helpers';
|
||||
import ActionsPopover from '../ActionsPopover/ActionsPopover';
|
||||
import DashboardRowTags from './DashboardRowTags/DashboardRowTags';
|
||||
import LegacyDashboardDialog from '../LegacyDashboardDialog/LegacyDashboardDialog';
|
||||
|
||||
import styles from './DashboardRow.module.scss';
|
||||
@@ -129,12 +129,7 @@ function DashboardRow({
|
||||
<div className={styles.titleWithAction}>
|
||||
<div className={styles.titleBlock}>
|
||||
{name.length > 50 ? (
|
||||
<TooltipSimple
|
||||
title={name}
|
||||
side="bottom"
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.nameTooltip }}
|
||||
>
|
||||
<TooltipSimple title={name} side="bottom" disableHoverableContent>
|
||||
{titleLink}
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
@@ -152,7 +147,18 @@ function DashboardRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DashboardRowTags tags={tags} />
|
||||
<div className={styles.tagsWithActions}>
|
||||
{tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLocked && (
|
||||
<TooltipSimple
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
.tags {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extraTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* Recolor via the tooltip's own CSS variables so the arrow (whose fill reads
|
||||
--tooltip-background) turns black along with the content. */
|
||||
.extraTagsTooltip {
|
||||
--tooltip-background: var(--bg-ink-500, #0b0c0e);
|
||||
--tooltip-border-color: var(--bg-ink-500, #0b0c0e);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
|
||||
import styles from './DashboardRowTags.module.scss';
|
||||
|
||||
const MAX_VISIBLE_TAGS = 3;
|
||||
|
||||
interface DashboardRowTagsProps {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboards-list row tag chips: the first few inline, the rest revealed on
|
||||
* hover of a "+N" chip.
|
||||
*/
|
||||
function DashboardRowTags({ tags }: DashboardRowTagsProps): JSX.Element | null {
|
||||
if (tags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extra = tags.slice(MAX_VISIBLE_TAGS);
|
||||
|
||||
return (
|
||||
<div className={styles.tags}>
|
||||
{tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{extra.length > 0 && (
|
||||
<TooltipSimple
|
||||
side="bottom"
|
||||
arrow
|
||||
tooltipContentProps={{ className: styles.extraTagsTooltip }}
|
||||
title={
|
||||
<div className={styles.extraTags}>
|
||||
{extra.map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Stop the click so hovering/clicking the chip doesn't navigate the row. */}
|
||||
<span role="presentation" onClick={(e): void => e.stopPropagation()}>
|
||||
<TagBadge>+{extra.length}</TagBadge>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardRowTags;
|
||||
@@ -115,9 +115,9 @@ function DashboardsList(): JSX.Element {
|
||||
void setPage(1);
|
||||
}, [resetView, setPage]);
|
||||
const handleRemoveView = useCallback(
|
||||
(id: string): Promise<void> => {
|
||||
(id: string): void => {
|
||||
removeView(id);
|
||||
void setPage(1);
|
||||
return removeView(id);
|
||||
},
|
||||
[removeView, setPage],
|
||||
);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// Vertically centre the alert icon against the confirm body, matching the v1
|
||||
// dashboard delete button.
|
||||
.deleteModal :global(.ant-modal-confirm-body) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--danger-background);
|
||||
margin-inline-end: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
|
||||
import styles from './DeleteConfirmModal.module.scss';
|
||||
|
||||
interface ConfirmDeleteOptions {
|
||||
title: ReactNode;
|
||||
content?: ReactNode;
|
||||
confirmText?: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface UseDeleteConfirm {
|
||||
/** Must be rendered in the calling component for the modal to appear. */
|
||||
contextHolder: ReactNode;
|
||||
confirmDelete: (options: ConfirmDeleteOptions) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared destructive confirmation for deleting dashboards and saved views, so
|
||||
* both get the same alert icon, aligned title, danger button, and a working
|
||||
* Cancel (antd closes on Cancel as long as we don't override its `onClick`).
|
||||
*/
|
||||
export function useDeleteConfirm(): UseDeleteConfirm {
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
const confirmDelete = ({
|
||||
title,
|
||||
content,
|
||||
confirmText = 'Delete',
|
||||
onConfirm,
|
||||
}: ConfirmDeleteOptions): void => {
|
||||
modal.confirm({
|
||||
className: styles.deleteModal,
|
||||
title,
|
||||
content,
|
||||
icon: <CircleAlert className={styles.icon} size="3xl" />,
|
||||
okText: confirmText,
|
||||
okButtonProps: { danger: true },
|
||||
// Returning a promise keeps Delete in a loading state until the action
|
||||
// settles, then antd closes the modal.
|
||||
onOk: () => Promise.resolve(onConfirm()),
|
||||
centered: true,
|
||||
});
|
||||
};
|
||||
|
||||
return { contextHolder, confirmDelete };
|
||||
}
|
||||
@@ -62,6 +62,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
links: [],
|
||||
},
|
||||
});
|
||||
void logEvent(DashboardListEvents.DashboardCreated, {
|
||||
|
||||
@@ -128,8 +128,7 @@
|
||||
|
||||
.requestForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
margin-top: 16px;
|
||||
@@ -137,15 +136,6 @@
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.requestHeader {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.requestRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.requestInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -80,49 +80,39 @@ function TemplatesPanel(): JSX.Element {
|
||||
|
||||
{isCloudUser && (
|
||||
<div className={styles.requestForm}>
|
||||
<Typography
|
||||
variant="text"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
className={styles.requestHeader}
|
||||
>
|
||||
Request a new template
|
||||
</Typography>
|
||||
<div className={styles.requestRow}>
|
||||
<Input
|
||||
className={styles.requestInput}
|
||||
placeholder="Enter dashboard name..."
|
||||
value={name}
|
||||
testId="request-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleRequest();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={submitting || requestName.length === 0}
|
||||
testId="request-dashboard-submit"
|
||||
prefix={
|
||||
submitting ? (
|
||||
<LoaderCircle size={14} className={styles.spinner} />
|
||||
) : (
|
||||
<Check size={14} />
|
||||
)
|
||||
}
|
||||
onClick={(): void => {
|
||||
<Input
|
||||
className={styles.requestInput}
|
||||
placeholder="Enter dashboard name..."
|
||||
value={name}
|
||||
testId="request-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleRequest();
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={submitting || requestName.length === 0}
|
||||
testId="request-dashboard-submit"
|
||||
prefix={
|
||||
submitting ? (
|
||||
<LoaderCircle size={14} className={styles.spinner} />
|
||||
) : (
|
||||
<Check size={14} />
|
||||
)
|
||||
}
|
||||
onClick={(): void => {
|
||||
void handleRequest();
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { type ChangeEvent, useCallback, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Bookmark, PenLine, Plus, Search, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
Bookmark,
|
||||
CircleAlert,
|
||||
PenLine,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
|
||||
import type { SavedView } from '../../types';
|
||||
import { type BuiltinView } from '../../utils/views';
|
||||
import { useDeleteConfirm } from '../DeleteConfirmModal/useDeleteConfirm';
|
||||
import ViewNamePopover from './ViewNamePopover';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
@@ -29,7 +36,7 @@ interface Props {
|
||||
onSave: (name: string) => void;
|
||||
onSaveChanges: () => void;
|
||||
onReset: () => void;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
@@ -61,7 +68,7 @@ function ViewsRail({
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const { contextHolder, confirmDelete } = useDeleteConfirm();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesQuery = (label: string): boolean =>
|
||||
@@ -77,9 +84,9 @@ function ViewsRail({
|
||||
const noMatches =
|
||||
!!q && personal.length === 0 && system.length === 0 && custom.length === 0;
|
||||
|
||||
const onConfirmDelete = useCallback(
|
||||
const confirmDelete = useCallback(
|
||||
(id: string, label: string): void => {
|
||||
confirmDelete({
|
||||
const { destroy } = modal.confirm({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Delete the{' '}
|
||||
@@ -88,15 +95,27 @@ function ViewsRail({
|
||||
</Typography.Title>
|
||||
),
|
||||
content: 'This removes the saved view. Your dashboards are not affected.',
|
||||
// Return the delete promise so the modal's Delete button stays loading
|
||||
// until the backend call settles, then closes — matching dashboard delete.
|
||||
onConfirm: (): Promise<void> => {
|
||||
void logEvent(DashboardListEvents.ViewDeleted, {});
|
||||
return onDelete(id);
|
||||
icon: (
|
||||
<CircleAlert
|
||||
style={{ color: 'var(--danger-background)', marginInlineEnd: '12px' }}
|
||||
size="3xl"
|
||||
/>
|
||||
),
|
||||
okText: 'Delete',
|
||||
okButtonProps: {
|
||||
danger: true,
|
||||
onClick: (e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void logEvent(DashboardListEvents.ViewDeleted, {});
|
||||
onDelete(id);
|
||||
destroy();
|
||||
},
|
||||
},
|
||||
centered: true,
|
||||
});
|
||||
},
|
||||
[confirmDelete, onDelete],
|
||||
[modal, onDelete],
|
||||
);
|
||||
|
||||
const handleSaveAsView = (name: string): void => {
|
||||
@@ -163,7 +182,7 @@ function ViewsRail({
|
||||
title="Delete view"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onConfirmDelete(row.id, row.label);
|
||||
confirmDelete(row.id, row.label);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface UseActiveViewResult {
|
||||
saveView: (name: string) => void;
|
||||
saveActiveView: () => void;
|
||||
resetView: () => void;
|
||||
removeView: (id: string) => Promise<void>;
|
||||
removeView: (id: string) => void;
|
||||
renameView: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
@@ -152,14 +152,8 @@ export function useActiveView({
|
||||
}, [canonicalQuery, setQuery, activeCustom, setSortColumn, setSortOrder]);
|
||||
|
||||
const removeView = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
try {
|
||||
await deleteView(id);
|
||||
} catch {
|
||||
// Failure is surfaced by the delete mutation's error toast; still
|
||||
// settle so the confirm modal stops loading and closes.
|
||||
return;
|
||||
}
|
||||
(id: string): void => {
|
||||
deleteView(id);
|
||||
if (activeViewId === id) {
|
||||
void setActiveViewId(BuiltinViewId.All);
|
||||
setQuery('');
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface UseSavedViewsResult {
|
||||
isLoading: boolean;
|
||||
createView: (input: SavedViewInput) => Promise<SavedView | null>;
|
||||
updateView: (id: string, input: SavedViewInput) => void;
|
||||
deleteView: (id: string) => Promise<void>;
|
||||
deleteView: (id: string) => void;
|
||||
}
|
||||
|
||||
// Org-shared saved views, backed by the Views API. Exposes the list plus
|
||||
@@ -127,8 +127,9 @@ export function useSavedViews(): UseSavedViewsResult {
|
||||
);
|
||||
|
||||
const deleteView = useCallback(
|
||||
(id: string): Promise<void> =>
|
||||
deleteMutation.mutateAsync({ pathParams: { id } }).then(() => undefined),
|
||||
(id: string): void => {
|
||||
deleteMutation.mutate({ pathParams: { id } });
|
||||
},
|
||||
[deleteMutation],
|
||||
);
|
||||
|
||||
|
||||
@@ -182,8 +182,7 @@ func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans, selectFields)
|
||||
start, end := flamegraphTrace.TimeRange()
|
||||
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), start, end, false), nil
|
||||
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start, summary.End, false), nil
|
||||
}
|
||||
|
||||
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
|
||||
@@ -199,8 +198,6 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
|
||||
flamegraphTrace := spantypes.NewFlamegraphTraceFromMinimal(minimalSpans)
|
||||
minimalSpans = nil //nolint:ineffassign,wastedassign // release backing array before further db calls
|
||||
|
||||
start, end := flamegraphTrace.TimeRange()
|
||||
|
||||
cfg := m.config.Flamegraph
|
||||
selectedSpans := flamegraphTrace.GetSelectedLevels(selectedSpanID, cfg.MaxSelectedLevels, cfg.MaxSpansPerLevel, cfg.SamplingTopLatencySpansCount, cfg.SamplingBucketCount)
|
||||
if len(selectedSpans) == 0 {
|
||||
@@ -213,5 +210,5 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
|
||||
}
|
||||
|
||||
enrichedSpans := flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields)
|
||||
return spantypes.NewGettableFlamegraphTrace(enrichedSpans, start, end, true), nil
|
||||
return spantypes.NewGettableFlamegraphTrace(enrichedSpans, summary.Start, summary.End, true), nil
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func newProvider(
|
||||
telemetryaudit.LogAttributeKeysTblName,
|
||||
telemetryaudit.LogResourceKeysTblName,
|
||||
telemetrymetadata.DBName,
|
||||
telemetrymetadata.AttributesMetadataLocalTableName,
|
||||
telemetrymetadata.AttributesMetadataTableName,
|
||||
telemetrymetadata.ColumnEvolutionMetadataTableName,
|
||||
flagger,
|
||||
)
|
||||
|
||||
@@ -447,7 +447,7 @@ func New(
|
||||
telemetryaudit.LogAttributeKeysTblName,
|
||||
telemetryaudit.LogResourceKeysTblName,
|
||||
telemetrymetadata.DBName,
|
||||
telemetrymetadata.AttributesMetadataLocalTableName,
|
||||
telemetrymetadata.AttributesMetadataTableName,
|
||||
telemetrymetadata.ColumnEvolutionMetadataTableName,
|
||||
flagger,
|
||||
)
|
||||
|
||||
@@ -89,8 +89,7 @@ func (c *conditionBuilder) conditionForArrayFunction(
|
||||
for i, v := range list {
|
||||
vals[i] = legacyCoerceNeedle(v, elemType)
|
||||
}
|
||||
// Pin the needle array type to the haystack; scalar fallback below coerces value-level.
|
||||
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, castNeedleArray(elemType, sb.Var(vals)))
|
||||
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, sb.Var(vals))
|
||||
if !hasScalar {
|
||||
return arrayCond, nil
|
||||
}
|
||||
@@ -114,27 +113,6 @@ func (c *conditionBuilder) conditionForArrayFunction(
|
||||
return fmt.Sprintf("(%s OR ifNull(%s, false))", arrayCond, sb.And(sb.E(scalarExpr, typedNeedle), scalarGuard)), nil
|
||||
}
|
||||
|
||||
// castNeedleArray pins an Int64 needle array to Array(Int64) so it matches the Array(Nullable(Int64))
|
||||
// haystack; without it a needle >= 2^32 binds as Array(UInt64) and hasAny/hasAll error (code 386).
|
||||
func castNeedleArray(elemType telemetrytypes.FieldDataType, arg string) string {
|
||||
if elemType == telemetrytypes.FieldDataTypeInt64 {
|
||||
return fmt.Sprintf("CAST(%s AS Array(Int64))", arg)
|
||||
}
|
||||
return arg
|
||||
}
|
||||
|
||||
// firstTokenSeparator returns the first char of s that hasToken treats as a token separator
|
||||
// (anything other than an ASCII letter or digit), and whether one was found.
|
||||
func firstTokenSeparator(s string) (string, bool) {
|
||||
for _, r := range s {
|
||||
isAlphaNum := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
if !isAlphaNum {
|
||||
return string(r), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// conditionForHasToken builds a hasToken full-text search over the body column, resolving the
|
||||
// column from the key name + use_json_body flag.
|
||||
func (c *conditionBuilder) conditionForHasToken(
|
||||
@@ -151,19 +129,11 @@ func (c *conditionBuilder) conditionForHasToken(
|
||||
}
|
||||
|
||||
// hasToken matches string tokens only.
|
||||
needleStr, ok := needle.(string)
|
||||
if !ok {
|
||||
if _, ok := needle.(string); !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"function `hasToken` expects value parameter to be a string").WithUrl(hasTokenFunctionDocURL)
|
||||
}
|
||||
|
||||
// A multi-token needle makes CH hasToken error (code 36); reject up front as a 400. Both modes flow here.
|
||||
if sep, found := firstTokenSeparator(needleStr); found {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"function `hasToken` matches a single whole token, but %q contains the separator %q; use a substring filter (e.g. `body CONTAINS '%s'`) to search across separators",
|
||||
needleStr, sep, needleStr).WithUrl(hasTokenFunctionDocURL)
|
||||
}
|
||||
|
||||
bodyJSONEnabled := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
|
||||
|
||||
if !bodyJSONEnabled {
|
||||
|
||||
@@ -104,23 +104,6 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
|
||||
expectedArgs: []any{int64(200), int64(200)},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
// Big-int needle CAST to Array(Int64) to match the haystack (else 386).
|
||||
category: "json",
|
||||
query: `hasAny(body.ids, ['9007199254740993', '9007199254740994'])`,
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE (hasAny(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), CAST(? AS Array(Int64))) OR ifNull((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') IN (?, ?) AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false))`,
|
||||
expectedArgs: []any{[]any{int64(9007199254740993), int64(9007199254740994)}, int64(9007199254740993), int64(9007199254740994)},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: `hasAll(body.ids, ['9007199254740993', '9007199254740994'])`,
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE (hasAll(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), CAST(? AS Array(Int64))) OR ifNull(((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ? AND JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ?) AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false))`,
|
||||
expectedArgs: []any{[]any{int64(9007199254740993), int64(9007199254740994)}, int64(9007199254740993), int64(9007199254740994)},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: "body.message = hello",
|
||||
|
||||
@@ -1561,19 +1561,6 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
expectedArgs: []any{"download"},
|
||||
expectedErrorContains: "function `hasToken` expects value parameter to be a string",
|
||||
},
|
||||
// A multi-token needle (separator/whitespace) is a clean 400, not a CH execution error.
|
||||
{
|
||||
category: "hasTokenUnderscoreNeedle",
|
||||
query: "hasToken(body, \"user_id\")",
|
||||
shouldPass: false,
|
||||
expectedErrorContains: "function `hasToken` matches a single whole token",
|
||||
},
|
||||
{
|
||||
category: "hasTokenWhitespaceNeedle",
|
||||
query: "hasToken(body, \"production node\")",
|
||||
shouldPass: false,
|
||||
expectedErrorContains: "function `hasToken` matches a single whole token",
|
||||
},
|
||||
// extra / mis-shaped value arguments are rejected, not silently dropped.
|
||||
{
|
||||
category: "hasExtraArgs",
|
||||
|
||||
@@ -96,8 +96,11 @@ func (c *conditionBuilder) conditionForKey(
|
||||
|
||||
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
|
||||
|
||||
// key must exists to apply main filter
|
||||
expr := `if(mapContains(%s, %s), %s, true)`
|
||||
// key must exist to apply the main filter. for positive operators the
|
||||
// absent-key rows are excluded (fallback false); for negative operators
|
||||
// they are kept (fallback true) so rows legitimately lacking the key match.
|
||||
keyMissingFallback := operator.IsNegativeOperator()
|
||||
expr := `if(mapContains(%s, %s), %s, %t)`
|
||||
|
||||
var cond string
|
||||
|
||||
@@ -171,5 +174,5 @@ func (c *conditionBuilder) conditionForKey(
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(expr, columns[0].Name, sb.Var(key.Name), cond), nil
|
||||
return fmt.Sprintf(expr, columns[0].Name, sb.Var(key.Name), cond, keyMissingFallback), nil
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestConditionFor(t *testing.T) {
|
||||
},
|
||||
operator: qbtypes.FilterOperatorILike,
|
||||
value: "%admin%",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), LOWER(attributes['user.id']) LIKE LOWER(?), true)",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), LOWER(attributes['user.id']) LIKE LOWER(?), false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
@@ -49,6 +49,150 @@ func TestConditionFor(t *testing.T) {
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), LOWER(attributes['user.id']) NOT LIKE LOWER(?), true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Equal operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "admin",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), attributes['user.id'] = ?, false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not Equal operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotEqual,
|
||||
value: "admin",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), attributes['user.id'] <> ?, true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "In operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorIn,
|
||||
value: []any{"admin", "root"},
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), (attributes['user.id'] = ? OR attributes['user.id'] = ?), false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not In operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotIn,
|
||||
value: []any{"admin", "root"},
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), (attributes['user.id'] <> ? AND attributes['user.id'] <> ?), true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Like operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorLike,
|
||||
value: "%admin%",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), attributes['user.id'] LIKE ?, false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not Like operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotLike,
|
||||
value: "%admin%",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), attributes['user.id'] NOT LIKE ?, true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Contains operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorContains,
|
||||
value: "admin",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), LOWER(attributes['user.id']) LIKE LOWER(?), false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not Contains operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotContains,
|
||||
value: "admin",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), LOWER(attributes['user.id']) NOT LIKE LOWER(?), true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Regexp operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorRegexp,
|
||||
value: "adm.*",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), match(attributes['user.id'], ?), false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not Regexp operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotRegexp,
|
||||
value: "adm.*",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), NOT match(attributes['user.id'], ?), true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Exists operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorExists,
|
||||
value: nil,
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), mapContains(attributes, 'user.id') = ?, false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not Exists operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotExists,
|
||||
value: nil,
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), mapContains(attributes, 'user.id') <> ?, true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
||||
@@ -1436,6 +1436,11 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, orgID valuer.
|
||||
sb.Where(sb.LE("unix_milli", fieldValueSelector.EndUnixMilli))
|
||||
}
|
||||
|
||||
// scope to the requested signal's rows;
|
||||
if fieldValueSelector.Signal != telemetrytypes.SignalUnspecified {
|
||||
sb.Where(sb.E("data_source", fieldValueSelector.Signal.StringValue()))
|
||||
}
|
||||
|
||||
if fieldValueSelector.Value != "" {
|
||||
var conds []string
|
||||
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextAttribute &&
|
||||
@@ -1464,9 +1469,9 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, orgID valuer.
|
||||
}
|
||||
|
||||
if len(conds) != 0 {
|
||||
// see `expr` in condition_builder.go, if key doesn't exist we don't check for value
|
||||
// hence, this is join of conditions on resource and attributes
|
||||
sb.Where(sb.And(conds...))
|
||||
// the key may sit in the resource or attribute map (or both), so OR the
|
||||
// two conditions — match if the key's value in either map contains searchText.
|
||||
sb.Where(sb.Or(conds...))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func TestGetFirstSeenFromMetricMetadata(t *testing.T) {
|
||||
telemetryaudit.LogAttributeKeysTblName,
|
||||
telemetryaudit.LogResourceKeysTblName,
|
||||
DBName,
|
||||
AttributesMetadataLocalTableName,
|
||||
AttributesMetadataTableName,
|
||||
ColumnEvolutionMetadataTableName,
|
||||
flaggertest.New(t),
|
||||
)
|
||||
|
||||
@@ -137,13 +137,6 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
|
||||
return nil, err
|
||||
}
|
||||
account.Config.Azure = azureConfig
|
||||
case CloudProviderTypeGCP:
|
||||
gcpConfig := new(GCPAccountConfig)
|
||||
err := json.Unmarshal([]byte(storableAccount.Config), gcpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account.Config.GCP = gcpConfig
|
||||
}
|
||||
|
||||
if storableAccount.LastAgentReport != nil {
|
||||
|
||||
@@ -17,17 +17,16 @@ import (
|
||||
// DashboardSpec is the SigNoz dashboard v2 spec shape. It mirrors
|
||||
// dashboard.Spec (Perses) field-for-field, except every common.Plugin
|
||||
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
|
||||
// per-site discriminated oneOf. Perses's datasources field is deliberately
|
||||
// dropped: SigNoz never reads it (queries carry their own signal/source), so
|
||||
// the drift test allowlists it as an intentional omission.
|
||||
// per-site discriminated oneOf.
|
||||
type DashboardSpec struct {
|
||||
Display Display `json:"display" required:"true"`
|
||||
Variables []Variable `json:"variables" required:"true" nullable:"false"`
|
||||
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
|
||||
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
|
||||
Duration common.DurationString `json:"duration"`
|
||||
RefreshInterval common.DurationString `json:"refreshInterval"`
|
||||
Links []Link `json:"links,omitzero"`
|
||||
Display Display `json:"display" required:"true"`
|
||||
Datasources map[string]*DatasourceSpec `json:"datasources,omitzero"`
|
||||
Variables []Variable `json:"variables" required:"true" nullable:"false"`
|
||||
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
|
||||
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
|
||||
Duration common.DurationString `json:"duration"`
|
||||
RefreshInterval common.DurationString `json:"refreshInterval"`
|
||||
Links []Link `json:"links" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -46,6 +45,16 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
|
||||
return d.Validate()
|
||||
}
|
||||
|
||||
// validateLinks rejects a missing/null spec.links value: a typed client must
|
||||
// send [] rather than omitting links, so its value round-trips faithfully.
|
||||
// Panel links are the panel spec's concern, validated in validatePanels.
|
||||
func (d *DashboardSpec) validateLinks() error {
|
||||
if d.Links == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.links is required; send [] when there are no links")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Cross-field validation
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -54,6 +63,9 @@ func (d *DashboardSpec) Validate() error {
|
||||
if err := d.Display.Validate("dashboard", "spec.display.name"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.validateLinks(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.validateVariables(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -105,9 +117,12 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
if err := panel.Spec.Display.Validate("panel", path+".spec.display.name"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := panel.Spec.validateLinks(path); err != nil {
|
||||
return err
|
||||
}
|
||||
panelKind := panel.Spec.Plugin.Kind
|
||||
if len(panel.Spec.Queries) != 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query, found %d", path, len(panel.Spec.Queries))
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)
|
||||
}
|
||||
allowed := allowedQueryKinds[panelKind]
|
||||
for qi, q := range panel.Spec.Queries {
|
||||
@@ -270,8 +285,8 @@ func (d *DashboardSpec) validateLayouts() error {
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
|
||||
}
|
||||
if grid.Display != nil {
|
||||
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxLayoutTitleLen {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxLayoutTitleLen, n)
|
||||
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxDisplayNameLen {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxDisplayNameLen, n)
|
||||
}
|
||||
}
|
||||
if err := validateGridLayoutGeometry(grid, li); err != nil {
|
||||
|
||||
@@ -30,7 +30,7 @@ const basePostableJSON = `{
|
||||
"spec": {
|
||||
"name": "service",
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": true,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestInvalidateNotAJSON(t *testing.T) {
|
||||
// TestUnmarshalErrorPreservesNestedMessage guards the wrap on dec.Decode in
|
||||
// DashboardSpec.UnmarshalJSON. The wrap stamps a consistent type/code on
|
||||
// decode failures, but must not smother the rich messages produced by nested
|
||||
// UnmarshalJSON methods (panel/query/variable plugin envelopes).
|
||||
// UnmarshalJSON methods (panel/query/variable/datasource plugin envelopes).
|
||||
func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
@@ -77,8 +77,8 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateEmptySpec(t *testing.T) {
|
||||
// no variables no panels no links
|
||||
data := []byte(`{}`)
|
||||
// no variables no panels
|
||||
data := []byte(`{"links": []}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
assert.NoError(t, err, "expected valid")
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestValidateOnlyVariables(t *testing.T) {
|
||||
"spec": {
|
||||
"name": "service",
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": true,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {
|
||||
@@ -237,12 +237,6 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "customAllValue cannot be set")
|
||||
})
|
||||
|
||||
t.Run("allowAllValue without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": true, "allowMultiple": false,`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "allowAllValue cannot be set")
|
||||
})
|
||||
|
||||
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
|
||||
require.Error(t, err)
|
||||
@@ -447,6 +441,20 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
}`,
|
||||
wantContain: "FakeVariable",
|
||||
},
|
||||
{
|
||||
name: "unknown datasource plugin",
|
||||
data: `{
|
||||
"datasources": {
|
||||
"ds1": {
|
||||
"default": true,
|
||||
"plugin": {"kind": "FakeDatasource", "spec": {}}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "FakeDatasource",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1731,61 +1739,55 @@ func TestInvalidateDuplicatePanelReference(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
|
||||
}
|
||||
|
||||
// Every display name — dashboard, panel, variable — is bounded at MaxDisplayNameLen,
|
||||
// while the grid layout title has its own, larger bound (MaxLayoutTitleLen). The name
|
||||
// is one over the relevant limit in each case, and the message reads "<json path>:
|
||||
// <field> name must be at most ...", pairing the locatable path (like the other spec
|
||||
// errors) with a human field label.
|
||||
// Every display name — dashboard, panel, variable — and the grid layout title is
|
||||
// bounded at MaxDisplayNameLen. The name is one over the limit in each case, and
|
||||
// the message reads "<json path>: <field> name must be at most ...", pairing the
|
||||
// locatable path (like the other spec errors) with a human field label.
|
||||
func TestInvalidateDisplayNameTooLong(t *testing.T) {
|
||||
tooLong := strings.Repeat("x", MaxDisplayNameLen+1)
|
||||
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", MaxDisplayNameLen, MaxDisplayNameLen+1)
|
||||
|
||||
testCases := []struct {
|
||||
scenario string
|
||||
limit int
|
||||
dashboardJSONFmt string
|
||||
expectedPath string
|
||||
expectedLabel string
|
||||
scenario string
|
||||
dashboardJSON string
|
||||
expectedPath string
|
||||
expectedLabel string
|
||||
}{
|
||||
{
|
||||
scenario: "dashboard display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"display": {"name": "%s"}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "dashboard",
|
||||
expectedPath: "spec.display.name",
|
||||
scenario: "dashboard display name",
|
||||
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "dashboard",
|
||||
expectedPath: "spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "panel display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [], "display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "panel",
|
||||
expectedPath: "spec.panels.p1.spec.display.name",
|
||||
scenario: "panel display name",
|
||||
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [],"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "panel",
|
||||
expectedPath: "spec.panels.p1.spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "list variable display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
scenario: "list variable display name",
|
||||
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "text variable display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "links": [], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
scenario: "text variable display name",
|
||||
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "links": [], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "layout title",
|
||||
limit: MaxLayoutTitleLen,
|
||||
dashboardJSONFmt: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
|
||||
expectedLabel: "layout",
|
||||
expectedPath: "spec.layouts[0].spec.display.title",
|
||||
scenario: "layout title",
|
||||
dashboardJSON: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
|
||||
expectedLabel: "layout",
|
||||
expectedPath: "spec.layouts[0].spec.display.title",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.scenario, func(t *testing.T) {
|
||||
tooLong := strings.Repeat("x", testCase.limit+1)
|
||||
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", testCase.limit, testCase.limit+1)
|
||||
_, err := unmarshalDashboard(fmt.Appendf(nil, testCase.dashboardJSONFmt, tooLong))
|
||||
_, err := unmarshalDashboard([]byte(testCase.dashboardJSON))
|
||||
require.Error(t, err)
|
||||
// Message is "<path>: <label> name must be at most N characters, got M".
|
||||
want := testCase.expectedPath + ": " + testCase.expectedLabel + " name " + lengthMsg
|
||||
|
||||
@@ -6,12 +6,12 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/perses/spec/go/datasource"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -21,27 +21,22 @@ func TestDashboardSpecMatchesPerses(t *testing.T) {
|
||||
name string
|
||||
ours reflect.Type
|
||||
perses reflect.Type
|
||||
// omit lists json fields intentionally dropped from our type that Perses
|
||||
// still has, so their absence is not reported as drift.
|
||||
omit []string
|
||||
}{
|
||||
{name: "DashboardSpec", ours: typeOf[DashboardSpec](), perses: typeOf[dashboard.Spec](), omit: []string{"datasources"}},
|
||||
{name: "Panel", ours: typeOf[Panel](), perses: typeOf[dashboard.Panel]()},
|
||||
{name: "PanelSpec", ours: typeOf[PanelSpec](), perses: typeOf[dashboard.PanelSpec]()},
|
||||
{name: "Query", ours: typeOf[Query](), perses: typeOf[dashboard.Query]()},
|
||||
{name: "QuerySpec", ours: typeOf[QuerySpec](), perses: typeOf[dashboard.QuerySpec]()},
|
||||
{name: "Variable", ours: typeOf[Variable](), perses: typeOf[dashboard.Variable]()},
|
||||
{name: "ListVariableSpec", ours: typeOf[ListVariableSpec](), perses: typeOf[dashboard.ListVariableSpec]()},
|
||||
{name: "TextVariableSpec", ours: typeOf[TextVariableSpec](), perses: typeOf[dashboard.TextVariableSpec]()},
|
||||
{name: "Layout", ours: typeOf[Layout](), perses: typeOf[dashboard.Layout]()},
|
||||
{"DashboardSpec", typeOf[DashboardSpec](), typeOf[dashboard.Spec]()},
|
||||
{"Panel", typeOf[Panel](), typeOf[dashboard.Panel]()},
|
||||
{"PanelSpec", typeOf[PanelSpec](), typeOf[dashboard.PanelSpec]()},
|
||||
{"Query", typeOf[Query](), typeOf[dashboard.Query]()},
|
||||
{"QuerySpec", typeOf[QuerySpec](), typeOf[dashboard.QuerySpec]()},
|
||||
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[datasource.Spec]()},
|
||||
{"Variable", typeOf[Variable](), typeOf[dashboard.Variable]()},
|
||||
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
|
||||
{"TextVariableSpec", typeOf[TextVariableSpec](), typeOf[dashboard.TextVariableSpec]()},
|
||||
{"Layout", typeOf[Layout](), typeOf[dashboard.Layout]()},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
missing, extra := drift(c.ours, c.perses)
|
||||
for _, f := range c.omit {
|
||||
missing = slices.DeleteFunc(missing, func(m string) bool { return m == f })
|
||||
}
|
||||
|
||||
assert.Empty(t, missing,
|
||||
"DashboardSpec (%s) is missing json fields present on Perses %s — upstream likely added or renamed a field",
|
||||
|
||||
@@ -215,6 +215,54 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
return restrictKindToOneValue(s, v.Kind)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Datasource plugin
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type DatasourcePlugin struct {
|
||||
Kind DatasourcePluginKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (DatasourcePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(DatasourceKindSigNoz): schemaRef("DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
|
||||
kind, specJSON, err := extractKindAndSpec(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
factory, ok := datasourcePluginSpecs[DatasourcePluginKind(kind)]
|
||||
if !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown datasource plugin kind %q; allowed values: %s", kind, allowedValuesForKind(slices.Sorted(maps.Keys(datasourcePluginSpecs))))
|
||||
}
|
||||
spec, err := decodeSpec(specJSON, factory(), kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Kind = DatasourcePluginKind(kind)
|
||||
p.Spec = *spec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (DatasourcePlugin) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
DatasourcePluginVariant[SigNozDatasourceSpec]{Kind: string(DatasourceKindSigNoz)},
|
||||
}
|
||||
}
|
||||
|
||||
type DatasourcePluginVariant[S any] struct {
|
||||
Kind string `json:"kind" required:"true"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (v DatasourcePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return restrictKindToOneValue(s, v.Kind)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -242,6 +290,10 @@ var (
|
||||
VariableKindQuery: func() any { return new(QueryVariableSpec) },
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
datasourcePluginSpecs = map[DatasourcePluginKind]func() any{
|
||||
DatasourceKindSigNoz: func() any { return new(SigNozDatasourceSpec) },
|
||||
}
|
||||
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
|
||||
@@ -16,14 +16,10 @@ import (
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
// MaxDisplayNameLen bounds the human-readable display names — dashboard, panel,
|
||||
// and variable. The grid layout title has its own, larger bound (MaxLayoutTitleLen).
|
||||
// MaxDisplayNameLen bounds every human-readable display name — dashboard, panel,
|
||||
// and variable display names, plus the grid layout title.
|
||||
const MaxDisplayNameLen = 128
|
||||
|
||||
// MaxLayoutTitleLen bounds a grid layout title. It is larger than MaxDisplayNameLen
|
||||
// because v1 section (row) titles ran longer.
|
||||
const MaxLayoutTitleLen = 256
|
||||
|
||||
type Display struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
// Description always serializes ("" included) so a create -> GET round-trip
|
||||
@@ -38,6 +34,16 @@ func (d Display) Validate(label, path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Datasource
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type DatasourceSpec struct {
|
||||
Display *common.Display `json:"display,omitempty"`
|
||||
Default bool `json:"default"`
|
||||
Plugin DatasourcePlugin `json:"plugin"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -72,7 +78,17 @@ type PanelSpec struct {
|
||||
Display Display `json:"display" required:"true"`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true"`
|
||||
Queries []Query `json:"queries" required:"true" nullable:"false"`
|
||||
Links []Link `json:"links,omitzero"`
|
||||
Links []Link `json:"links" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// validateLinks rejects a missing/null links field, where path is the panel's
|
||||
// location (e.g. "spec.panels.<key>"). A typed client must send [] rather than
|
||||
// omitting links, so its value round-trips faithfully.
|
||||
func (s *PanelSpec) validateLinks(path string) error {
|
||||
if s.Links == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.links is required; send [] when there are no links", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Link replicates dashboard.Link (Perses) so its zero-valued fields survive the
|
||||
@@ -225,9 +241,6 @@ func (s *ListVariableSpec) validate(path string) error {
|
||||
if s.CustomAllValue != "" && !s.AllowAllValue {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: customAllValue cannot be set if allowAllValue is not set to true", path)
|
||||
}
|
||||
if s.AllowAllValue && !s.AllowMultiple {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: allowAllValue cannot be set if allowMultiple is not set to true", path)
|
||||
}
|
||||
if s.DefaultValue != nil && len(s.DefaultValue.SliceValues) > 0 && !s.AllowMultiple {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: defaultValue cannot be a list if allowMultiple is not set to true", path)
|
||||
}
|
||||
|
||||
@@ -179,6 +179,21 @@ func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList}
|
||||
}
|
||||
|
||||
type DatasourcePluginKind string
|
||||
|
||||
const (
|
||||
DatasourceKindSigNoz DatasourcePluginKind = "signoz/Datasource"
|
||||
)
|
||||
|
||||
func (DatasourcePluginKind) Enum() []any {
|
||||
return []any{DatasourceKindSigNoz}
|
||||
}
|
||||
|
||||
// SigNozDatasourceSpec is the (empty) signoz/Datasource plugin spec. Naming the
|
||||
// type gives the variant a concrete, non-nullable spec schema instead of an
|
||||
// inline free-form one.
|
||||
type SigNozDatasourceSpec struct{}
|
||||
|
||||
type TimeSeriesPanelSpec struct {
|
||||
Visualization TimeSeriesVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
|
||||
11
pkg/types/dashboardtypes/testdata/perses.json
vendored
11
pkg/types/dashboardtypes/testdata/perses.json
vendored
@@ -4,6 +4,15 @@
|
||||
"description": "Trying to cover as many concepts here as possible"
|
||||
},
|
||||
"duration": "1h",
|
||||
"datasources": {
|
||||
"SigNozDatasource": {
|
||||
"default": true,
|
||||
"plugin": {
|
||||
"kind": "signoz/Datasource",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
@@ -13,7 +22,7 @@
|
||||
"name": "serviceName"
|
||||
},
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": true,
|
||||
"allowMultiple": false,
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
"name": "NV dashboard with sections",
|
||||
"description": ""
|
||||
},
|
||||
"datasources": {
|
||||
"SigNozDatasource": {
|
||||
"default": true,
|
||||
"plugin": {
|
||||
"kind": "signoz/Datasource",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"panels": {
|
||||
"b424e23b": {
|
||||
|
||||
@@ -2,7 +2,6 @@ package spantypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
@@ -122,11 +121,6 @@ func (t *FlamegraphTrace) updateTimeRange(timestamp, durationNano uint64) {
|
||||
}
|
||||
}
|
||||
|
||||
// TimeRange returns the actual trace start and end computed from span timestamps.
|
||||
func (t *FlamegraphTrace) TimeRange() (start, end time.Time) {
|
||||
return time.Unix(0, int64(t.startTime)), time.Unix(0, int64(t.endTime))
|
||||
}
|
||||
|
||||
func (t *FlamegraphTrace) buildSpanTree() {
|
||||
for _, node := range t.nodeByID {
|
||||
if node.ParentSpanID != "" {
|
||||
|
||||
@@ -126,55 +126,6 @@ func TestGetAllLevels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TimeRange
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestTimeRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
spans []MinimalSpan
|
||||
wantStart uint64 // nanoseconds since epoch
|
||||
wantEnd uint64 // nanoseconds since epoch (max of span_start + span_duration)
|
||||
}{
|
||||
{
|
||||
// Single span: end must include duration, not just start.
|
||||
name: "single_span_end_includes_duration",
|
||||
spans: []MinimalSpan{mkMinimal("a", "", 1_000_000_000, 500_000_000)},
|
||||
wantStart: 1_000_000_000,
|
||||
wantEnd: 1_500_000_000,
|
||||
},
|
||||
{
|
||||
// Two spans: end is the max of (start+duration) across all spans, not max(start).
|
||||
name: "end_from_longest_span_not_latest_start",
|
||||
spans: []MinimalSpan{
|
||||
mkMinimal("a", "", 1_000_000_000, 1_000_000_000), // ends at 2_000_000_000
|
||||
mkMinimal("b", "", 1_800_000_000, 100_000_000), // ends at 1_900_000_000; starts later but ends sooner
|
||||
},
|
||||
wantStart: 1_000_000_000,
|
||||
wantEnd: 2_000_000_000, // must NOT be 1_900_000_000 (max start + its duration)
|
||||
},
|
||||
{
|
||||
// Missing parent: synthetic span must not influence TimeRange beyond its children.
|
||||
name: "missing_parent_does_not_skew_range",
|
||||
spans: []MinimalSpan{
|
||||
mkMinimal("child", "ghost", 1_000_000_000, 200_000_000),
|
||||
},
|
||||
wantStart: 1_000_000_000,
|
||||
wantEnd: 1_200_000_000,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
trace := NewFlamegraphTraceFromMinimal(tc.spans)
|
||||
start, end := trace.TimeRange()
|
||||
assert.Equal(t, tc.wantStart, uint64(start.UnixNano()), "start mismatch")
|
||||
assert.Equal(t, tc.wantEnd, uint64(end.UnixNano()), "end mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GetSelectedLevels
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,6 +16,7 @@ pytest_plugins = [
|
||||
"fixtures.logs",
|
||||
"fixtures.traces",
|
||||
"fixtures.metrics",
|
||||
"fixtures.metadata",
|
||||
"fixtures.meter",
|
||||
"fixtures.browser",
|
||||
"fixtures.keycloak",
|
||||
|
||||
47
tests/fixtures/cloudintegrations.py
vendored
47
tests/fixtures/cloudintegrations.py
vendored
@@ -1,7 +1,6 @@
|
||||
"""Fixtures for cloud integration tests."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
@@ -21,29 +20,6 @@ from fixtures.logger import setup_logger
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
# Per-provider config shape.
|
||||
@dataclass(frozen=True)
|
||||
class ProviderAccountSpec:
|
||||
# provider slug used in the URL path and config key (e.g. "aws", "gcp").
|
||||
provider: str
|
||||
# params for the account created by default.
|
||||
initial_params: dict
|
||||
# params for the config an update (PUT) test sends.
|
||||
updated_params: dict
|
||||
# params -> the provider-keyed `config` block for a POST/PUT body.
|
||||
build_config: Callable[[dict], dict]
|
||||
# params -> the full config block the API is expected to return under
|
||||
# config[provider] on GET/list. This may differ from what build_config sends:
|
||||
# e.g. AWS accepts deploymentRegion on POST but the API does not echo it back.
|
||||
expected_config: Callable[[dict], dict]
|
||||
# id shown in parametrized test names; defaults to the provider slug.
|
||||
id: str = field(default="")
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.id:
|
||||
object.__setattr__(self, "id", self.provider)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def deprecated_create_cloud_integration_account(
|
||||
request: pytest.FixtureRequest,
|
||||
@@ -121,26 +97,19 @@ def create_cloud_integration_account(
|
||||
cloud_provider: str = "aws",
|
||||
deployment_region: str = "us-east-1",
|
||||
regions: list[str] | None = None,
|
||||
config: dict | None = None,
|
||||
) -> dict:
|
||||
# `config`, when given, is the fully-formed provider-keyed config block
|
||||
# (e.g. built via a ProviderAccountSpec.build_config) and is used as-is.
|
||||
# Otherwise fall back to the AWS shape built from deployment_region/regions,
|
||||
# preserving existing AWS callers.
|
||||
if config is None:
|
||||
if regions is None:
|
||||
regions = ["us-east-1"]
|
||||
config = {
|
||||
cloud_provider: {
|
||||
"deploymentRegion": deployment_region,
|
||||
"regions": regions,
|
||||
}
|
||||
}
|
||||
if regions is None:
|
||||
regions = ["us-east-1"]
|
||||
|
||||
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
|
||||
|
||||
request_payload = {
|
||||
"config": config,
|
||||
"config": {
|
||||
cloud_provider: {
|
||||
"deploymentRegion": deployment_region,
|
||||
"regions": regions,
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"sigNozApiURL": "https://test-deployment.test.signoz.cloud",
|
||||
"sigNozApiKey": "test-api-key-789",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user