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
108 changed files with 1479 additions and 7090 deletions

View File

@@ -272,10 +272,6 @@ func (module *module) CloneV2(ctx context.Context, orgID valuer.UUID, createdBy
return module.pkgDashboardModule.CloneV2(ctx, orgID, createdBy, creator, id)
}
func (module *module) ConvertAllV1ToV2(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.V1ToV2MigrationResult, error) {
return module.pkgDashboardModule.ConvertAllV1ToV2(ctx, orgID)
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}

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

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

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

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

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

@@ -61,10 +61,6 @@ type Module interface {
CloneV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
// ConvertAllV1ToV2 converts every dashboard in the org from v1 to v2 in place,
// overwriting the stored data and syncing tags. Temporary scaffolding for the schema migration.
ConvertAllV1ToV2(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.V1ToV2MigrationResult, error)
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error)

View File

@@ -351,23 +351,6 @@ func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashb
return nil
}
func (store *store) UpdateName(ctx context.Context, orgID valuer.UUID, id valuer.UUID, name string) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model((*dashboardtypes.StorableDashboard)(nil)).
Set("name = ?", name).
Where("id = ?", id).
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with id %s doesn't exist", id)
}
return nil
}
func (store *store) UpdatePublic(ctx context.Context, storable *dashboardtypes.StorablePublicDashboard) error {
_, err := store.
sqlstore.

View File

@@ -1,21 +1,16 @@
package impldashboard
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -32,39 +27,12 @@ func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
orgID := valuer.MustNewUUID(claims.OrgID)
// Read the body ourselves (rather than binding straight from r.Body) so the
// raw bytes survive a failed bind for the v1 fallback below.
body, err := io.ReadAll(r.Body)
if err != nil {
var req dashboardtypes.PostableDashboardV2
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
var req dashboardtypes.PostableDashboardV2
if err := binding.JSON.BindBody(bytes.NewReader(body), &req); err != nil {
// Fallback: the body may be a legacy v1 dashboard. Migrate it to v2 (same
// v4→v5 + v1→v2 pass the bulk migration runs) and retry; if it isn't a
// convertible v1 payload either, surface the original v2 binding error.
var data map[string]any
if json.Unmarshal(body, &data) != nil {
render.Error(rw, err)
return
}
storable := dashboardtypes.StorableDashboard{Data: data, OrgID: orgID}
transition.NewDashboardMigrateV5(handler.providerSettings.Logger, nil, nil).Migrate(ctx, storable.Data)
v2, convErr := storable.ConvertV1ToV2()
if convErr != nil {
render.Error(rw, err)
return
}
req = dashboardtypes.PostableDashboardV2{
DashboardV2MetadataBase: v2.DashboardV2MetadataBase,
Name: v2.Name,
Tags: tagtypes.NewPostableTagsFromTags(v2.Tags),
Spec: v2.Spec,
}
}
dashboard, err := handler.module.CreateV2(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), dashboardtypes.SourceUser, req)
if err != nil {
render.Error(rw, err)

View File

@@ -4,7 +4,6 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
@@ -251,79 +250,3 @@ func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID val
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.store.DeletePreferencesForUser(ctx, orgID, userID)
}
// ConvertAllV1ToV2 migrates every dashboard in the org from the v1 to the v2
// schema in place: each v1 dashboard is converted and its stored data is
// overwritten (with tags synced), dashboards already in v2 are skipped. Each
// dashboard is migrated in its own transaction so one failure doesn't roll back
// the rest. Temporary scaffolding for the schema migration.
func (m *module) ConvertAllV1ToV2(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.V1ToV2MigrationResult, error) {
storables, err := m.store.List(ctx, orgID)
if err != nil {
return nil, err
}
result := &dashboardtypes.V1ToV2MigrationResult{
Total: len(storables),
Results: make([]dashboardtypes.V1ToV2MigrationItem, 0, len(storables)),
}
migrator := transition.NewDashboardMigrateV5(m.settings.Logger(), nil, nil)
for _, storable := range storables {
item := dashboardtypes.V1ToV2MigrationItem{ID: storable.ID.String()}
if storable.IsV2() {
item.Status = "skipped"
result.Skipped++
result.Results = append(result.Results, item)
continue
}
// The v1→v2 conversion assumes v5-shaped widget queries, so run the
// v4→v5 migration first (in place) — same pass the create path runs.
migrator.Migrate(ctx, storable.Data)
if err := m.migrateOneV1ToV2(ctx, orgID, storable); err != nil {
item.Status = "failed"
item.Error = err.Error()
result.Failed++
// Backfill the name column from the v1 title even though the data couldn't
// migrate, so the dashboard stays findable by name. Only when it's unset, so a
// retry doesn't regenerate a different (randomly-suffixed) name. Best-effort.
if storable.Name == "" {
if nameErr := m.store.UpdateName(ctx, orgID, storable.ID, storable.V1Name()); nameErr != nil {
m.settings.Logger().ErrorContext(ctx, "failed to backfill name for unmigrated dashboard", "dashboard_id", storable.ID.String(), "error", nameErr)
}
}
} else {
item.Status = "migrated"
result.Migrated++
}
result.Results = append(result.Results, item)
}
return result, nil
}
func (m *module) migrateOneV1ToV2(ctx context.Context, orgID valuer.UUID, storable *dashboardtypes.StorableDashboard) error {
v2, err := storable.ConvertV1ToV2()
if err != nil {
return err
}
return m.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, v2.ID, tagtypes.NewPostableTagsFromTags(v2.Tags))
if err != nil {
return err
}
v2.Tags = resolvedTags
v2Storable, err := v2.ToStorableDashboard()
if err != nil {
return err
}
return m.store.Update(ctx, orgID, v2Storable)
})
}

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

@@ -222,7 +222,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
sqlmigration.NewAddTelemetryTuplesFactory(sqlstore),
sqlmigration.NewAddTagRelationRankFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlstore, sqlschema),
)
}

View File

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

View File

@@ -1,113 +0,0 @@
package sqlmigration
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type migrateDashboardsV1ToV2 struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
settings factory.ProviderSettings
}
func NewMigrateDashboardsV1ToV2Factory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_dashboards_v1_to_v2"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateDashboardsV1ToV2{sqlstore: sqlstore, sqlschema: sqlschema, settings: ps}, nil
},
)
}
func (migration *migrateDashboardsV1ToV2) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up converts every v1 dashboard in every org to the v2 schema in place. It
// delegates to the dashboard module's ConvertAllV1ToV2, which — per dashboard,
// in its own transaction — runs the v4→v5 query migration, converts to v2,
// overwrites the stored data, and syncs tags. Dashboards already in v2 are
// skipped and per-dashboard conversion failures are recorded rather than
// aborting the run, so one irrecoverably malformed dashboard can't wedge startup.
func (migration *migrateDashboardsV1ToV2) Up(ctx context.Context, db *bun.DB) error {
var orgIDs []string
if err := db.NewSelect().Model((*types.Organization)(nil)).Column("id").Scan(ctx, &orgIDs); err != nil {
return err
}
dashboardModule := impldashboard.NewModule(
impldashboard.NewStore(migration.sqlstore),
migration.settings,
nil, // analytics, orgGetter, and queryParser are unused on the conversion path
nil,
nil,
impltag.NewModule(impltag.NewStore(migration.sqlstore)),
)
logger := migration.settings.Logger
for _, id := range orgIDs {
orgID, err := valuer.NewUUID(id)
if err != nil {
return err
}
result, err := dashboardModule.ConvertAllV1ToV2(ctx, orgID)
if err != nil {
return err
}
logger.InfoContext(ctx, "converted dashboards from v1 to v2",
slog.String("org_id", id),
slog.Int("total", result.Total),
slog.Int("migrated", result.Migrated),
slog.Int("skipped", result.Skipped),
slog.Int("failed", result.Failed),
)
for _, item := range result.Results {
if item.Status == "failed" {
logger.WarnContext(ctx, "failed to convert dashboard from v1 to v2",
slog.String("org_id", id),
slog.String("dashboard_id", item.ID),
slog.String("error", item.Error),
)
}
}
}
// Every dashboard now has a name (migrated, or backfilled from the v1 title), so a
// v2 dashboard's (org_id, name) can be made unique.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: "dashboard",
ColumnNames: []sqlschema.ColumnName{"org_id", "name"},
})
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *migrateDashboardsV1ToV2) Down(context.Context, *bun.DB) error {
return nil
}

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

