Compare commits

..

10 Commits

Author SHA1 Message Date
nikhilmantri0902
f5bd7416d4 chore: test case both columns contain host.name -> resources take precedence 2026-07-24 02:27:56 +05:30
nikhilmantri0902
85f1bf29ce chore: more simplified comment 2026-07-24 02:19:01 +05:30
nikhilmantri0902
6d72efde81 chore: simplified comment 2026-07-24 00:37:44 +05:30
nikhilmantri0902
f66ea90b35 chore: added integration tests 2026-07-23 21:56:37 +05:30
nikhilmantri0902
9f34bcf5b3 chore: added data_source condition 2026-07-23 21:06:18 +05:30
nikhilmantri0902
e0a8f41ac6 chore: flip AND to or in outside condition 2026-07-23 19:46:08 +05:30
Nikhil Mantri
63e890a20e Merge branch 'main' into querier/telemetrymetadata_fix_map_contains_fallback 2026-07-23 18:52:54 +05:30
Nikhil Mantri
1783b942a2 Merge branch 'main' into querier/telemetrymetadata_fix_map_contains_fallback 2026-07-23 14:32:41 +05:30
nikhilmantri0902
ab3ed06fc0 chore: changed to distributed table 2026-07-23 14:27:44 +05:30
nikhilmantri0902
b483cb3545 chore: update fallback to false for positive operators 2026-07-23 13:28:27 +05:30
107 changed files with 1718 additions and 2107 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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' }],
);
});
});

View File

@@ -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',
]);
});
});

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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}
>

View File

@@ -55,6 +55,7 @@ export function useCreateExportDashboard({
layouts: [],
panels: {},
variables: [],
links: [],
},
}),
{

View File

@@ -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],
);

View File

@@ -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;
}

View File

@@ -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>
))}

View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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 }),

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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;

View File

@@ -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"

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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 {

View File

@@ -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 (

View File

@@ -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(

View File

@@ -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();
});
});

View File

@@ -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,
);
});
});

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}`}

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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'}

View File

@@ -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,

View File

@@ -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;
});
});
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -1,6 +1,6 @@
.body {
flex: 1;
padding: 12px 16px;
padding: 12px 24px;
overflow: auto;
}

View File

@@ -1,5 +0,0 @@
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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;

View File

@@ -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 =>

View File

@@ -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 =>

View File

@@ -1,7 +1,7 @@
.variableItem {
position: relative;
display: flex;
width: 180px;
width: 160px;
flex-direction: column;
padding-top: 7px;
}

View File

@@ -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>

View File

@@ -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 });
}}
/>
);

View File

@@ -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}`}
/>
);

View File

@@ -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)
);
}

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 } };
});
}

View File

@@ -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'>;

View File

@@ -42,8 +42,3 @@
.menuItemWrap button:disabled {
pointer-events: none;
}
.deleteName {
color: var(--danger-background);
font-weight: var(--font-weight-medium);
}

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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],
);

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -62,6 +62,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
layouts: [],
panels: {},
variables: [],
links: [],
},
});
void logEvent(DashboardListEvents.DashboardCreated, {

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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('');

View File

@@ -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],
);

View File

@@ -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
}

View File

@@ -70,7 +70,7 @@ func newProvider(
telemetryaudit.LogAttributeKeysTblName,
telemetryaudit.LogResourceKeysTblName,
telemetrymetadata.DBName,
telemetrymetadata.AttributesMetadataLocalTableName,
telemetrymetadata.AttributesMetadataTableName,
telemetrymetadata.ColumnEvolutionMetadataTableName,
flagger,
)

View File

@@ -447,7 +447,7 @@ func New(
telemetryaudit.LogAttributeKeysTblName,
telemetryaudit.LogResourceKeysTblName,
telemetrymetadata.DBName,
telemetrymetadata.AttributesMetadataLocalTableName,
telemetrymetadata.AttributesMetadataTableName,
telemetrymetadata.ColumnEvolutionMetadataTableName,
flagger,
)

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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...))
}
}

View File

@@ -61,7 +61,7 @@ func TestGetFirstSeenFromMetricMetadata(t *testing.T) {
telemetryaudit.LogAttributeKeysTblName,
telemetryaudit.LogResourceKeysTblName,
DBName,
AttributesMetadataLocalTableName,
AttributesMetadataTableName,
ColumnEvolutionMetadataTableName,
flaggertest.New(t),
)

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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"}

View File

@@ -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

View File

@@ -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",

View File

@@ -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},

View File

@@ -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)
}

View File

@@ -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"`

View File

@@ -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",

View File

@@ -3,6 +3,15 @@
"name": "NV dashboard with sections",
"description": ""
},
"datasources": {
"SigNozDatasource": {
"default": true,
"plugin": {
"kind": "signoz/Datasource",
"spec": {}
}
}
},
"links": [],
"panels": {
"b424e23b": {

View File

@@ -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 != "" {

View File

@@ -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
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -16,6 +16,7 @@ pytest_plugins = [
"fixtures.logs",
"fixtures.traces",
"fixtures.metrics",
"fixtures.metadata",
"fixtures.meter",
"fixtures.browser",
"fixtures.keycloak",

View File

@@ -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