@@ -10,7 +10,6 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type migrateCommon struct {
@@ -24,10 +23,119 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
}
}
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
// transform is stateless and shared with the v1→v2 dashboard conversion.
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {

View File

@@ -1,353 +0,0 @@
// nolint
package transition
import (
"context"
"log/slog"
)
// ══════════════════════════════════════════════
// Shape-safe (idempotent) migration
// ══════════════════════════════════════════════
//
// A copy of the Migrate → updateWidget → updateQueryData chain with the
// "uniformly v4 input" assumption removed, so it is safe on a dashboard whose
// `version` tag lies (a "v5"-labelled dashboard with un-upgraded, possibly mixed,
// bodies — the v1→v2 converter's case). Versus the original: no version gate, and
// each step acts only on the pre-v5 shape (leaving a v5 field alone), so it is
// idempotent. The original Migrate is left unchanged (battle-tested, no test net).
// The *ShapeSafe methods below each note the original they copy; the reused steps
// (createFilterExpression, fixGroupBy, buildAggregationExpression, orderByExpr) are
// already v5-safe.
// MigrateQueryDataShapeSafe is the per-query entry point (the core of
// updateQueryDataShapeSafe) for callers that process queries one at a time (the
// v1→v2 converter). widgetType is the v1 panelTypes (metric reduceTo on tables);
// "" is safe.
func (m *dashboardMigrateV5) MigrateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
return m.updateQueryDataShapeSafe(ctx, queryData, widgetType)
}
// updateQueryDataShapeSafe copies updateQueryData, with each destructive step
// guarded to act only on the pre-v5 shape (see the file header).
func (mc *migrateCommon) updateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
updated := false
aggregateOp, _ := queryData["aggregateOperator"].(string)
hasAggregation := aggregateOp != "" && aggregateOp != "noop"
if mc.createAggregationsShapeSafe(ctx, queryData, widgetType) {
updated = true
}
// createFilterExpression only touches v4 `filters`; skip if a v5 `filter` exists.
if _, hasFilter := queryData["filter"]; !hasFilter {
if mc.createFilterExpression(ctx, queryData) {
updated = true
}
}
if mc.fixGroupBy(queryData) {
updated = true
}
if mc.createHavingExpressionShapeSafe(queryData) {
updated = true
}
if hasAggregation {
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
newOrderBy := make([]any, 0)
for _, order := range orderBy {
if orderMap, ok := order.(map[string]any); ok {
columnName, _ := orderMap["columnName"].(string)
// skip timestamp, id (logs, traces), samples(metrics) ordering for aggregation queries
if columnName != "timestamp" && columnName != "samples" && columnName != "id" {
if columnName == "#SIGNOZ_VALUE" {
if expr, has := mc.orderByExpr(queryData); has {
orderMap["columnName"] = expr
}
} else {
// if the order by key is not part of the group by keys, remove it
present := false
groupBy, ok := queryData["groupBy"].([]any)
if !ok {
return false
}
for idx := range groupBy {
item, ok := groupBy[idx].(map[string]any)
if !ok {
continue
}
key, ok := item["key"].(string)
if !ok {
continue
}
if key == columnName {
present = true
}
}
if !present {
mc.logger.WarnContext(ctx, "found a order by without group by, skipping", slog.String("order_col_name", columnName))
continue
}
}
newOrderBy = append(newOrderBy, orderMap)
}
}
}
queryData["orderBy"] = newOrderBy
updated = true
}
} else {
dataSource, _ := queryData["dataSource"].(string)
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
newOrderBy := make([]any, 0)
for _, order := range orderBy {
if orderMap, ok := order.(map[string]any); ok {
columnName, _ := orderMap["columnName"].(string)
// skip id and timestamp for (traces)
if (columnName == "id" || columnName == "timestamp") && dataSource == "traces" {
mc.logger.InfoContext(ctx, "skipping `id` order by for traces")
continue
}
// skip id for (logs)
if (columnName == "id" || columnName == "timestamp") && dataSource == "logs" {
mc.logger.InfoContext(ctx, "skipping `id`/`timestamp` order by for logs")
continue
}
newOrderBy = append(newOrderBy, orderMap)
}
}
queryData["orderBy"] = newOrderBy
updated = true
}
}
// Only the `&& functionsArePreV5(functions)` guard differs from updateQueryData.
if functions, ok := queryData["functions"].([]any); ok && functionsArePreV5(functions) {
v5Functions := make([]any, len(functions))
for i, fn := range functions {
if fnMap, ok := fn.(map[string]any); ok {
v5Function := map[string]any{
"name": fnMap["name"],
}
// Convert args from v4 format to v5 FunctionArg format
if args, ok := fnMap["args"].([]any); ok {
v5Args := make([]any, len(args))
for j, arg := range args {
// In v4, args were just values. In v5, they are FunctionArg objects
v5Args[j] = map[string]any{
"name": "", // v4 didn't have named args
"value": arg,
}
}
v5Function["args"] = v5Args
}
// Handle namedArgs if present (some functions might have used this)
if namedArgs, ok := fnMap["namedArgs"].(map[string]any); ok {
// Convert named args to the new format
existingArgs, _ := v5Function["args"].([]any)
if existingArgs == nil {
existingArgs = []any{}
}
for name, value := range namedArgs {
existingArgs = append(existingArgs, map[string]any{
"name": name,
"value": value,
})
}
v5Function["args"] = existingArgs
}
v5Functions[i] = v5Function
}
}
queryData["functions"] = v5Functions
updated = true
}
delete(queryData, "aggregateOperator")
delete(queryData, "aggregateAttribute")
delete(queryData, "temporality")
delete(queryData, "timeAggregation")
delete(queryData, "spaceAggregation")
delete(queryData, "reduceTo")
delete(queryData, "filters")
delete(queryData, "ShiftBy")
delete(queryData, "IsAnomaly")
delete(queryData, "QueriesUsedInFormula")
delete(queryData, "seriesAggregation")
return updated
}
// createHavingExpressionShapeSafe copies createHavingExpression but leaves an
// already-v5 having:{expression} alone instead of wiping it.
func (mc *migrateCommon) createHavingExpressionShapeSafe(queryData map[string]any) bool {
if _, ok := queryData["having"].(map[string]any); ok {
return false // already v5-shaped
}
having, ok := queryData["having"].([]any)
if !ok || len(having) == 0 {
queryData["having"] = map[string]any{"expression": ""}
return true
}
dataSource, _ := queryData["dataSource"].(string)
for idx := range having {
if havingItem, ok := having[idx].(map[string]any); ok {
havingCol, has := mc.orderByExpr(queryData)
if has {
havingItem["columnName"] = havingCol
havingItem["key"] = map[string]any{"key": havingCol}
}
having[idx] = havingItem
}
}
queryData["having"] = map[string]any{"expression": mc.buildExpression(context.Background(), having, "AND", dataSource)}
return true
}
// createAggregationsShapeSafe copies createAggregations but skips a query that
// already has a v5 aggregations[], and picks the metric time/space aggregation
// from the body's shape (has timeAggregation/spaceAggregation?) rather than the
// version tag.
func (mc *migrateCommon) createAggregationsShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
if aggs, ok := queryData["aggregations"].([]any); ok && len(aggs) > 0 {
return false // already v5-shaped
}
aggregateOp, hasOp := queryData["aggregateOperator"].(string)
aggregateAttr, hasAttr := queryData["aggregateAttribute"].(map[string]any)
dataSource, _ := queryData["dataSource"].(string)
if aggregateOp == "noop" && dataSource != "metrics" {
return false
}
if !hasOp || !hasAttr {
return false
}
var aggregation map[string]any
switch dataSource {
case "metrics":
_, hasTime := queryData["timeAggregation"]
_, hasSpace := queryData["spaceAggregation"]
if hasTime || hasSpace { // acts as a check for v4 shape: the body carries its own time/space aggregation.
if _, ok := queryData["spaceAggregation"]; !ok {
queryData["spaceAggregation"] = aggregateOp
}
aggregation = map[string]any{
"metricName": aggregateAttr["key"],
"temporality": queryData["temporality"],
"timeAggregation": queryData["timeAggregation"],
"spaceAggregation": queryData["spaceAggregation"],
}
if reduceTo, ok := queryData["reduceTo"].(string); ok {
aggregation["reduceTo"] = reduceTo
}
} else {
// v3 shape: derive time/space from the compound operator.
var timeAgg, spaceAgg, reduceTo string
switch aggregateOp {
case "sum_rate", "rate_sum":
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
case "avg_rate", "rate_avg":
timeAgg, spaceAgg, reduceTo = "rate", "avg", "avg"
case "min_rate", "rate_min":
timeAgg, spaceAgg, reduceTo = "rate", "min", "min"
case "max_rate", "rate_max":
timeAgg, spaceAgg, reduceTo = "rate", "max", "max"
case "hist_quantile_50":
timeAgg, spaceAgg, reduceTo = "", "p50", "avg"
case "hist_quantile_75":
timeAgg, spaceAgg, reduceTo = "", "p75", "avg"
case "hist_quantile_90":
timeAgg, spaceAgg, reduceTo = "", "p90", "avg"
case "hist_quantile_95":
timeAgg, spaceAgg, reduceTo = "", "p95", "avg"
case "hist_quantile_99":
timeAgg, spaceAgg, reduceTo = "", "p99", "avg"
case "rate":
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
case "p99", "p90", "p75", "p50", "p25", "p20", "p10", "p05":
mc.logger.InfoContext(ctx, "found invalid config")
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
case "min":
timeAgg, spaceAgg, reduceTo = "min", "min", "min"
case "max":
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
case "avg":
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
case "sum":
timeAgg, spaceAgg, reduceTo = "sum", "sum", "sum"
case "count":
timeAgg, spaceAgg, reduceTo = "count", "sum", "sum"
case "count_distinct":
timeAgg, spaceAgg, reduceTo = "count_distinct", "sum", "sum"
case "noop":
mc.logger.WarnContext(ctx, "noop found in the aggregation data")
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
}
aggregation = map[string]any{
"metricName": aggregateAttr["key"],
"temporality": queryData["temporality"],
"timeAggregation": timeAgg,
"spaceAggregation": spaceAgg,
}
if widgetType == "table" {
aggregation["reduceTo"] = reduceTo
} else if reduceTo, ok := queryData["reduceTo"].(string); ok {
aggregation["reduceTo"] = reduceTo
}
}
case "logs", "traces":
aggregation = map[string]any{"expression": mc.buildAggregationExpression(aggregateOp, aggregateAttr)}
default:
return false
}
queryData["aggregations"] = []any{aggregation}
return true
}
// orderByIsPreV5 reports whether an orderBy slice is still in the v4 shape (an
// entry carries "columnName"); a v5 orderBy uses {key:{name}, direction}.
func orderByIsPreV5(orderBy []any) bool {
for _, o := range orderBy {
if m, ok := o.(map[string]any); ok {
if _, has := m["columnName"]; has {
return true
}
}
}
return false
}
// functionsArePreV5 reports whether a functions slice is still in the v4 shape
// (args are raw values); a v5 function's args are {name,value} objects.
func functionsArePreV5(functions []any) bool {
for _, f := range functions {
if m, ok := f.(map[string]any); ok {
args, ok := m["args"].([]any)
if !ok || len(args) == 0 {
continue
}
_, argIsObject := args[0].(map[string]any)
return !argIsObject
}
}
return false
}

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -21,7 +22,6 @@ var (
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
ErrCodeDashboardMigrationFailed = errors.MustNewCode("dashboard_migration_failed")
)
type StorableDashboard struct {
@@ -413,26 +413,27 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
widgetData := data.Widgets[widgetIndex]
switch widgetData.Query.QueryType {
case "builder":
migrate := transition.NewMigrateCommon(logger)
for _, query := range widgetData.Query.Builder.QueryData {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
}
for _, query := range widgetData.Query.Builder.QueryFormulas {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
}
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
}
case "clickhouse_sql":
for _, query := range widgetData.Query.ClickhouseSQL {

View File

@@ -122,7 +122,7 @@ func (d *DashboardSpec) validatePanels() error {
}
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 {
@@ -285,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

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

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

@@ -1,124 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
)
// V1 → V2 migration. The v1 storable shape is the frontend's `DashboardData`
// (see frontend/src/types/api/dashboard/getAll.ts); v2 is DashboardV2 /
// DashboardSpec.
//
// Assumes the v1 widget query data has already been migrated to v5 shape
// (transition.dashboardMigrateV5). Pre-v5 builder queries will produce
// invalid v2 envelopes — run the v4→v5 migration first.
//
// The conversion is split across sibling files by concern:
// - perses_v1_to_v2_tags.go tags
// - perses_v1_to_v2_panels.go widgets → panels (+ panel field mappers)
// - perses_v1_to_v2_queries.go widget queries
// - perses_v1_to_v2_layouts.go grid layouts and sections
// - perses_v1_to_v2_variables.go variables
// - perses_v1_to_v2_decoder.go v1Decoder: typed field reads + malformed-field detection
// ══════════════════════════════════════════════
// Entry point
// ══════════════════════════════════════════════
func (storable StorableDashboard) IsV2() bool {
metadata, _ := storable.Data["metadata"].(map[string]any)
if metadata == nil {
return false
}
version, _ := metadata["schemaVersion"].(string)
return version == SchemaVersion
}
func (storable StorableDashboard) ConvertV1ToV2() (result *DashboardV2, err error) {
// Legacy v1 data can be arbitrarily malformed. The accessors degrade
// gracefully, but recover from any unforeseen panic so one bad dashboard
// surfaces as an error (to be logged and skipped) rather than crashing the run.
defer func() {
if r := recover(); r != nil {
result, err = nil, errors.Newf(errors.TypeInternal, ErrCodeDashboardMigrationFailed, "panic converting dashboard %s: %v", storable.ID, r)
}
}()
if storable.IsV2() {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardMigrationFailed, "dashboard %s is already in %s schema", storable.ID, SchemaVersion)
}
d := &v1Decoder{}
title := d.readString(storable.Data, "title")
description := d.readString(storable.Data, "description")
image := d.readString(storable.Data, "image")
sanitizeWidgetIDs(storable.Data)
panels := d.convertV1Panels(retainPlacedWidgets(storable.Data))
spec := DashboardSpec{
Display: Display{Name: clipName(title, MaxDisplayNameLen), Description: description},
Variables: d.convertV1Variables(storable.Data["variables"]),
Panels: panels,
Layouts: d.convertV1Layouts(storable.Data, panels),
// v1 has no dashboard links; emit [] (not nil) so the required field round-trips.
Links: []Link{},
}
// marshal and unmarshal cycle to confirm full validation
raw, marshalErr := json.Marshal(spec)
if marshalErr != nil {
return nil, errors.WrapInternalf(marshalErr, errors.CodeInternal, "marshal converted dashboard %s", storable.ID)
}
if err := json.Unmarshal(raw, new(DashboardSpec)); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardMigrationFailed, "converted dashboard %s is invalid", storable.ID)
}
tags := d.convertV1TagsForOrg(storable.OrgID, storable.Data["tags"])
if err := d.errIfHasMalformedFields(); err != nil {
return nil, err
}
return &DashboardV2{
Identifiable: storable.Identifiable,
TimeAuditable: storable.TimeAuditable,
UserAuditable: storable.UserAuditable,
OrgID: storable.OrgID,
Locked: storable.Locked,
Source: storable.Source,
DashboardV2MetadataBase: DashboardV2MetadataBase{
SchemaVersion: SchemaVersion,
Image: image,
},
Name: generateDashboardName(title),
Tags: tags,
Spec: spec,
}, nil
}
// V1Name derives the v2 name column from a v1 dashboard's title. Used to backfill the
// name of a dashboard whose data fails to migrate, so it stays findable by name.
func (storable StorableDashboard) V1Name() string {
return generateDashboardName(storable.Data.readString("title"))
}
// ══════════════════════════════════════════════
// In-place migration (temporary)
// ══════════════════════════════════════════════
// V1ToV2MigrationResult is the summary of migrating every dashboard in an org
// from the v1 to the v2 schema in place. Each v1 dashboard's stored data is
// converted and overwritten; dashboards already in v2 are skipped.
type V1ToV2MigrationResult struct {
Total int `json:"total"`
Migrated int `json:"migrated"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Results []V1ToV2MigrationItem `json:"results"`
}
type V1ToV2MigrationItem struct {
ID string `json:"id"`
Status string `json:"status"` // migrated | skipped | failed
Error string `json:"error,omitempty"`
}

View File

@@ -1,214 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
)
// ══════════════════════════════════════════════
// v1 decoder
// ══════════════════════════════════════════════
// v1Decoder reads fields out of the untyped v1 dashboard blob. Every read*
// method follows the same contract: a field that is absent or null yields the
// zero value; a field present with the wrong type yields zero AND records a
// malformed-field error. Conversion proceeds (so one bad field doesn't abort
// the rest) and ConvertV1ToV2 returns d.malformedFieldsErr() at the end so the
// dashboard is logged and skipped.
//
// Polymorphic v1 fields (spanGaps bool|number, selectedValue string|array, …)
// are read with a type switch on the already-extracted value, never through
// these accessors, so they stay lenient by construction.
type v1Decoder struct {
bad []string
seen map[string]struct{}
}
// note records a decoding problem (malformed field, unknown value, swallowed
// sub-parse error), deduping identical messages. ConvertV1ToV2 surfaces these
// via errIfHasMalformedFields.
func (d *v1Decoder) note(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if _, dup := d.seen[msg]; dup {
return
}
if d.seen == nil {
d.seen = make(map[string]struct{})
}
d.seen[msg] = struct{}{}
d.bad = append(d.bad, msg)
}
// noteMalformedField records a v1 field present with the wrong Go type.
func (d *v1Decoder) noteMalformedField(field string, raw any) {
d.note("%q has unexpected type %T", field, raw)
}
// detailErr renders an error for a diagnostic note, unfolding the structured
// detail our JSON binding attaches via WithAdditional. A plain %v on these
// errors prints only the innermost message ("request body contains invalid
// field value") and drops the field/type context that says which field was
// wrong — the part that actually tells you what to fix.
func detailErr(err error) string {
if err == nil {
return ""
}
j := errors.AsJSON(err)
if len(j.Errors) == 0 {
return err.Error()
}
details := make([]string, 0, len(j.Errors))
for _, e := range j.Errors {
details = append(details, e.Message)
}
return j.Message + ": " + strings.Join(details, "; ")
}
func (d *v1Decoder) errIfHasMalformedFields() error {
if len(d.bad) == 0 {
return nil
}
// One field per line: these lists run long (a bad widget query is reported
// once per widget), and a single "; "-joined line is an unscannable wall.
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidData, "malformed v1 dashboard fields:\n %s", strings.Join(d.bad, "\n "))
}
func readField[T any](d *v1Decoder, m map[string]any, key string) T {
var zero T
v, present := m[key]
if !present || v == nil {
return zero
}
t, ok := v.(T)
if !ok {
d.noteMalformedField(key, v)
return zero
}
return t
}
func (d *v1Decoder) readString(m map[string]any, key string) string {
return readField[string](d, m, key)
}
func (d *v1Decoder) readFloat(m map[string]any, key string) float64 {
v, present := m[key]
if !present || v == nil {
return 0
}
f, ok := coerceFloat(v)
if !ok {
d.noteMalformedField(key, v)
return 0
}
return f
}
// coerceFloat accepts a JSON number or a numeric string (v1 sometimes stores
// numbers like softMin as quoted strings). A blank string is "unset", not a
// number, so it fails to coerce.
func coerceFloat(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
if err != nil {
return 0, false
}
return f, true
}
return 0, false
}
func (d *v1Decoder) readBool(m map[string]any, key string) bool { return readField[bool](d, m, key) }
func (d *v1Decoder) readArray(m map[string]any, key string) []any { return readField[[]any](d, m, key) }
func (d *v1Decoder) readObject(m map[string]any, key string) map[string]any {
return readField[map[string]any](d, m, key)
}
// readInt narrows a numeric field to int (JSON numbers decode as float64).
func (d *v1Decoder) readInt(m map[string]any, key string) int { return int(d.readFloat(m, key)) }
func (d *v1Decoder) readFloatPtr(m map[string]any, key string) *float64 {
v, present := m[key]
if !present || v == nil {
return nil
}
// A blank string means "unset" (v1's empty softMin/softMax), not malformed.
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
return nil
}
f, ok := coerceFloat(v)
if !ok {
d.noteMalformedField(key, v)
return nil
}
return &f
}
// clipName truncates s to at most limit runes so a v1 name over a v2 length bound
// (MaxDisplayNameLen / MaxLayoutTitleLen) is shortened rather than failing migration.
func clipName(s string, limit int) string {
r := []rune(s)
if len(r) <= limit {
return s
}
return string(r[:limit])
}
func (d *v1Decoder) readStringMap(m map[string]any, key string) map[string]string {
// An empty list is a stand-in for an empty map here; tolerate it silently
// rather than flagging the wrong-type as malformed.
if s, ok := m[key].([]any); ok && len(s) == 0 {
return nil
}
raw := d.readObject(m, key)
if len(raw) == 0 {
return nil
}
out := make(map[string]string, len(raw))
for k, v := range raw {
s, ok := v.(string)
if !ok {
d.noteMalformedField(key+"."+k, v)
continue
}
out[k] = s
}
return out
}
func (d *v1Decoder) readObjects(m map[string]any, key string) []map[string]any {
raw := d.readArray(m, key)
if len(raw) == 0 {
return nil
}
out := make([]map[string]any, 0, len(raw))
for i, item := range raw {
obj, ok := item.(map[string]any)
if !ok {
d.noteMalformedField(fmt.Sprintf("%s[%d]", key, i), item)
continue
}
out = append(out, obj)
}
return out
}
// decodeMapInto converts an untyped map[string]any into a typed T by
// round-tripping through JSON, letting encoding/json (struct tags, custom
// UnmarshalJSON) do the field mapping instead of hand-copying out of the map.
func decodeMapInto[T any](src map[string]any) (T, error) {
var dst T
bytes, err := json.Marshal(src)
if err != nil {
return dst, err
}
if err := json.Unmarshal(bytes, &dst); err != nil {
return dst, err
}
return dst, nil
}

View File

@@ -1,352 +0,0 @@
package dashboardtypes
import (
"sort"
"strings"
"github.com/perses/spec/go/common"
"github.com/perses/spec/go/dashboard"
)
// panelRefPrefix is the JSON-ref prefix a grid item uses to point at a panel:
// "#/spec/panels/<id>".
const panelRefPrefix = "#/spec/panels/"
// sanitizePanelID rewrites a widget id to something valid in a panel $ref. Perses
// accepts only [a-zA-Z0-9_-] per ref segment (common.jsonRefMatching), so every
// other rune (em dash, spaces, dots, unicode, …) is mapped to a hyphen.
func sanitizePanelID(id string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
return r
default:
return '-'
}
}, id)
}
// sanitizeWidgetIDs rewrites every widget id in the raw v1 data — widgets[].id,
// layout[].i, panelMap keys and their widgets[].i — through sanitizePanelID, so a
// panel's map key and the layout $ref pointing at it stay identical (an illegal char
// in one but not the other would dangle the ref). Runs before panels/layouts build.
func sanitizeWidgetIDs(data StorableDashboardData) {
sanitizeField := func(raw any, field string) {
items, _ := raw.([]any)
for _, it := range items {
if m, ok := it.(map[string]any); ok {
if s, ok := m[field].(string); ok {
m[field] = sanitizePanelID(s)
}
}
}
}
sanitizeField(data["widgets"], "id")
sanitizeField(data["layout"], "i")
if panelMap, ok := data["panelMap"].(map[string]any); ok {
for key, v := range panelMap {
if s := sanitizePanelID(key); s != key {
panelMap[s] = v
delete(panelMap, key)
}
if m, ok := v.(map[string]any); ok {
sanitizeField(m["widgets"], "i")
}
}
}
}
// ══════════════════════════════════════════════
// Layouts (data.layout + data.panelMap)
// ══════════════════════════════════════════════
// convertV1Layouts groups v1 react-grid-layout entries into v2 grid layouts.
// Membership is positional (as the frontend renders): each row widget owns the
// panels below it until the next row; panels above the first row form an unnamed
// grid with no section header. Collapsed rows are the exception — their children
// live in panelMap[rowID].widgets, not `layout`.
func (d *v1Decoder) convertV1Layouts(data StorableDashboardData, panels map[string]*Panel) []Layout {
layout := d.readObjects(data, "layout")
if len(layout) == 0 {
return []Layout{}
}
// react-grid-layout can persist the same widget id more than once. Keep the first
// occurrence in stored order (mirroring getUpdatedLayout — the losing entry's
// geometry is discarded, not merged) and drop the rest. Dedupe before sortByPosition
// so "first" means first-in-stored-order, not topmost. Entries with no id are left
// for the main loop to drop.
seenWidgetIds := make(map[string]bool, len(layout))
dedupedLayouts := layout[:0]
for _, item := range layout {
if id := d.readString(item, "i"); id != "" {
if seenWidgetIds[id] {
continue
}
seenWidgetIds[id] = true
}
dedupedLayouts = append(dedupedLayouts, item)
}
layout = dedupedLayouts
rows := d.extractRowsAndCollapsedWidgets(data)
// ids placed directly in `layout`. A collapsed child also listed here is rendered from
// layout (the open section), so it's dropped from its collapsed section below.
placedInLayout := make(map[string]bool, len(layout))
for _, item := range layout {
if id := d.readString(item, "i"); id != "" {
placedInLayout[id] = true
}
}
d.sortByPosition(layout)
type section struct {
row *rowInfo // nil for the unnamed grid of ungrouped panels
items []map[string]any
}
topSectionWithoutHeader := &section{}
sectionsWithHeader := make([]*section, 0, len(rows))
currentRowHeader := topSectionWithoutHeader
for _, item := range layout {
id := d.readString(item, "i")
if id == "" {
continue
}
if row, ok := rows[id]; ok {
newRowHeader := &section{row: row, items: d.extractValidLayoutItemsForCollapsedSection(row.collapsedWidgets, panels, placedInLayout)}
sectionsWithHeader = append(sectionsWithHeader, newRowHeader)
// A collapsed row owns only its stashed children; later panels → ungrouped.
if row.collapsed {
currentRowHeader = topSectionWithoutHeader
} else {
currentRowHeader = newRowHeader
}
continue
}
// Keep a layout entry only if its widget became a panel; otherwise (skipped
// widget, deleted id, or the "__dropping-elem__" drag placeholder) it would
// reference a panel that does not exist. Rows are handled above.
if _, ok := panels[id]; !ok {
continue
}
currentRowHeader.items = append(currentRowHeader.items, item)
}
out := make([]Layout, 0, len(sectionsWithHeader)+1)
if len(topSectionWithoutHeader.items) > 0 {
out = append(out, d.buildV2GridLayout(nil, topSectionWithoutHeader.items))
}
for _, sec := range sectionsWithHeader {
out = append(out, d.buildV2GridLayout(sec.row, sec.items))
}
return out
}
// retainPlacedWidgets drops widgets the v1 layout never places, returning the
// filtered widgets. v1 doesn't render an unplaced widget, so converting it — and
// noting any problems it has — is pure noise; filter before conversion so only
// rendered widgets reach convertV1Panels. A non-array widgets value is returned
// untouched for convertV1Panels to flag; non-map entries are kept so it still
// flags them as malformed.
func retainPlacedWidgets(data StorableDashboardData) any {
widgets, ok := data["widgets"].([]any)
if !ok {
return data["widgets"]
}
placed := placedWidgetIDs(data)
kept := make([]any, 0, len(widgets))
for _, w := range widgets {
wm, ok := w.(map[string]any)
if !ok {
kept = append(kept, w) // malformed entry — leave it for convertV1Panels to note
continue
}
if id, _ := wm["id"].(string); placed[id] {
kept = append(kept, w)
}
}
return kept
}
// placedWidgetIDs returns the set of widget ids the v1 layout actually renders:
// every id in `layout`, plus the collapsed-row children stashed in panelMap.
// Read leniently (no malformed notes) — convertV1Layouts re-reads these and
// reports any genuine problems.
func placedWidgetIDs(data StorableDashboardData) map[string]bool {
ids := make(map[string]bool)
if layout, ok := data["layout"].([]any); ok {
for _, e := range layout {
if m, ok := e.(map[string]any); ok {
if i, ok := m["i"].(string); ok && i != "" {
ids[i] = true
}
}
}
}
if panelMap, ok := data["panelMap"].(map[string]any); ok {
for _, v := range panelMap {
m, ok := v.(map[string]any)
if !ok {
continue
}
widgets, ok := m["widgets"].([]any)
if !ok {
continue
}
for _, w := range widgets {
if wm, ok := w.(map[string]any); ok {
if i, ok := wm["i"].(string); ok && i != "" {
ids[i] = true
}
}
}
}
}
return ids
}
// extractValidLayoutItemsForCollapsedSection keeps only the collapsed-row children
// backed by a real panel and not already placed in `layout`, dropping ghosts and any
// child the open layout renders instead. These come from panelMap and skip the main
// loop's per-item panel check, so a grid never references a missing or twice-placed panel.
func (d *v1Decoder) extractValidLayoutItemsForCollapsedSection(items []map[string]any, panels map[string]*Panel, placedInLayout map[string]bool) []map[string]any {
out := make([]map[string]any, 0, len(items))
seen := make(map[string]bool, len(items))
for _, item := range items {
id := d.readString(item, "i")
if id == "" {
continue
}
if _, ok := panels[id]; !ok {
continue
}
if placedInLayout[id] || seen[id] {
continue
}
seen[id] = true
out = append(out, item)
}
return out
}
type rowInfo struct {
title string
collapsed bool
collapsedWidgets []map[string]any
}
// extractRowsAndCollapsedWidgets returns the row widgets keyed by id; collapsed
// rows also carry their children stashed under panelMap[id].widgets.
func (d *v1Decoder) extractRowsAndCollapsedWidgets(data StorableDashboardData) map[string]*rowInfo {
panelMap := d.readObject(data, "panelMap")
rows := make(map[string]*rowInfo)
for _, w := range d.readObjects(data, "widgets") {
// Read id directly (not via readString): a non-string id is skipped silently by
// convertV1Panels, so flagging it malformed here would fail the migration for a
// widget that's already been dropped.
id, _ := w["id"].(string)
if d.readString(w, "panelTypes") != "row" || id == "" {
continue
}
row := &rowInfo{title: d.readString(w, "title")}
// Some templates store panelMap[id] as a bare []widgetID instead of the
// canonical {widgets, collapsed}. The frontend treats such a non-object
// entry as "not collapsed" (see GridCardLayout), so read it leniently: a
// non-map yields nil, which reads as not collapsed.
pm, _ := panelMap[id].(map[string]any)
if d.readBool(pm, "collapsed") {
row.collapsed = true
row.collapsedWidgets = d.readObjects(pm, "widgets")
}
rows[id] = row
}
return rows
}
// buildV2GridLayout builds one v2 grid. row is nil for the unnamed grid (no
// display); otherwise the grid takes the row's title and collapse state. Items are
// sorted by (y, x) then vertically compacted (see compactGridItemsVertically).
func (d *v1Decoder) buildV2GridLayout(row *rowInfo, items []map[string]any) Layout {
d.sortByPosition(items)
spec := dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, 0, len(items))}
if row != nil {
spec.Display = &dashboard.GridLayoutDisplay{
Title: clipName(row.title, MaxLayoutTitleLen),
Collapse: &dashboard.GridLayoutCollapse{Open: !row.collapsed},
}
}
for _, item := range items {
id := d.readString(item, "i")
spec.Items = append(spec.Items, dashboard.GridItem{
X: d.readInt(item, "x"),
Y: d.readInt(item, "y"),
Width: d.readInt(item, "w"),
Height: d.readInt(item, "h"),
// Path mirrors what JSONRef.validate derives on decode (each "/segment"
// of the ref; the leading "#" captures nothing).
Content: &common.JSONRef{Ref: panelRefPrefix + id, Path: []string{"spec", "panels", id}},
})
}
compactGridItemsVertically(spec.Items)
return Layout{Kind: dashboard.KindGridLayout, Spec: &spec}
}
// compactGridItemsVertically mirrors react-grid-layout's correctBounds+compact
// (compactType "vertical", allowOverlap false): clamp each item into the grid (x,y>=0;
// x+width<=cols by shifting left), then move sorted-first items up to fill space and
// down past collisions. Fixes overlaps, gaps, and out-of-bounds coords so the migrated
// grid matches the v1 UI and passes v2 validation.
func compactGridItemsVertically(items []dashboard.GridItem) {
collides := func(a, b dashboard.GridItem) bool {
return a.X < b.X+b.Width && b.X < a.X+a.Width && a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
}
firstCollision := func(l dashboard.GridItem, placed []dashboard.GridItem) (dashboard.GridItem, bool) {
for _, p := range placed {
if collides(l, p) {
return p, true
}
}
return dashboard.GridItem{}, false
}
for i := range items {
l := items[i]
if l.X+l.Width > gridColumnCount { // overflows right → shift left to fit
l.X = gridColumnCount - l.Width
}
if l.X < 0 {
l.X = 0
}
if l.Y < 0 {
l.Y = 0
}
for l.Y > 0 { // move up to fill space above
up := l
up.Y--
if _, hit := firstCollision(up, items[:i]); hit {
break
}
l.Y--
}
for { // then down past any collision with an already-placed item
c, hit := firstCollision(l, items[:i])
if !hit {
break
}
l.Y = c.Y + c.Height
}
items[i] = l
}
}
func (d *v1Decoder) sortByPosition(items []map[string]any) {
sort.SliceStable(items, func(i, j int) bool {
if yi, yj := d.readInt(items[i], "y"), d.readInt(items[j], "y"); yi != yj {
return yi < yj
}
return d.readInt(items[i], "x") < d.readInt(items[j], "x")
})
}

View File

@@ -1,487 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// Widgets → Panels
// ══════════════════════════════════════════════
// convertV1Panels walks the v1 `widgets` array and produces v2 panels keyed by
// the v1 widget id. WidgetRow entries (panelTypes == "row") are dropped here
// and consumed by convertV1Layouts as section headers.
func (d *v1Decoder) convertV1Panels(raw any) map[string]*Panel {
if raw == nil {
return map[string]*Panel{}
}
widgetsRaw, ok := raw.([]any)
if !ok {
d.noteMalformedField("widgets", raw)
return nil
}
panels := make(map[string]*Panel, len(widgetsRaw))
for i, widgetRaw := range widgetsRaw {
widget, ok := widgetRaw.(map[string]any)
if !ok {
d.noteMalformedField(fmt.Sprintf("widgets[%d]", i), widgetRaw)
continue
}
// A non-string (or missing) id can't be referenced by any layout entry, and
// v1 doesn't render such widgets either — skip silently, don't flag it as
// malformed. Read directly (not via readString) to avoid a malformed note.
id, ok := widget["id"].(string)
if !ok || id == "" {
continue
}
var panel *Panel
panelType := d.readString(widget, "panelTypes")
switch panelType {
case "graph":
panel = d.convertGraphWidget(widget)
case "time_series", "TIME_SERIES":
// Malformed panelTypes: the canonical v1 value is "graph". Some dashboards
// stored the v2/enum-style name instead; accept it as a time-series graph.
panel = d.convertGraphWidget(widget)
case "bar":
panel = d.convertBarWidget(widget)
case "value":
panel = d.convertValueWidget(widget)
case "pie":
panel = d.convertPieWidget(widget)
case "table":
panel = d.convertTableWidget(widget)
case "histogram":
panel = d.convertHistogramWidget(widget)
case "list":
panel = d.convertListWidget(widget)
case "row":
// "row" (section header) is handled by the layout pass;
continue
default:
// Unknown/unsupported panel type — v1 can't render it either, so skip the
// widget silently rather than failing the whole migration.
continue
}
if panel == nil {
continue
}
if len(panel.Spec.Queries) == 0 {
// No renderable queries — every query was dropped as unrenderable, or none
// were defined. v1 renders nothing, so skip the widget silently.
continue
}
// v1 has no panel links; emit [] (not nil) so the required links field
// round-trips, rather than setting it in every panel constructor.
panel.Spec.Links = []Link{}
panels[id] = panel
}
return panels
}
func (d *v1Decoder) convertGraphWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindTimeSeries,
Spec: &TimeSeriesPanelSpec{
Visualization: TimeSeriesVisualization{
BasicVisualization: d.basicVisualization(w),
FillSpans: d.readBool(w, "fillSpans"),
},
Formatting: d.panelFormatting(w),
ChartAppearance: TimeSeriesChartAppearance{
LineInterpolation: mapV1Enum(d.readString(w, "lineInterpolation"), LineInterpolationSpline,
LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore),
ShowPoints: d.readBool(w, "showPoints"),
LineStyle: mapV1Enum(d.readString(w, "lineStyle"), LineStyleSolid, LineStyleSolid, LineStyleDashed),
FillMode: mapV1Enum(d.readString(w, "fillMode"), FillModeNone, FillModeSolid, FillModeGradient, FillModeNone),
SpanGaps: mapV1SpanGaps(w["spanGaps"]),
},
Axes: d.axesFromWidget(w),
Legend: d.legendFromWidget(w),
Thresholds: d.mapV1ThresholdsWithLabel(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindTimeSeries),
},
}
}
func (d *v1Decoder) convertBarWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindBarChart,
Spec: &BarChartPanelSpec{
Visualization: BarChartVisualization{
BasicVisualization: d.basicVisualization(w),
FillSpans: d.readBool(w, "fillSpans"),
StackedBarChart: d.readBool(w, "stackedBarChart"),
},
Formatting: d.panelFormatting(w),
Axes: d.axesFromWidget(w),
Legend: d.legendFromWidget(w),
Thresholds: d.mapV1ThresholdsWithLabel(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindBarChart),
},
}
}
func (d *v1Decoder) convertValueWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindNumber,
Spec: &NumberPanelSpec{
Visualization: d.basicVisualization(w),
Formatting: d.panelFormatting(w),
Thresholds: d.mapV1ComparisonThresholds(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindNumber),
},
}
}
func (d *v1Decoder) convertPieWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindPieChart,
Spec: &PieChartPanelSpec{
Visualization: d.basicVisualization(w),
Formatting: d.panelFormatting(w),
Legend: d.legendFromWidget(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindPieChart),
},
}
}
func (d *v1Decoder) convertTableWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindTable,
Spec: &TablePanelSpec{
Visualization: d.basicVisualization(w),
Formatting: TableFormatting{
ColumnUnits: d.readStringMap(w, "columnUnits"),
DecimalPrecision: mapV1Precision(w["decimalPrecision"]),
},
Thresholds: d.mapV1TableThresholds(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindTable),
},
}
}
func (d *v1Decoder) convertHistogramWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindHistogram,
Spec: &HistogramPanelSpec{
HistogramBuckets: HistogramBuckets{
BucketCount: d.readFloatPtr(w, "bucketCount"),
BucketWidth: d.readFloatPtr(w, "bucketWidth"),
MergeAllActiveQueries: d.readBool(w, "mergeAllActiveQueries"),
},
Legend: d.legendFromWidget(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindHistogram),
},
}
}
func (d *v1Decoder) convertListWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindList,
Spec: &ListPanelSpec{
SelectFields: d.mapV1SelectFields(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindList),
},
}
}
// ══════════════════════════════════════════════
// Panel-spec shared helpers
// ══════════════════════════════════════════════
func (d *v1Decoder) widgetDisplay(w map[string]any) Display {
return Display{Name: clipName(d.readString(w, "title"), MaxDisplayNameLen), Description: d.readString(w, "description")}
}
func (d *v1Decoder) basicVisualization(w map[string]any) BasicVisualization {
return BasicVisualization{TimePreference: mapV1TimePreference(d.readString(w, "timePreferance"))}
}
func (d *v1Decoder) panelFormatting(w map[string]any) PanelFormatting {
return PanelFormatting{Unit: d.readString(w, "yAxisUnit"), DecimalPrecision: mapV1Precision(w["decimalPrecision"])}
}
func (d *v1Decoder) axesFromWidget(w map[string]any) Axes {
return Axes{
SoftMin: d.readFloatPtr(w, "softMin"),
SoftMax: d.readFloatPtr(w, "softMax"),
IsLogScale: d.readBool(w, "isLogScale"),
}
}
func (d *v1Decoder) legendFromWidget(w map[string]any) Legend {
return Legend{
Position: mapV1Enum(d.readString(w, "legendPosition"), LegendPositionBottom, LegendPositionBottom, LegendPositionRight),
CustomColors: d.readStringMap(w, "customLegendColors"),
}
}
func (d *v1Decoder) mapV1SelectFields(w map[string]any) []telemetrytypes.TelemetryFieldKey {
field := "selectedLogFields"
raw := d.readArray(w, field)
if len(raw) == 0 {
field = "selectedTracesFields"
raw = d.readArray(w, field)
}
if len(raw) == 0 {
return nil
}
normalizePreV5FieldKeys(raw)
fields, err := decodeTelemetryFields(raw)
if err != nil {
d.note("widget %q has malformed %s: %v", d.readString(w, "id"), field, err)
return nil
}
// Drop nameless entries (blank column rows) — v2 requires a name, and the v1
// UI renders nothing for them anyway.
out := fields[:0]
for _, f := range fields {
if f.Name != "" {
out = append(out, f)
}
}
return out
}
func decodeTelemetryFields(raw []any) ([]telemetrytypes.TelemetryFieldKey, error) {
bytes, err := json.Marshal(raw)
if err != nil {
return nil, err
}
var fields []telemetrytypes.TelemetryFieldKey
if err := json.Unmarshal(bytes, &fields); err != nil {
return nil, err
}
return fields, nil
}
// ══════════════════════════════════════════════
// Panel field mappers
// ══════════════════════════════════════════════
// v1 stores timePreferance as `GLOBAL_TIME`, `LAST_5_MIN`, … (see
// frontend/src/container/NewWidget/RightContainer/timeItems.ts). v2 uses the
// lowercase form, so the translation is just downcase.
func mapV1TimePreference(s string) TimePreference {
if s == "" {
return TimePreferenceGlobalTime
}
candidate := TimePreference{valuer.NewString(strings.ToLower(s))}
for _, allowed := range candidate.Enum() {
if allowed == candidate {
return candidate
}
}
return TimePreferenceGlobalTime
}
// mapV1Precision is polymorphic (string|number), so it type-switches the raw
// value rather than reading through a typed accessor.
func mapV1Precision(raw any) PrecisionOption {
switch v := raw.(type) {
case string:
candidate := PrecisionOption{valuer.NewString(v)}
for _, allowed := range candidate.Enum() {
if allowed == candidate {
return candidate
}
}
case float64:
n := int(v)
if n >= 0 && n <= 4 {
return PrecisionOption{valuer.NewString(strconv.Itoa(n))}
}
}
return PrecisionOption2
}
// mapV1Enum picks the v1 string value if it matches one of the allowed v2
// values, otherwise returns the fallback. v1 frontend enums (lineInterpolation,
// lineStyle, fillMode, legendPosition) already use the v2 lowercase form.
func mapV1Enum[T interface{ StringValue() string }](s string, fallback T, allowed ...T) T {
if s == "" {
return fallback
}
for _, a := range allowed {
if a.StringValue() == s {
return a
}
}
return fallback
}
// v1 spanGaps is `boolean | number`. true → span every gap; false → never span;
// a number is interpreted (per frontend SeriesProps.spanGaps docs) as an
// X-axis threshold in seconds. Polymorphic, so it type-switches the raw value.
func mapV1SpanGaps(raw any) SpanGaps {
switch v := raw.(type) {
case bool:
return SpanGaps{FillOnlyBelow: false}
case float64:
return SpanGaps{FillOnlyBelow: true, FillLessThan: time.Duration(v * float64(time.Second)).String()}
}
return SpanGaps{FillOnlyBelow: false}
}
func (d *v1Decoder) mapV1ThresholdsWithLabel(w map[string]any) []ThresholdWithLabel {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]ThresholdWithLabel, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
if color == "" {
// Only color is required on ThresholdWithLabel; label is optional, so an
// unlabeled threshold must survive.
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, ThresholdWithLabel{Value: value, Unit: d.readString(t, "thresholdUnit"), Color: color, Label: d.readString(t, "thresholdLabel")})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1ComparisonThresholds(w map[string]any) []ComparisonThreshold {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]ComparisonThreshold, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
if color == "" {
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, ComparisonThreshold{
Value: value,
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
Unit: d.readString(t, "thresholdUnit"),
Color: color,
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1TableThresholds(w map[string]any) []TableThreshold {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]TableThreshold, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
columnName := d.readString(t, "thresholdTableOptions")
if color == "" || columnName == "" {
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, TableThreshold{
ComparisonThreshold: ComparisonThreshold{
Value: value,
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
Unit: d.readString(t, "thresholdUnit"),
Color: color,
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
},
ColumnName: columnName,
})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1ComparisonOperator(s string) ComparisonOperator {
switch s {
case ">", "gt":
return ComparisonOperatorAbove
case ">=", "gte":
return ComparisonOperatorAboveOrEqual
case "<", "lt":
return ComparisonOperatorBelow
case "<=", "lte":
return ComparisonOperatorBelowOrEqual
case "=", "==", "eq":
return ComparisonOperatorEqual
case "!=", "neq":
return ComparisonOperatorNotEqual
default:
// v1 often leaves the operator empty or carries an unknown value; default to
// "above" without flagging.
return ComparisonOperatorAbove
}
}
// mapV1ThresholdFormat reads the raw value (not via readString) so a non-string
// thresholdFormat — some v1 dashboards store it as a number — defaults to text
// silently instead of being flagged malformed.
func mapV1ThresholdFormat(raw any) ThresholdFormat {
s, _ := raw.(string)
switch strings.ToLower(s) {
case "background":
return ThresholdFormatBackground
case "text":
return ThresholdFormatText
}
return ThresholdFormatText
}

View File

@@ -1,448 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// ══════════════════════════════════════════════
// Queries
// ══════════════════════════════════════════════
// convertV1WidgetQuery returns exactly one Query (per Spec.Validate). The kind
// chosen depends on the v1 widget query shape:
// - a single query (promql / clickhouse_sql / builder) → its native kind
// - multiple queries → signoz/CompositeQuery
//
// A single query is never wrapped in a CompositeQuery; in particular List
// panels accept only a bare signoz/BuilderQuery. Builder queries are routed
// through qb.WrapInV5Envelope (in collectV1QueryEnvelopes), which translates v4
// builder-field names (orderBy/selectColumns/dataSource) into their v5
// equivalents and adds the `signal` field required by BuilderQuerySpec's
// per-signal dispatch.
func (d *v1Decoder) convertV1WidgetQuery(widget map[string]any, panelKind PanelPluginKind) []Query {
envelopes, signal := d.collectV1QueryEnvelopes(widget, panelKind)
if len(envelopes) == 0 {
return nil
}
// List panels accept only a bare BuilderQuery — never a CompositeQuery. Keep the
// first query and drop the rest so a multi-query v1 list widget still migrates.
if panelKind == PanelKindList && len(envelopes) > 1 {
envelopes = envelopes[:1]
}
requestType := requestTypeForPanel(panelKind)
// A single query keeps its native kind — never wrapped in a CompositeQuery.
if len(envelopes) == 1 {
if q := singleQueryFromEnvelope(envelopes[0], requestType, signal); q != nil {
return []Query{*q}
}
}
// Default: wrap in CompositeQuery.
composite, err := parseCompositeFromEnvelopes(envelopes)
if err != nil || composite == nil {
d.note("widget %q: could not build query from %d envelope(s): %s", d.readString(widget, "id"), len(envelopes), detailErr(err))
return nil
}
return []Query{{
Kind: requestType,
Spec: QuerySpec{
Plugin: QueryPlugin{Kind: QueryKindComposite, Spec: composite},
},
}}
}
// dropUnrenderableQueries removes queries whose aggregation can't render (see
// queryIsUnrenderable). If every query is unrenderable the result is empty, so the
// widget produces no panel and is skipped silently (convertV1Panels) — matching v1,
// which renders nothing.
func dropUnrenderableQueries(queries []map[string]any) []map[string]any {
renderable := make([]map[string]any, 0, len(queries))
for _, q := range queries {
if !queryIsUnrenderable(q) {
renderable = append(renderable, q)
}
}
return renderable
}
// queryIsUnrenderable reports whether a builder query can't render because of its
// aggregations: a metrics query with none or an empty metric name, or a logs/traces
// query with an empty aggregation expression. No aggregations is valid for a raw
// logs/traces query, so that isn't flagged.
func queryIsUnrenderable(q map[string]any) bool {
aggs, _ := q["aggregations"].([]any)
switch signalFromDataSource(q["dataSource"]) {
case telemetrytypes.SignalMetrics:
if len(aggs) == 0 {
return true
}
for _, a := range aggs {
if agg, ok := a.(map[string]any); ok {
if mn, _ := agg["metricName"].(string); mn == "" {
return true
}
}
}
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
for _, a := range aggs {
if agg, ok := a.(map[string]any); ok {
if expr, _ := agg["expression"].(string); expr == "" {
return true
}
}
}
}
return false
}
// requestTypeForPanel maps a v2 panel plugin kind to the request type (result
// shape) its queries produce. Mirrors the frontend's panelTypeToRequestType
// (buildQueryRangeRequest.ts): time series for line/bar/histogram (histogram
// bins client-side from raw time series, V1 parity), scalar for
// number/pie/table, raw rows for list.
func requestTypeForPanel(panelKind PanelPluginKind) qb.RequestType {
switch panelKind {
case PanelKindTimeSeries, PanelKindBarChart, PanelKindHistogram:
return qb.RequestTypeTimeSeries
case PanelKindNumber, PanelKindPieChart, PanelKindTable:
return qb.RequestTypeScalar
case PanelKindList:
return qb.RequestTypeRaw
}
return qb.RequestTypeTimeSeries
}
// collectV1QueryEnvelopes inspects widget.query.queryType and produces a
// flattened list of v5-shaped envelopes. The returned signal is the dominant
// builder signal (if any), used for typed builder-query dispatch.
func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind PanelPluginKind) ([]map[string]any, telemetrytypes.Signal) {
queryMap := d.readObject(widget, "query")
if queryMap == nil {
d.note("widget %q has no query map", d.readString(widget, "id"))
return nil, telemetrytypes.Signal{}
}
rowLimitPanel := panelKind == PanelKindList || panelKind == PanelKindTable
// Raw (list) panels legitimately have no aggregation; every other panel needs one.
needsAggregation := requestTypeForPanel(panelKind) != qb.RequestTypeRaw
queryType := d.readString(queryMap, "queryType")
switch queryType {
case "promql":
promQueries := d.readObjects(queryMap, "promql")
var out []map[string]any
for _, q := range promQueries {
// Drop empty queries; if none remain the widget produces no queries and is
// skipped silently (convertV1Panels), as v1 renders nothing.
if d.readString(q, "query") == "" {
continue
}
out = append(out, promQLEnvelope(q))
}
return out, telemetrytypes.Signal{}
case "clickhouse_sql":
chQueries := d.readObjects(queryMap, "clickhouse_sql")
var out []map[string]any
for _, q := range chQueries {
// Drop empty queries; if none remain the widget produces no queries and is
// skipped silently (convertV1Panels), as v1 renders nothing.
if d.readString(q, "query") == "" {
continue
}
out = append(out, clickhouseEnvelope(q))
}
return out, telemetrytypes.Signal{}
// "builder" plus a blank queryType: v1 defaults an unset type to builder, so a
// missing value still carries a real builder query — try the builder path.
case "builder", "":
builder := d.readObject(queryMap, "builder")
if builder == nil {
d.note("widget %q has no builder data in the query map", d.readString(widget, "id"))
return nil, telemetrytypes.Signal{}
}
var out []map[string]any
var signal telemetrytypes.Signal
widgetType := d.readString(widget, "panelTypes")
queries := d.readObjects(builder, "queryData")
assignQueryDataNames(queries)
for _, q := range queries {
normalizePreV5QueryData(q, widgetType)
normalizePreV5SelectColumns(q)
normalizePreV5GroupBy(q)
normalizePreV5PageSize(q, rowLimitPanel)
normalizeQueryLimit(q)
if needsAggregation {
ensureDefaultAggregation(q)
}
// After the aggregation is settled (incl. an injected default), so a
// value-order key (#SIGNOZ_VALUE) can resolve against it.
normalizeOrderByKeys(q)
}
queries = dropUnrenderableQueries(queries)
for _, q := range queries {
name := d.readString(q, "queryName")
out = append(out, qb.WrapInV5Envelope(name, q, string(qb.QueryTypeBuilder.StringValue())))
if signal.IsZero() {
signal = signalFromDataSource(q["dataSource"])
}
}
formulas := d.readObjects(builder, "queryFormulas")
assignMissingFormulaNames(formulas)
for _, f := range formulas {
normalizePreV5QueryData(f, widgetType)
name := d.readString(f, "queryName")
env := qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue()))
backfillFormulaFields(env, f)
// Drop a formula whose expression the validator rejects (blank/unparseable);
// v1 tolerated it but v2 fails the whole query. Reuse the real validator
// rather than reimplement it, as we do for functions.
if !formulaEnvelopeIsValid(env) {
continue
}
out = append(out, env)
}
for _, op := range d.readObjects(builder, "queryTraceOperator") {
// A trace operator's expression is the operation itself ("A=>B->C") and is
// required (ParseExpression rejects a blank one); drop it if empty.
expression := d.readString(op, "expression")
if expression == "" {
continue
}
normalizePreV5QueryData(op, widgetType)
normalizePreV5GroupBy(op)
normalizeOrderByKeys(op)
name := d.readString(op, "queryName")
out = append(out, traceOperatorEnvelope(name, expression, op))
}
return out, signal
default:
d.note("widget %q has unknown queryType %q", d.readString(widget, "id"), queryType)
}
return nil, telemetrytypes.Signal{}
}
// traceOperatorEnvelope builds a v5 builder_trace_operator envelope. WrapInV5Envelope
// would misclassify a trace operator as a formula (its name differs from its
// expression, e.g. "A=>B->C"), so route the map through the builder-query path —
// temporarily aligning expression with name to dodge that heuristic — then restore the
// real expression, drop the builder-only signal (a trace operator's spec has no signal
// field), and set the trace-operator type.
func traceOperatorEnvelope(name, expression string, op map[string]any) map[string]any {
op["expression"] = name
env := qb.WrapInV5Envelope(name, op, string(qb.QueryTypeBuilder.StringValue()))
if spec, ok := env["spec"].(map[string]any); ok {
delete(spec, "signal")
spec["expression"] = expression
}
env["type"] = string(qb.QueryTypeTraceOperator.StringValue())
return env
}
// maxQueries mirrors the frontend MAX_QUERIES; builder query names run A..Z.
const maxQueries = 26
// assignQueryDataNames names builder data queries the way the frontend does: each
// unnamed query takes the first unused A..Z, deduped against existing names. It also
// forces expression == queryName, since a data query's expression is always its own
// name and WrapInV5Envelope's name != expression heuristic would otherwise
// misclassify the query as a formula.
func assignQueryDataNames(queries []map[string]any) {
taken := make(map[string]bool, len(queries))
for _, q := range queries {
if name, _ := q["queryName"].(string); name != "" {
taken[name] = true
}
}
for _, q := range queries {
name, _ := q["queryName"].(string)
if name == "" {
for i := 0; i < maxQueries; i++ {
candidate := string(rune('A' + i))
if !taken[candidate] {
name = candidate
taken[candidate] = true
break
}
}
q["queryName"] = name
}
q["expression"] = name
}
}
// formulaEnvelopeIsValid reports whether a builder_formula envelope's spec passes
// QueryBuilderFormula.Validate (blank/unparseable expression, blank name, invalid
// functions). Reuses the real validator rather than reimplementing it.
func formulaEnvelopeIsValid(env map[string]any) bool {
spec, ok := env["spec"].(map[string]any)
if !ok {
return false
}
raw, err := json.Marshal(spec)
if err != nil {
return false
}
var f qb.QueryBuilderFormula
if err := json.Unmarshal(raw, &f); err != nil {
return false
}
return f.Validate() == nil
}
// maxFormulas mirrors the frontend MAX_FORMULAS; formula names run F1..F20.
const maxFormulas = 20
// assignMissingFormulaNames fills queryName for unnamed formulas, mirroring the
// frontend: pick the first F{n} (n in 1..20) not already used by another formula.
// Formulas that already have a name keep it.
func assignMissingFormulaNames(formulas []map[string]any) {
taken := make(map[string]bool, len(formulas))
for _, f := range formulas {
if name, _ := f["queryName"].(string); name != "" {
taken[name] = true
}
}
for _, f := range formulas {
if name, _ := f["queryName"].(string); name != "" {
continue
}
for i := 1; i <= maxFormulas; i++ {
candidate := "F" + strconv.Itoa(i)
if !taken[candidate] {
f["queryName"] = candidate
taken[candidate] = true
break
}
}
}
}
func promQLEnvelope(q map[string]any) map[string]any {
return map[string]any{
"type": qb.QueryTypePromQL.StringValue(),
"spec": map[string]any{
"name": q["name"],
"query": q["query"],
"disabled": q["disabled"],
"legend": q["legend"],
},
}
}
func clickhouseEnvelope(q map[string]any) map[string]any {
return map[string]any{
"type": qb.QueryTypeClickHouseSQL.StringValue(),
"spec": map[string]any{
"name": q["name"],
"query": q["query"],
"disabled": q["disabled"],
"legend": q["legend"],
},
}
}
// singleQueryFromEnvelope returns a typed Query for one envelope, using its
// native query kind (promql/clickhouse_sql/builder) rather than wrapping it in
// a CompositeQuery. A bare signoz/BuilderQuery is valid for every panel kind
// and is the only kind List panels accept.
func singleQueryFromEnvelope(envelope map[string]any, requestType qb.RequestType, signal telemetrytypes.Signal) *Query {
t, _ := envelope["type"].(string)
spec, _ := envelope["spec"].(map[string]any)
switch t {
case qb.QueryTypePromQL.StringValue():
prom, err := decodeMapInto[qb.PromQuery](spec)
if err != nil {
return nil
}
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: prom.Name,
Plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &prom},
},
}
case qb.QueryTypeClickHouseSQL.StringValue():
ch, err := decodeMapInto[qb.ClickHouseQuery](spec)
if err != nil {
return nil
}
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: ch.Name,
Plugin: QueryPlugin{Kind: QueryKindClickHouseSQL, Spec: &ch},
},
}
case qb.QueryTypeBuilder.StringValue():
builderSpec := parseBuilderQuerySpec(spec, signal)
if builderSpec == nil {
return nil
}
name, _ := spec["name"].(string)
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: name,
Plugin: QueryPlugin{Kind: QueryKindBuilder, Spec: &BuilderQuerySpec{Spec: builderSpec}},
},
}
}
return nil
}
func parseCompositeFromEnvelopes(envelopes []map[string]any) (*CompositeQuerySpec, error) {
bytes, err := json.Marshal(envelopes)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v1 query envelopes")
}
var parsed []qb.QueryEnvelope
if err := json.Unmarshal(bytes, &parsed); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidWidgetQuery, "decode v5 query envelopes")
}
return &CompositeQuerySpec{Queries: parsed}, nil
}
func parseBuilderQuerySpec(rawSpec any, signal telemetrytypes.Signal) any {
spec, ok := rawSpec.(map[string]any)
if !ok {
return nil
}
if !signal.IsZero() {
spec["signal"] = signal.StringValue()
}
bytes, err := json.Marshal(spec)
if err != nil {
return nil
}
parsed, err := qb.UnmarshalBuilderQueryBySignal(bytes)
if err != nil {
return nil
}
return parsed
}
// signalFromDataSource maps a v1 data-source string to a v5 signal. Casing
// varies by source: builder queries store lowercase ("traces"), while variable
// `dynamicVariablesSource` stores capitalized ("Traces"), so match
// case-insensitively. Unknown values (e.g. "All telemetry") map to the zero
// Signal.
func signalFromDataSource(raw any) telemetrytypes.Signal {
s, _ := raw.(string)
switch strings.ToLower(s) {
case "traces":
return telemetrytypes.SignalTraces
case "logs":
return telemetrytypes.SignalLogs
case "metrics":
return telemetrytypes.SignalMetrics
}
return telemetrytypes.Signal{}
}

View File

@@ -1,499 +0,0 @@
package dashboardtypes
import (
"context"
"encoding/json"
"log/slog"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// Malformed-field normalization
// ══════════════════════════════════════════════
//
// Pre-v5 query-body reshapes for dashboards whose bodies aren't actually v5-shaped
// (e.g. stamped version:"v5" but never upgraded). The bulk of the upgrade is
// delegated to transition.MigrateQueryDataShapeSafe (see normalizePreV5QueryData);
// this file keeps only the reshapes it doesn't cover.
// preV5Migrator runs transition's shape-safe (idempotent) v4→v5 upgrade. Stateless
// after construction, so a shared instance with a discard logger / no ambiguity
// keys is fine.
var preV5Migrator = transition.NewDashboardMigrateV5(slog.New(slog.DiscardHandler), nil, nil)
// normalizePreV5QueryData upgrades one builder queryData/formula in place: the
// shared migrator, then a reshape of any existing aggregations[] it leaves alone.
func normalizePreV5QueryData(query map[string]any, widgetType string) {
dropLegacyFilter(query)
normalizeFilterItemOps(query)
preV5Migrator.MigrateQueryDataShapeSafe(context.Background(), query, widgetType)
normalizePreV5LogTraceAggregations(query)
normalizeMetricAggregations(query)
normalizeFunctionArgs(query)
dropInvalidFunctions(query)
// normalizeOrderByKeys runs in the caller, after ensureDefaultAggregation: a
// value-order key resolves against the aggregation that may have just been injected.
}
// dropInvalidFunctions removes any function the v5 validator would reject — an unknown
// name, or a missing/uncastable required arg (see Function.Validate). v1 tolerated these
// but v2 fails the whole query, so we drop just the offending function. Runs after
// normalizeFunctionArgs so a merely double-wrapped (but otherwise valid) function isn't
// lost.
func dropInvalidFunctions(query map[string]any) {
fns, ok := query["functions"].([]any)
if !ok {
return
}
kept := make([]any, 0, len(fns))
for _, f := range fns {
raw, err := json.Marshal(f)
if err != nil {
continue
}
var fn qb.Function
if err := json.Unmarshal(raw, &fn); err != nil {
continue
}
if fn.Validate() != nil {
continue
}
kept = append(kept, f)
}
query["functions"] = kept
}
// normalizeFunctionArgs collapses a doubly-wrapped function arg to a scalar. The
// v4→v5 migration that runs before ConvertV1ToV2 (transition.updateQueryData) wraps every arg as
// {name, value} without checking whether it's already a v5 arg, so a body that was
// already v5 comes back as {value:{value:60}} and fails validation ("must be a floating
// value"). We can't guard it at the source — transition's Migrate is shared and left
// untouched — so unwrap one level of {value:...} nesting here.
func normalizeFunctionArgs(query map[string]any) {
fns, ok := query["functions"].([]any)
if !ok {
return
}
for _, f := range fns {
fn, ok := f.(map[string]any)
if !ok {
continue
}
args, ok := fn["args"].([]any)
if !ok {
continue
}
for _, a := range args {
arg, ok := a.(map[string]any)
if !ok {
continue
}
if inner, ok := arg["value"].(map[string]any); ok {
if v, ok := inner["value"]; ok {
arg["value"] = v
}
}
}
}
}
// malformedOrderByValueKeys are v4 order-by columnNames meaning "order by the aggregation value"
// that the v5 aggregation validator rejects (validateOrderByForAggregation). All resolve
// to the same aggregation key. Add more as they surface. The frontend passes these
// through (the query-service resolves them), but the v2 dashboard validator only accepts
// a real aggregation key.
var malformedOrderByValueKeys = map[string]bool{
"#SIGNOZ_VALUE": true,
"A": true,
"A.count()": true,
"__result": true,
"value": true,
"A.p99(duration_nano)": true,
"aws_Kafka_MessagesInPerSec_max": true,
"byte_in_count": true,
"(http_server_request_duration_ms.bucket)": true,
}
// normalizeOrderByKeys rewrites any orderBy columnName in orderByValueKeys to the
// v5-valid aggregation key. Left untouched if the key can't resolve (no aggregation to
// name).
func normalizeOrderByKeys(query map[string]any) {
orders, ok := query["orderBy"].([]any)
if !ok {
return
}
key, ok := aggregationOrderKey(query)
if !ok {
return
}
for _, o := range orders {
order, ok := o.(map[string]any)
if !ok {
continue
}
if cn, _ := order["columnName"].(string); malformedOrderByValueKeys[cn] {
order["columnName"] = key
}
}
}
// aggregationOrderKey names the first aggregation the way validateOrderByForAggregation
// expects: "space(metricName)" for metrics, the expression for logs/traces.
func aggregationOrderKey(query map[string]any) (string, bool) {
aggs, ok := query["aggregations"].([]any)
if !ok || len(aggs) == 0 {
return "", false
}
agg, ok := aggs[0].(map[string]any)
if !ok {
return "", false
}
if signalFromDataSource(query["dataSource"]) == telemetrytypes.SignalMetrics {
metricName, _ := agg["metricName"].(string)
space, _ := agg["spaceAggregation"].(string)
if metricName == "" || space == "" {
return "", false
}
return space + "(" + metricName + ")", true
}
expr, _ := agg["expression"].(string)
if expr == "" {
return "", false
}
return expr, true
}
// backfillFormulaFields restores order/limit/having onto the formula's spec.
// WrapInV5Envelope's formula branch emits only name/expression/disabled/legend/functions
// and drops these three, even though QueryBuilderFormula supports them.
func backfillFormulaFields(env, formula map[string]any) {
spec := env["spec"].(map[string]any)
// limit and having are already v5-shaped (the shape-safe migrator rewrites having),
// so copy them across unchanged.
if limit, ok := formula["limit"]; ok {
spec["limit"] = limit
}
if having, ok := formula["having"]; ok {
spec["having"] = having
}
// orderBy is still in the v4 shape ([{columnName, order}]); reshape each entry into
// the v5 order shape ([{key: {name}, direction}]).
orderBy, ok := formula["orderBy"].([]any)
if !ok {
return
}
order := make([]any, 0, len(orderBy))
for _, item := range orderBy {
ob, ok := item.(map[string]any)
if !ok {
continue
}
order = append(order, map[string]any{
"key": map[string]any{"name": ob["columnName"]},
"direction": ob["order"],
})
}
spec["order"] = order
}
// dropLegacyFilter removes a v4-shaped filter ({items, op}) stored under the v5
// `filter` key. The v5 filter is {expression}; the migrator only rewrites the v4
// `filters` key and skips when `filter` is present, so this stale shape would reach
// WrapInV5Envelope and fail v5 validation. The v1 UI ignores it — it types
// IBuilderQuery.filter as {expression} (frontend queryBuilderData.ts, filter?: Filter)
// and only ever reads filter.expression, so items/op go unread. We drop it before the
// migrator, which can then rebuild `filter` from `filters` if present.
func dropLegacyFilter(query map[string]any) {
filter, ok := query["filter"].(map[string]any)
if !ok {
return
}
_, hasItems := filter["items"]
_, hasOp := filter["op"]
if hasItems || hasOp {
delete(query, "filter")
}
}
// normalizeFilterItemOps lowercases exists/nexists filter ops (frontend stores them
// uppercase) to the spelling transition's buildCondition (pkg/transition/migrate_common.go)
// matches; otherwise it appends a spurious empty value ("svc EXISTS ''"). Value
// operators already round-trip via that switch's default case.
func normalizeFilterItemOps(query map[string]any) {
filters, ok := query["filters"].(map[string]any)
if !ok {
return
}
items, ok := filters["items"].([]any)
if !ok {
return
}
for _, it := range items {
item, ok := it.(map[string]any)
if !ok {
continue
}
op, ok := item["op"].(string)
if !ok {
continue
}
switch strings.ToLower(strings.ReplaceAll(op, "_", " ")) {
case "exists":
item["op"] = "exists"
case "nexists", "not exists":
item["op"] = "nexists"
}
}
}
// metricAggregationFields are the JSON keys a metric aggregation accepts (see
// MetricAggregation). The decoder is strict, so any other key (e.g. a logs/traces
// style `expression`) is rejected as an unknown field.
var metricAggregationFields = map[string]bool{
"metricName": true,
"temporality": true,
"timeAggregation": true,
"spaceAggregation": true,
"comparisonSpaceAggregationParam": true,
"reduceTo": true,
}
// normalizeMetricAggregations reshapes a metric query's aggregations to the shape v5
// expects. v1 bodies sometimes carry a logs/traces-style aggregation ({expression});
// the frontend ignores expression for metrics and builds from the metric fields
// (createAggregation, prepareQueryRangePayloadV5.ts), so we drop every non-metric
// key. A dropped expression leaves metricName empty and the widget is skipped later
// (isUnrenderableMetricQuery), matching what v1 renders.
//
// It also defaults an invalid spaceAggregation to "sum": v1 bodies often leave it
// empty or carry a stale value, which fails validation (SpaceAggregation.IsValid). A
// valid value (including a histogram percentile) is left alone; the metric type isn't
// in the body, so we can't prefer a percentile default for histograms.
func normalizeMetricAggregations(query map[string]any) {
if signalFromDataSource(query["dataSource"]) != telemetrytypes.SignalMetrics {
return
}
aggs, ok := query["aggregations"].([]any)
if !ok {
return
}
for _, a := range aggs {
agg, ok := a.(map[string]any)
if !ok {
continue
}
for k := range agg {
if !metricAggregationFields[k] {
delete(agg, k)
}
}
sa, _ := agg["spaceAggregation"].(string)
if !(metrictypes.SpaceAggregation{String: valuer.NewString(sa)}).IsValid() {
agg["spaceAggregation"] = metrictypes.SpaceAggregationSum.StringValue()
}
}
}
// normalizePreV5LogTraceAggregations reshapes an existing logs/traces aggregations[]
// via parseAggregations (extract func(args), lift inline "as alias", split
// multi-part, drop metric-only fields; empty → count()). Covers the case the
// migrator skips: it builds from flat fields but leaves a present-but-malformed
// aggregations[] alone. A query with none is left as-is.
func normalizePreV5LogTraceAggregations(query map[string]any) {
switch signalFromDataSource(query["dataSource"]) {
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
default:
return
}
aggs, ok := query["aggregations"].([]any)
if !ok || len(aggs) == 0 {
return
}
out := make([]any, 0, len(aggs))
for _, a := range aggs {
agg, ok := a.(map[string]any)
if !ok {
continue
}
expr, _ := agg["expression"].(string)
alias, _ := agg["alias"].(string)
parsed := parseAggregations(expr, alias)
if len(parsed) == 0 {
parsed = []any{map[string]any{"expression": "count()"}}
}
out = append(out, parsed...)
}
query["aggregations"] = out
}
// ensureDefaultAggregation defaults an empty logs/traces aggregations[] to count(),
// mirroring the frontend. Callers gate this to aggregation panels. Metrics are skipped:
// count() can't stand in for a missing metricName.
func ensureDefaultAggregation(query map[string]any) {
switch signalFromDataSource(query["dataSource"]) {
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
default:
return
}
if aggs, ok := query["aggregations"].([]any); ok && len(aggs) > 0 {
return
}
query["aggregations"] = []any{map[string]any{"expression": "count()"}}
}
// aggExprRe matches one "func(args)" with an optional "as alias". Mirrors the
// frontend's parseAggregations regex; matching only well-formed func(args)
// discards trailing junk ("sum(x) ) )" → "sum(x)").
var aggExprRe = regexp.MustCompile(`([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+('[^']*'|"[^"]*"|[a-zA-Z0-9_-]+))?`)
// aggExprNestedRe is a backup for aggExprRe that tolerates one level of nested
// parens in args (rate(count())). HACK: the flat aggExprRe (and the frontend it
// mirrors) truncates such exprs to an unbalanced "rate(count()"; the UI fails
// these today, so this is best-effort beyond v1. Tried only when the flat match
// comes back unbalanced.
var aggExprNestedRe = regexp.MustCompile(`([a-zA-Z0-9_]+\((?:[a-zA-Z0-9_]+\([^()]*\)|[^()])*\))(?:\s*as\s+('[^']*'|"[^"]*"|[a-zA-Z0-9_-]+))?`)
// parseAggregations pulls every func(args) (with inline or passed-through alias,
// quotes stripped) out of a v1 expression. Mirrors the frontend's
// parseAggregations; empty result if none.
func parseAggregations(expression, availableAlias string) []any {
matches := aggExprRe.FindAllStringSubmatch(expression, -1)
if hasUnbalancedParens(matches) {
matches = aggExprNestedRe.FindAllStringSubmatch(expression, -1)
}
out := make([]any, 0, len(matches))
for _, m := range matches {
alias := m[2]
if alias == "" {
alias = availableAlias
}
agg := map[string]any{"expression": m[1]}
if alias != "" {
agg["alias"] = strings.Trim(alias, `'"`)
}
out = append(out, agg)
}
return out
}
// hasUnbalancedParens reports whether any matched expression has mismatched
// parens — the signature of aggExprRe truncating a nested expr ("rate(count()").
func hasUnbalancedParens(matches [][]string) bool {
for _, m := range matches {
if strings.Count(m[1], "(") != strings.Count(m[1], ")") {
return true
}
}
return false
}
// normalizePreV5SelectColumns / normalizePreV5GroupBy let WrapInV5Envelope (which
// reads the old {key,dataType,type}) handle selectColumns/groupBy stored the v5 way
// ({name,…}) — see backfillPreV5FieldKeys. Inverse of normalizePreV5FieldKeys (the
// two consumers want opposite shapes).
func normalizePreV5SelectColumns(query map[string]any) {
if cols, ok := query["selectColumns"].([]any); ok {
query["selectColumns"] = backfillPreV5FieldKeys(cols)
}
}
func normalizePreV5GroupBy(query map[string]any) {
if gb, ok := query["groupBy"].([]any); ok {
query["groupBy"] = backfillPreV5FieldKeys(gb)
}
}
// backfillPreV5FieldKeys copies v5 field names (name/fieldDataType/fieldContext)
// down to their v4 equivalents (key/dataType/type) so WrapInV5Envelope, which reads
// the v4 names, sees a field stored the v5 way. Fields with no resolvable key are
// dropped.
func backfillPreV5FieldKeys(fields []any) []any {
out := make([]any, 0, len(fields))
for _, f := range fields {
field, ok := f.(map[string]any)
if !ok {
continue
}
if _, ok := field["key"]; !ok {
if name, ok := field["name"]; ok {
field["key"] = name
}
}
if _, ok := field["dataType"]; !ok {
if fdt, ok := field["fieldDataType"]; ok {
field["dataType"] = fdt
}
}
if _, ok := field["type"]; !ok {
if fc, ok := field["fieldContext"]; ok {
field["type"] = fc
}
}
if key, _ := field["key"].(string); key == "" {
continue
}
out = append(out, field)
}
return out
}
// normalizePreV5FieldKeys renames list-panel field keys {key,dataType,type} →
// {name,fieldDataType,fieldContext} in place (as WrapInV5Envelope does for
// groupBy/orderBy). Entries already carrying "name" are left as-is.
func normalizePreV5FieldKeys(fields []any) {
for _, f := range fields {
field, ok := f.(map[string]any)
if !ok {
continue
}
if _, hasName := field["name"]; hasName {
continue
}
if key, ok := field["key"]; ok {
field["name"] = key
}
if dataType, ok := field["dataType"]; ok {
field["fieldDataType"] = dataType
}
if typ, ok := field["type"]; ok {
field["fieldContext"] = typ
}
}
}
// normalizePreV5PageSize backfills limit from the legacy pageSize (frontend's
// `limit || pageSize`), for row-limited panels (list/table) only. Leaves a query
// that already has limit, or a non-row-limited panel, untouched.
func normalizePreV5PageSize(query map[string]any, rowLimitPanel bool) {
if !rowLimitPanel {
return
}
if limit, ok := query["limit"]; ok && limit != nil {
return
}
if ps, ok := query["pageSize"]; ok {
query["limit"] = ps
}
}
// normalizeQueryLimit drops a limit above the v5 maximum (MaxQueryLimit); v1 allowed
// larger/unbounded limits, and an over-max value fails validation. Removing it leaves
// the query unlimited (the field is optional).
func normalizeQueryLimit(query map[string]any) {
limit, ok := coerceFloat(query["limit"])
if !ok {
return
}
if limit > qb.MaxQueryLimit {
delete(query, "limit")
}
}

View File

@@ -1,122 +0,0 @@
package dashboardtypes
import (
"fmt"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// Tags
// ══════════════════════════════════════════════
// v1 carries tags as a flat []string; v2 tags are (key, value) pairs. Each v1
// string is normalized into a pair (separator split, empty-side fallback,
// reserved-key prefix, `/` scrub). Tags that normalize to the same
// (lower(key), lower(value)) within a dashboard are collapsed, first occurrence
// winning the display casing.
//
// Characters still illegal after normalization (spaces, punctuation) are molded
// to fit the tag validators: disallowed runs collapse to "_" (see moldTagField).
// defaultV1TagKey is the key assigned when a v1 tag string has no usable
// separator (or one side of the split is empty).
const defaultV1TagKey = "tag"
func (d *v1Decoder) convertV1TagsForOrg(orgID valuer.UUID, raw any) []*tagtypes.Tag {
if raw == nil {
return nil
}
rawTagsList, ok := raw.([]any)
if !ok {
d.noteMalformedField("tags", raw)
return nil
}
seen := make(map[string]struct{}, len(rawTagsList))
tagsV2 := make([]*tagtypes.Tag, 0, len(rawTagsList))
for i, rawTag := range rawTagsList {
s, ok := rawTag.(string)
if !ok {
d.noteMalformedField(fmt.Sprintf("tags[%d]", i), rawTag)
continue
}
key, value, ok := normalizeV1Tag(s)
if !ok {
continue
}
dedupKey := strings.ToLower(key) + "\x00" + strings.ToLower(value)
if _, dup := seen[dedupKey]; dup {
continue
}
seen[dedupKey] = struct{}{}
tagsV2 = append(tagsV2, tagtypes.NewTag(orgID, coretypes.KindDashboard, key, value))
}
return tagsV2
}
// normalizeV1Tag derives a (key, value) pair from one v1 tag string. After
// splitting and molding both sides, a lone survivor becomes a value under the
// default key; ok is false if neither survives.
func normalizeV1Tag(s string) (string, string, bool) {
s = strings.TrimSpace(s)
if s == "" {
return "", "", false
}
var rawKey, rawValue string
switch {
case strings.Contains(s, ":"):
rawKey, rawValue, _ = strings.Cut(s, ":")
// Only the first ":" separates key from value; collapse the rest.
rawValue = strings.ReplaceAll(rawValue, ":", "_")
case strings.Contains(s, "/"):
rawKey, rawValue, _ = strings.Cut(s, "/")
default:
rawValue = s
}
rawKey = strings.TrimSpace(rawKey)
rawValue = strings.TrimSpace(rawValue)
// Reserved-key collision: prefix "_" so the list-query DSL stays unambiguous.
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(rawKey))]; rawKey != "" && reserved {
rawKey = "_" + rawKey
}
key := moldTagField(rawKey, tagKeyDisallowed, tagKeyNotLead, tagtypes.MAX_LEN_TAG_KEY)
value := moldTagField(rawValue, tagValueDisallowed, nil, tagtypes.MAX_LEN_TAG_VALUE)
switch {
case key == "" && value == "":
return "", "", false
case key == "":
return defaultV1TagKey, value, true
case value == "":
return defaultV1TagKey, key, true
default:
return key, value, true
}
}
// Inverse of tagKeyRegex/tagValueRegex ("/" always rejected); tagKeyNotLead
// matches a bad first char for a key. TestMoldedV1TagsPassValidation guards drift.
var (
tagKeyDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:-]+`)
tagValueDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:.+=-]+`)
tagKeyNotLead = regexp.MustCompile(`^[^a-zA-Z$_@{#]`)
)
// moldTagField collapses disallowed runs to "_", prefixes "_" if notLead hits
// the first char, and caps at max. Keeps a leading "_", trims a trailing one.
func moldTagField(s string, disallowed, notLead *regexp.Regexp, max int) string {
s = strings.TrimRight(disallowed.ReplaceAllString(s, "_"), "_")
if s != "" && notLead != nil && notLead.MatchString(s) {
s = "_" + s
}
if len(s) > max {
s = strings.TrimRight(s[:max], "_")
}
return s
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,243 +0,0 @@
package dashboardtypes
import (
"sort"
"strconv"
"strings"
"github.com/perses/spec/go/dashboard/variable"
)
// ══════════════════════════════════════════════
// Variables
// ══════════════════════════════════════════════
// convertV1Variables walks the v1 `variables` map (UUID-keyed) and produces an
// ordered []Variable. Variables sort by `order` first, then by id for stable
// output. v1 variable types map as follows:
//
// QUERY → ListVariable + signoz/QueryVariable
// CUSTOM → ListVariable + signoz/CustomVariable
// DYNAMIC → ListVariable + signoz/DynamicVariable
// TEXTBOX → TextVariable
func (d *v1Decoder) convertV1Variables(raw any) []Variable {
if raw == nil {
return []Variable{}
}
rawVariablesMap, ok := raw.(map[string]any)
if !ok {
// v1 sometimes stores variables as a list. The frontend consumes it via
// Object.entries/keys, which for an array yields the stringified index as the
// key, so mirror that: [{...}] is treated as {"0":{...}}. An empty list is
// simply "no variables".
rawSlice, isSlice := raw.([]any)
if !isSlice {
d.noteMalformedField("variables", raw)
return nil
}
rawVariablesMap = make(map[string]any, len(rawSlice))
for i, v := range rawSlice {
rawVariablesMap[strconv.Itoa(i)] = v
}
}
type ordered struct {
variableID string
variableContent map[string]any
order float64
}
entries := make([]ordered, 0, len(rawVariablesMap))
for variableID, variableContentRaw := range rawVariablesMap {
variableContent, ok := variableContentRaw.(map[string]any)
if !ok {
// A variable whose content isn't an object (e.g. a stray "list" array) can't
// render in the current UI, so it's useless — skip it instead of failing the
// migration.
continue
}
entries = append(entries, ordered{variableID: variableID, variableContent: variableContent, order: d.readFloat(variableContent, "order")})
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].order != entries[j].order {
return entries[i].order < entries[j].order
}
return entries[i].variableID < entries[j].variableID
})
variablesV2 := make([]Variable, 0, len(entries))
for _, e := range entries {
v, ok := d.convertV1Variable(e.variableContent)
if !ok {
continue
}
variablesV2 = append(variablesV2, v)
}
return variablesV2
}
func (d *v1Decoder) convertV1Variable(v map[string]any) (Variable, bool) {
name := d.readString(v, "name")
if name == "" {
return Variable{}, false
}
description := d.readString(v, "description")
// v1 stores the type upper-cased (QUERY/CUSTOM/…); tolerate any casing.
kind := strings.ToUpper(d.readString(v, "type"))
switch kind {
case "TEXTBOX":
spec := &TextVariableSpec{
Display: Display{Name: clipName(name, MaxDisplayNameLen), Description: description},
Value: d.readString(v, "textboxValue"),
Name: name,
}
return Variable{Kind: variable.KindText, Spec: spec}, true
case "QUERY", "CUSTOM", "DYNAMIC":
// Drop (don't fail on) a dynamic variable with no attribute — it can't resolve.
if kind == "DYNAMIC" && d.readString(v, "dynamicVariablesAttribute") == "" {
return Variable{}, false
}
// Drop a custom variable with no recoverable option list — v2 requires one.
if kind == "CUSTOM" && d.readString(v, "customValue") == "" && d.readString(v, "selectedValue") == "" && d.readString(v, "defaultValue") == "" {
return Variable{}, false
}
// Drop a query variable with no query — it can't resolve.
if kind == "QUERY" && d.readString(v, "queryValue") == "" {
return Variable{}, false
}
listSpec := &ListVariableSpec{
Display: Display{Name: clipName(name, MaxDisplayNameLen), Description: description},
AllowAllValue: d.readBool(v, "showALLOption"),
AllowMultiple: d.readBool(v, "multiSelect"),
CustomAllValue: d.readString(v, "customAllValue"),
CapturingRegexp: d.readString(v, "capturingRegexp"),
Sort: mapV1Sort(v["sort"]),
Plugin: d.variablePluginFor(kind, v),
Name: name,
}
// A single-select variable can't offer an "All" option: v2 rejects allowAllValue
// (and thus customAllValue) without allowMultiple. Drop them rather than fail.
if !listSpec.AllowMultiple {
listSpec.AllowAllValue = false
listSpec.CustomAllValue = ""
}
if dv := mapV1VariableDefault(v, listSpec.AllowMultiple); dv != nil {
listSpec.DefaultValue = dv
}
return Variable{Kind: variable.KindList, Spec: listSpec}, true
case "":
// v1 sometimes stores a variable with no type; it can't render, so drop it
// silently rather than flagging it malformed.
return Variable{}, false
default:
d.note("variable %q has unknown type %q", name, kind)
return Variable{}, false
}
}
func (d *v1Decoder) variablePluginFor(kind string, v map[string]any) VariablePlugin {
switch kind {
case "QUERY":
return VariablePlugin{
Kind: VariableKindQuery,
Spec: &QueryVariableSpec{QueryValue: d.readString(v, "queryValue")},
}
case "CUSTOM":
// Some v1 dashboards stored the option list in selectedValue/defaultValue
// instead of customValue; fall back so the variable survives migration.
customValue := d.readString(v, "customValue")
if customValue == "" {
customValue = d.readString(v, "selectedValue")
}
if customValue == "" {
customValue = d.readString(v, "defaultValue")
}
return VariablePlugin{
Kind: VariableKindCustom,
Spec: &CustomVariableSpec{CustomValue: customValue},
}
case "DYNAMIC":
return VariablePlugin{
Kind: VariableKindDynamic,
Spec: &DynamicVariableSpec{
Name: d.readString(v, "dynamicVariablesAttribute"),
Signal: mapV1VariableSignal(v["dynamicVariablesSource"]),
},
}
}
return VariablePlugin{}
}
// mapV1VariableSignal maps a v1 dynamicVariablesSource to a dynamic variable's
// signal. v1 stores it capitalized ("Traces"), so match case-insensitively;
// v1's "All telemetry" (and any unrecognized/empty source) means every signal.
func mapV1VariableSignal(raw any) DynamicVariableSignal {
s, _ := raw.(string)
switch strings.ToLower(s) {
case "traces":
return DynamicVariableSignalTraces
case "logs":
return DynamicVariableSignalLogs
case "metrics":
return DynamicVariableSignalMetrics
}
return DynamicVariableSignalAll
}
// mapV1VariableDefault reads selectedValue/defaultValue, both polymorphic
// (string|array), so it indexes the raw value and lets defaultValueFromAny
// type-switch — no typed accessor, intentionally lenient.
func mapV1VariableDefault(v map[string]any, allowMultiple bool) *VariableDefaultValue {
if raw, ok := v["selectedValue"]; ok {
return defaultValueFromAny(raw, allowMultiple)
}
if raw, ok := v["defaultValue"]; ok {
return defaultValueFromAny(raw, allowMultiple)
}
return nil
}
func defaultValueFromAny(raw any, allowMultiple bool) *VariableDefaultValue {
switch v := raw.(type) {
case string:
if v == "" {
return nil
}
return &VariableDefaultValue{variable.DefaultValue{SingleValue: v}}
case []any:
if len(v) == 0 {
return nil
}
values := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
values = append(values, s)
}
}
if len(values) == 0 {
return nil
}
// A single-select variable can't carry a list default; collapse a lone value.
if !allowMultiple && len(values) == 1 {
return &VariableDefaultValue{variable.DefaultValue{SingleValue: values[0]}}
}
return &VariableDefaultValue{variable.DefaultValue{SliceValues: values}}
}
return nil
}
// mapV1Sort reads the raw value (not via readString) so a non-string sort — some v1
// dashboards store it as a number (e.g. 0) — defaults to none silently instead of
// being flagged malformed.
func mapV1Sort(raw any) ListVariableSpecSort {
s, _ := raw.(string)
switch s {
case "ASC":
return SortAlphabeticalAsc
case "DESC":
return SortAlphabeticalDesc
}
return ListVariableSpecSort{} // zero (omitzero) — SortNone is the implicit default
}

View File

@@ -25,10 +25,6 @@ type Store interface {
Update(context.Context, valuer.UUID, *StorableDashboard) error
// UpdateName updates only the name column of a dashboard, leaving its data
// untouched — used to backfill the name of a dashboard whose data fails to migrate.
UpdateName(ctx context.Context, orgID valuer.UUID, id valuer.UUID, name string) error
UpdatePublic(context.Context, *StorablePublicDashboard) error
Delete(context.Context, valuer.UUID, valuer.UUID) error

Some files were not shown because too many files have changed in this diff Show More