mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-24 15:00:41 +01:00
Compare commits
10 Commits
nv/sql-das
...
querier/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5bd7416d4 | ||
|
|
85f1bf29ce | ||
|
|
6d72efde81 | ||
|
|
f66ea90b35 | ||
|
|
9f34bcf5b3 | ||
|
|
e0a8f41ac6 | ||
|
|
63e890a20e | ||
|
|
1783b942a2 | ||
|
|
ab3ed06fc0 | ||
|
|
b483cb3545 |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -713,19 +713,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Row buttons select without letting the wrapping checkbox also toggle:
|
||||
// stop propagation, run the selection, then drop the active/chip focus.
|
||||
const selectFromButton = (
|
||||
e: React.MouseEvent,
|
||||
source: 'option' | 'checkbox',
|
||||
): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection(source);
|
||||
setActiveChipIndex(-1);
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value || `option-${index}`}
|
||||
@@ -739,6 +726,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection('option');
|
||||
setActiveChipIndex(-1);
|
||||
setActiveIndex(-1);
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
|
||||
e.stopPropagation();
|
||||
@@ -758,7 +752,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
<Checkbox
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection('checkbox');
|
||||
setActiveChipIndex(-1);
|
||||
setActiveIndex(-1);
|
||||
}}
|
||||
>
|
||||
<div className="option-content">
|
||||
<Typography.Text truncate={1} className="option-label-text">
|
||||
@@ -768,19 +768,11 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
{option.value && ensureValidOption(option.value) && (
|
||||
<Button
|
||||
type="text"
|
||||
className="only-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'option')}
|
||||
>
|
||||
<Button type="text" className="only-btn">
|
||||
{currentToggleTagValue({ option: option.value })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
className="toggle-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -656,28 +656,6 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('UI-03b: clicking "Only" selects just that value', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
onChange={mockOnChange}
|
||||
value={['frontend']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
await user.click(combobox);
|
||||
|
||||
// "Only" renders for each non-selected row (hidden until hover via CSS,
|
||||
// which jsdom doesn't apply, so it's clickable here).
|
||||
const onlyButtons = await screen.findAllByText('Only');
|
||||
mockOnChange.mockClear();
|
||||
await user.click(onlyButtons[0]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnChange.mock.calls[0][0]).toStrictEqual(['backend']);
|
||||
});
|
||||
|
||||
it('UI-04: Should display values with loading info at bottom', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} loading />,
|
||||
@@ -1471,7 +1449,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
['custom-value'],
|
||||
[{ label: 'custom-value', value: 'custom-value', type: 'custom' }],
|
||||
[{ label: 'custom-value', value: 'custom-value' }],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { prioritizeOrAddOptionForMultiSelect } from '../utils';
|
||||
|
||||
describe('prioritizeOrAddOptionForMultiSelect ordering', () => {
|
||||
it('hoists selected then preserves the given (sorted) order in each group', () => {
|
||||
const sorted = ['apple', 'banana', 'cherry', 'date', 'elderberry'].map(
|
||||
(v) => ({ label: v, value: v }),
|
||||
);
|
||||
// selection given in a non-sorted order on purpose
|
||||
const result = prioritizeOrAddOptionForMultiSelect(sorted, [
|
||||
'date',
|
||||
'banana',
|
||||
]);
|
||||
expect(result.map((o) => o.value)).toStrictEqual([
|
||||
'banana',
|
||||
'date',
|
||||
'apple',
|
||||
'cherry',
|
||||
'elderberry',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -458,9 +458,7 @@ $custom-border-color: #2c3044;
|
||||
|
||||
.option-item {
|
||||
padding: 8px 12px;
|
||||
// Not the whole row — only the checkbox and the action buttons get the
|
||||
// pointer (set below), so inert areas don't look clickable.
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -496,13 +494,6 @@ $custom-border-color: #2c3044;
|
||||
|
||||
.option-checkbox {
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
|
||||
// The checkbox button is the only pointer target on the left; the label
|
||||
// still toggles on click but keeps a default cursor.
|
||||
> button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// @signozhq/ui Checkbox renders children inside a <label> that is
|
||||
// content-sized by default. Make it fill the row (min-width: 0 lets it
|
||||
@@ -544,9 +535,9 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
|
||||
// "Only"/"All" is the primary action — a filled pill that reads as a
|
||||
// button; "Toggle" is a secondary hint in plain text. Sized to the row's
|
||||
// resting height so revealing them on hover never shifts it.
|
||||
// Size the buttons to the row's resting content height (20px) and fully
|
||||
// override antd's default 32px Button box, so revealing them on hover
|
||||
// never changes the row height.
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
@@ -554,39 +545,14 @@ $custom-border-color: #2c3044;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.only-btn {
|
||||
padding: 4px 8px;
|
||||
// Black interior + a visible border so the pill stands out clearly
|
||||
// against the near-black row when revealed on hover.
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 3px;
|
||||
background-color: var(--bg-ink-500, #0b0c0e);
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
box-shadow: none;
|
||||
|
||||
// Toggle appears over the checkbox area; "Only/All" takes over the row
|
||||
// content and hides Toggle there (higher specificity wins).
|
||||
&:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
display: none;
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,7 +560,6 @@ $custom-border-color: #2c3044;
|
||||
.only-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
@@ -604,6 +569,15 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-checkbox:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
}
|
||||
.option-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
|
||||
@@ -770,34 +770,6 @@ export const removeVariableFromExpression = (
|
||||
return removeKeysFromExpression(expression, keysToRemove, `$${variableName}`);
|
||||
};
|
||||
|
||||
// Appends `clause` as a top-level AND term, parenthesising the base only when it
|
||||
// has a top-level OR (AND binds tighter, so `a OR b AND c` would misbind).
|
||||
export const appendAndClause = (
|
||||
expression: string | undefined,
|
||||
clause: string,
|
||||
): string => {
|
||||
const base = expression?.trim();
|
||||
if (!base) {
|
||||
return clause;
|
||||
}
|
||||
|
||||
const chars = CharStreams.fromString(base);
|
||||
const lexer = new FilterQueryLexer(chars);
|
||||
lexer.removeErrorListeners();
|
||||
const tokenStream = new CommonTokenStream(lexer);
|
||||
const parser = new FilterQueryParser(tokenStream);
|
||||
parser.removeErrorListeners();
|
||||
const tree = parser.query();
|
||||
|
||||
if (parser.syntaxErrorsCount > 0) {
|
||||
return `(${base}) AND ${clause}`;
|
||||
}
|
||||
|
||||
const hasTopLevelOr =
|
||||
tree.expression().orExpression().andExpression_list().length > 1;
|
||||
return hasTopLevelOr ? `(${base}) AND ${clause}` : `${base} AND ${clause}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert old having format to new having format
|
||||
* @param having - Array of old having objects with columnName, op, and value
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.static {
|
||||
// Tag chips are not interactive, but the @signozhq Badge darkens outline
|
||||
// variants on hover — which reads as clickable. Pin the hover background to the
|
||||
// resting background so hovering a plain tag produces no change.
|
||||
--badge-outline-hover-background-color: var(
|
||||
--badge-outline-background-color,
|
||||
color-mix(in oklab, var(--badge-background) 10%, transparent)
|
||||
);
|
||||
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { type MouseEvent, type ReactNode } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TagBadge.module.scss';
|
||||
|
||||
interface TagBadgeProps {
|
||||
children: ReactNode;
|
||||
@@ -25,7 +22,7 @@ function TagBadge({
|
||||
<Badge
|
||||
color="sienna"
|
||||
variant="outline"
|
||||
className={cx(styles.static, className)}
|
||||
className={className}
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
>
|
||||
|
||||
@@ -101,10 +101,8 @@ function DashboardActions({
|
||||
|
||||
const handleCreateSection = useCallback(
|
||||
async (title: string): Promise<void> => {
|
||||
const ok = await addSection(title);
|
||||
if (ok) {
|
||||
setIsNewSectionOpen(false);
|
||||
}
|
||||
await addSection(title);
|
||||
setIsNewSectionOpen(false);
|
||||
},
|
||||
[addSection],
|
||||
);
|
||||
|
||||
@@ -100,11 +100,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Fixed footprint (2 tags + `+N`): never shrinks, so a long title ellipsizes
|
||||
around it rather than collapsing the tags. */
|
||||
/* Flexes into the remaining space and clips so the ResizeObserver can measure
|
||||
how many tags fit before collapsing the rest into a `+N` badge. */
|
||||
.dashboardTags {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex: 1 1 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -20,13 +20,10 @@ import { linkifyText } from 'utils/linkifyText';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useVisibleTagCount } from './useVisibleTagCount';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
|
||||
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
|
||||
const MAX_VISIBLE_TAGS = 2;
|
||||
|
||||
interface DashboardInfoProps {
|
||||
title: string;
|
||||
image: string;
|
||||
@@ -71,8 +68,10 @@ function DashboardInfo({
|
||||
const hasTags = tags.length > 0;
|
||||
const hasDescription = !isEmpty(description);
|
||||
|
||||
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
|
||||
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
|
||||
const { containerRef, visibleCount } = useVisibleTagCount(tags);
|
||||
const needsOverflow = tags.length > visibleCount;
|
||||
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
|
||||
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
|
||||
|
||||
let lockTooltip: string;
|
||||
if (onToggleLock) {
|
||||
@@ -226,7 +225,11 @@ function DashboardInfo({
|
||||
{hasTags && (
|
||||
<>
|
||||
<span className={styles.divider} />
|
||||
<div className={styles.dashboardTags} data-testid="dashboard-tags">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.dashboardTags}
|
||||
data-testid="dashboard-tags"
|
||||
>
|
||||
{visibleTags.map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BADGE_GAP,
|
||||
estimateBadgeWidth,
|
||||
OVERFLOW_BADGE_WIDTH,
|
||||
} from 'components/Alerts/LabelColumn/utils';
|
||||
|
||||
interface Result {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
visibleCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures how many tags fit in the container and returns the visible count,
|
||||
* reserving room for the `+N` overflow badge. Reuses the badge-width estimation
|
||||
* from the alerts LabelColumn so dashboards and alerts overflow identically.
|
||||
*/
|
||||
export function useVisibleTagCount(tags: string[]): Result {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(tags.length);
|
||||
|
||||
const calculateVisible = useCallback(
|
||||
(width: number): number => {
|
||||
if (width <= 0) {
|
||||
return 1;
|
||||
}
|
||||
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
|
||||
let usedWidth = 0;
|
||||
let count = 0;
|
||||
for (const tag of tags) {
|
||||
const badgeWidth = estimateBadgeWidth(tag) + BADGE_GAP;
|
||||
if (usedWidth + badgeWidth > availableWidth && count > 0) {
|
||||
break;
|
||||
}
|
||||
usedWidth += badgeWidth;
|
||||
count += 1;
|
||||
}
|
||||
return Math.max(1, count);
|
||||
},
|
||||
[tags],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry && entry.contentRect.width > 0) {
|
||||
setVisibleCount(calculateVisible(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
if (container.clientWidth > 0) {
|
||||
setVisibleCount(calculateVisible(container.clientWidth));
|
||||
}
|
||||
return (): void => observer.disconnect();
|
||||
}, [calculateVisible]);
|
||||
|
||||
return { containerRef, visibleCount };
|
||||
}
|
||||
@@ -78,12 +78,6 @@ function JsonEditorDrawer({
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (!readOnly) {
|
||||
@@ -91,7 +85,7 @@ function JsonEditorDrawer({
|
||||
}
|
||||
}
|
||||
},
|
||||
[apply, readOnly, onClose],
|
||||
[apply, readOnly],
|
||||
);
|
||||
|
||||
const applyDisabled = readOnly || !isDirty || !validity.valid || isSaving;
|
||||
|
||||
@@ -44,12 +44,8 @@ const dashboard = {
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
// The editor exposes `spec`, `tags`, `image` (in that order); every other key is redacted.
|
||||
const redacted = {
|
||||
spec: dashboard.spec,
|
||||
tags: dashboard.tags,
|
||||
image: dashboard.image,
|
||||
};
|
||||
// The editor only exposes `tags` and `spec`; every other key is redacted.
|
||||
const redacted = { tags: dashboard.tags, spec: dashboard.spec };
|
||||
const serialized = JSON.stringify(redacted, null, 2);
|
||||
|
||||
describe('useJsonEditor', () => {
|
||||
@@ -69,18 +65,17 @@ describe('useJsonEditor', () => {
|
||||
expect(result.current.validity.lineCount).toBe(serialized.split('\n').length);
|
||||
});
|
||||
|
||||
it('exposes spec/tags/image (in order) and redacts server-owned keys', () => {
|
||||
it('redacts server-owned keys from the editable draft', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.current.draft);
|
||||
// Key order is intentional: spec, then tags, then image.
|
||||
expect(Object.keys(parsed)).toStrictEqual(['spec', 'tags', 'image']);
|
||||
expect(parsed.image).toBe('icon.png');
|
||||
expect(Object.keys(parsed).sort()).toStrictEqual(['spec', 'tags']);
|
||||
expect(parsed.id).toBeUndefined();
|
||||
expect(parsed.name).toBeUndefined();
|
||||
expect(parsed.schemaVersion).toBeUndefined();
|
||||
expect(parsed.image).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flags invalid JSON with a line number and marks the draft dirty', () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { dashboardToUpdatable } from './dashboardToUpdatable';
|
||||
import { findPanelLayoutIssues } from './danglingPanels';
|
||||
import { compactSpecLayouts } from '../../layoutCompaction';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
export interface JsonValidity {
|
||||
@@ -47,16 +46,15 @@ interface Result {
|
||||
}
|
||||
|
||||
/**
|
||||
* The editable, user-facing view: `spec`, `tags` and `image`, in that key order.
|
||||
* Everything else (id, orgId, name, timestamps, locked, schemaVersion, …) is redacted
|
||||
* so it can't be seen, copied, exported or edited; those keys are preserved on save.
|
||||
* The editable, user-facing view: only `tags` and `spec`. Everything else
|
||||
* (id, orgId, name, timestamps, locked, schemaVersion, image, …) is redacted so it
|
||||
* can't be seen, copied, exported or edited; those keys are preserved on save (see `apply`).
|
||||
*/
|
||||
const redact = (
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): Pick<DashboardtypesGettableDashboardV2DTO, 'spec' | 'tags' | 'image'> => ({
|
||||
spec: dashboard.spec,
|
||||
): Pick<DashboardtypesGettableDashboardV2DTO, 'tags' | 'spec'> => ({
|
||||
tags: dashboard.tags,
|
||||
image: dashboard.image,
|
||||
spec: dashboard.spec,
|
||||
});
|
||||
|
||||
const serialize = (dashboard: DashboardtypesGettableDashboardV2DTO): string =>
|
||||
@@ -169,18 +167,7 @@ export function useJsonEditor({
|
||||
setIsSaving(true);
|
||||
// The draft only carries name/tags/spec; overlay it on the current dashboard
|
||||
// so the redacted fields (schemaVersion, image, …) are preserved on save.
|
||||
const edited = JSON.parse(draft) as Pick<
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
'spec' | 'tags' | 'image'
|
||||
>;
|
||||
// Snap hand-edited panel geometry to a non-overlapping layout so a JSON edit
|
||||
// can't be rejected by the backend's no-overlap check (matches drag/resize).
|
||||
if (edited.spec?.layouts) {
|
||||
edited.spec = {
|
||||
...edited.spec,
|
||||
layouts: compactSpecLayouts(edited.spec.layouts),
|
||||
};
|
||||
}
|
||||
const edited = JSON.parse(draft) as Record<string, unknown>;
|
||||
await updateDashboardV2(
|
||||
{ id: dashboardId },
|
||||
dashboardToUpdatable({ ...dashboard, ...edited }),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -27,28 +26,11 @@ function DashboardImagePicker({
|
||||
onChange,
|
||||
triggerClassName,
|
||||
}: Props): JSX.Element {
|
||||
// A custom image (pasted URL / base64 data-URI, not in the preset set) is kept as
|
||||
// a selectable option for the picker's lifetime — without a matching option the
|
||||
// trigger renders the raw value string and the image can't be re-selected.
|
||||
const [customImages, setCustomImages] = useState<string[]>(() =>
|
||||
image && !Base64Icons.includes(image) ? [image] : [],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (image && !Base64Icons.includes(image)) {
|
||||
setCustomImages((prev) => (prev.includes(image) ? prev : [...prev, image]));
|
||||
}
|
||||
}, [image]);
|
||||
|
||||
const options = useMemo(
|
||||
() => [...customImages, ...Base64Icons],
|
||||
[customImages],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select value={image} onChange={(value): void => onChange(value as string)}>
|
||||
<SelectTrigger className={cx(styles.trigger, triggerClassName)} />
|
||||
<SelectContent className={styles.options} withPortal={false}>
|
||||
{options.map((icon) => (
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem key={icon} value={icon} className={styles.item}>
|
||||
<img src={icon} alt="dashboard-icon" className={styles.image} />
|
||||
</SelectItem>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -27,7 +27,6 @@ function QueryVariableFields({
|
||||
onError,
|
||||
}: QueryVariableFieldsProps): JSX.Element {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const hasAutoRun = useRef(false);
|
||||
|
||||
const runTest = async (): Promise<void> => {
|
||||
setIsRunning(true);
|
||||
@@ -60,16 +59,6 @@ function QueryVariableFields({
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch options on load so the Default Value dropdown is populated without a
|
||||
// manual Test Run — once, and only when there's a query to run.
|
||||
useEffect(() => {
|
||||
if (!hasAutoRun.current && queryValue) {
|
||||
hasAutoRun.current = true;
|
||||
void runTest();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queryValue]);
|
||||
|
||||
return (
|
||||
<div className={styles.queryContainer}>
|
||||
<div className={styles.labelContainer}>
|
||||
|
||||
@@ -160,26 +160,6 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* The full "Not Recommended" pill — hidden once the tab row gets tight so it
|
||||
never forces horizontal scroll; the amber info icon takes over below. */
|
||||
.notRecommendedBadge {
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Amber info icon: always present, but the sole "not recommended" cue on small
|
||||
screens (its tooltip carries the message + learn-more link). */
|
||||
.notRecommendedInfo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Query */
|
||||
.queryContainer {
|
||||
display: flex;
|
||||
|
||||
@@ -130,9 +130,6 @@ function VariableForm({
|
||||
value={selectedPanelIds}
|
||||
onChange={(value): void => setSelectedPanelIds(value as string[])}
|
||||
data-testid="variable-apply-panels"
|
||||
// Resolve the closed-state tags to panel names (else they show the id).
|
||||
showLabels
|
||||
placement="topRight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,7 +215,7 @@ function VariableForm({
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
disabled={!!nameError || !!attributeError || isSaving}
|
||||
disabled={!!nameError || !!attributeError}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
testId="variable-save"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
@@ -68,23 +67,20 @@ function VariableTypeTabs(): JSX.Element {
|
||||
>
|
||||
<DatabaseZap size={14} />
|
||||
Query
|
||||
{/* Wide screens: the full "Not Recommended" pill. */}
|
||||
<Badge color="amber" className={styles.notRecommendedBadge}>
|
||||
<Badge color="amber" className={styles.betaTag}>
|
||||
Not Recommended
|
||||
</Badge>
|
||||
{/* Small screens: an amber info icon stands in for the pill (keeps the
|
||||
tab row from overflowing), its tooltip carries the same message + link. */}
|
||||
<span
|
||||
className={styles.notRecommendedInfo}
|
||||
className={styles.betaTag}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<TextToolTip
|
||||
text="Query variables can be slow and brittle, so they aren't recommended. Learn why"
|
||||
text="Learn why we don't recommend"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} color={Color.BG_AMBER_600} />}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
DYNAMIC_SIGNALS,
|
||||
emptyVariableFormModel,
|
||||
VARIABLE_SORT,
|
||||
type VariableFormModel,
|
||||
} from '../variableFormModel';
|
||||
import { useVariableForm, type UseVariableForm } from './useVariableForm';
|
||||
|
||||
// Mock the store (its full slice graph is huge to transform and irrelevant here;
|
||||
// the hook only reads dashboardId + variableValues for the Test-Run payload).
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: undefined, variableValues: {} }),
|
||||
}));
|
||||
|
||||
function initial(overrides?: Partial<VariableFormModel>): VariableFormModel {
|
||||
return {
|
||||
...emptyVariableFormModel(),
|
||||
type: 'QUERY',
|
||||
name: 'svc',
|
||||
defaultValue: 'foo',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const args = (
|
||||
init: VariableFormModel,
|
||||
): Parameters<typeof useVariableForm>[0] => ({
|
||||
initial: init,
|
||||
siblings: [],
|
||||
isNew: false,
|
||||
onSave: jest.fn(),
|
||||
});
|
||||
|
||||
// The hook resets its form state whenever the `initial` reference changes (open a
|
||||
// different variable), so the args must be stable across re-renders — otherwise a
|
||||
// fresh `initial` each render would loop the reset effect. Real callers memoize
|
||||
// `initial`; mirror that here by building the props once and closing over them.
|
||||
const renderForm = (
|
||||
props: Parameters<typeof useVariableForm>[0],
|
||||
): RenderHookResult<UseVariableForm, unknown> =>
|
||||
renderHook(() => useVariableForm(props));
|
||||
|
||||
describe('useVariableForm default reset — QUERY (on Test Run)', () => {
|
||||
it('keeps the default when only the sort order changes', () => {
|
||||
const { result } = renderForm(args(initial()));
|
||||
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
|
||||
// order-only change doesn't touch the preview values, so it must not reset
|
||||
act(() => result.current.set({ sort: VARIABLE_SORT.DESC }));
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
});
|
||||
|
||||
it('resets the default when a Test Run returns values that no longer contain it', () => {
|
||||
const { result } = renderForm(args(initial()));
|
||||
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
|
||||
act(() => result.current.setRawPreview(['bar', 'baz']));
|
||||
expect(result.current.defaultValue).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the default when a Test Run still contains it', () => {
|
||||
const { result } = renderForm(args(initial()));
|
||||
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
act(() => result.current.setRawPreview(['foo', 'baz', 'qux']));
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
});
|
||||
|
||||
it('keeps the default when a re-run yields the same values (no actual change)', () => {
|
||||
const { result } = renderForm(args(initial({ defaultValue: 'bar' })));
|
||||
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
// same set again — re-running an unchanged query must not disturb the default
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
expect(result.current.defaultValue).toBe('bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVariableForm default reset — DYNAMIC (on attribute/signal change)', () => {
|
||||
const dynamic = (overrides?: Partial<VariableFormModel>): VariableFormModel =>
|
||||
initial({
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: 'service.name',
|
||||
dynamicSignal: DYNAMIC_SIGNALS[1],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('does not reset on the passive edit-open auto-fetch', () => {
|
||||
// The auto-fetch populates the preview without going through onDynamicChange,
|
||||
// so opening an existing variable must never clear its saved default.
|
||||
const { result } = renderForm(args(dynamic({ defaultValue: 'zzz' })));
|
||||
|
||||
act(() => result.current.setRawPreview(['foo', 'bar']));
|
||||
expect(result.current.defaultValue).toBe('zzz');
|
||||
});
|
||||
|
||||
it('resets when the attribute changes', () => {
|
||||
const { result } = renderForm(args(dynamic()));
|
||||
|
||||
act(() => result.current.onDynamicChange({ dynamicAttribute: 'host.name' }));
|
||||
expect(result.current.defaultValue).toBe('');
|
||||
});
|
||||
|
||||
it('resets when the signal changes', () => {
|
||||
const { result } = renderForm(args(dynamic()));
|
||||
|
||||
act(() =>
|
||||
result.current.onDynamicChange({ dynamicSignal: DYNAMIC_SIGNALS[2] }),
|
||||
);
|
||||
expect(result.current.defaultValue).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the default when the attribute is set to the same value', () => {
|
||||
const { result } = renderForm(args(dynamic()));
|
||||
|
||||
act(() =>
|
||||
result.current.onDynamicChange({ dynamicAttribute: 'service.name' }),
|
||||
);
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVariableForm default reset — CUSTOM (on options edit)', () => {
|
||||
const custom = (overrides?: Partial<VariableFormModel>): VariableFormModel =>
|
||||
initial({ type: 'CUSTOM', ...overrides });
|
||||
|
||||
it('resets when the edited options no longer contain the default', () => {
|
||||
const { result } = renderForm(args(custom()));
|
||||
|
||||
act(() => result.current.onCustomChange('bar, baz'));
|
||||
expect(result.current.defaultValue).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the default when the edited options still contain it', () => {
|
||||
const { result } = renderForm(args(custom()));
|
||||
|
||||
act(() => result.current.onCustomChange('foo, bar, baz'));
|
||||
expect(result.current.defaultValue).toBe('foo');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
@@ -92,35 +92,6 @@ export function useVariableForm({
|
||||
[rawPreview, model.sort],
|
||||
);
|
||||
|
||||
// QUERY: drop a now-invalid default when the user re-runs the query and the
|
||||
// returned values actually change. The query preview is populated only by the
|
||||
// manual "Test Run" (never on edit-open), so keying off `rawPreview` here is
|
||||
// effectively "on run"; the signature guard skips a re-run that yields the same
|
||||
// values so a still-valid default is left untouched. DYNAMIC/CUSTOM resets are
|
||||
// handled in their change handlers instead (see below).
|
||||
const lastQueryPreviewRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
lastQueryPreviewRef.current = null;
|
||||
}, [initial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (model.type !== 'QUERY' || rawPreview.length === 0) {
|
||||
return;
|
||||
}
|
||||
const optionValues = rawPreview.map(String);
|
||||
const signature = JSON.stringify(optionValues);
|
||||
if (signature === lastQueryPreviewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastQueryPreviewRef.current = signature;
|
||||
|
||||
// Clear a now-invalid default; resolution falls back to the first option/ALL.
|
||||
setDefaultValue((current) =>
|
||||
current && !optionValues.includes(current) ? '' : current,
|
||||
);
|
||||
}, [rawPreview, model.type]);
|
||||
|
||||
const existingNames = useMemo(() => siblings.map((v) => v.name), [siblings]);
|
||||
|
||||
const existingDynamicAttributes = useMemo(
|
||||
@@ -169,28 +140,12 @@ export function useVariableForm({
|
||||
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
const parsed = commaValuesParser(value);
|
||||
setRawPreview(parsed);
|
||||
|
||||
const optionValues = parsed.map(String);
|
||||
setDefaultValue((current) =>
|
||||
current && !optionValues.includes(current) ? '' : current,
|
||||
);
|
||||
setRawPreview(commaValuesParser(value));
|
||||
};
|
||||
|
||||
// In add mode, mirror the selected attribute into the name until the user
|
||||
// edits the name themselves (matches the V1 dynamic-variable behaviour).
|
||||
const onDynamicChange = (patch: Partial<VariableFormModel>): void => {
|
||||
const attributeChanged =
|
||||
patch.dynamicAttribute !== undefined &&
|
||||
patch.dynamicAttribute !== model.dynamicAttribute;
|
||||
const signalChanged =
|
||||
patch.dynamicSignal !== undefined &&
|
||||
patch.dynamicSignal !== model.dynamicSignal;
|
||||
if (attributeChanged || signalChanged) {
|
||||
setDefaultValue('');
|
||||
}
|
||||
|
||||
if (isNew && !nameTouched && patch.dynamicAttribute) {
|
||||
set({ ...patch, name: patch.dynamicAttribute });
|
||||
} else {
|
||||
|
||||
@@ -54,18 +54,11 @@ function VariableImpactDialog({
|
||||
useVariableImpactState(usages, open);
|
||||
|
||||
const isRename = mode === 'rename';
|
||||
const isDelete = mode === 'delete';
|
||||
const count = usages.length;
|
||||
const plural = count === 1 ? '' : 's';
|
||||
let intro: string;
|
||||
if (isRename) {
|
||||
intro = `$${variableName} is used in ${count} place${plural}. Review the updated queries before renaming to $${newName}.`;
|
||||
} else if (isDelete) {
|
||||
intro = `$${variableName} is used in ${count} place${plural}. Edit or remove each usage before deleting.`;
|
||||
} else {
|
||||
intro = `Applying $${variableName} can update upto ${count} panel quer${count === 1 ? 'y' : 'ies'}. Review the changes before applying.`;
|
||||
}
|
||||
const confirmLabel = isRename ? 'Rename' : isDelete ? 'Delete' : 'Apply';
|
||||
const intro = isRename
|
||||
? `$${variableName} is used in ${count} place${plural}. Review the updated queries before renaming to $${newName}.`
|
||||
: `$${variableName} is used in ${count} place${plural}. Edit or remove each usage before deleting.`;
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
@@ -80,13 +73,13 @@ function VariableImpactDialog({
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color={isDelete ? 'destructive' : 'primary'}
|
||||
color={isRename ? 'primary' : 'destructive'}
|
||||
loading={isLoading}
|
||||
onClick={(): void => onConfirm(resolvedUsages)}
|
||||
testId="variable-impact-confirm"
|
||||
>
|
||||
<Check size={12} />
|
||||
{confirmLabel}
|
||||
{isRename ? 'Rename' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -99,14 +92,7 @@ function VariableImpactDialog({
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
isRename
|
||||
? `Rename $${variableName}`
|
||||
: isDelete
|
||||
? `Delete $${variableName}`
|
||||
: `Apply $${variableName} to panels`
|
||||
}
|
||||
title={isRename ? `Rename $${variableName}` : `Delete $${variableName}`}
|
||||
width="wide"
|
||||
showCloseButton={false}
|
||||
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
|
||||
@@ -118,9 +104,7 @@ function VariableImpactDialog({
|
||||
<Typography.Text className={styles.intro}>{intro}</Typography.Text>
|
||||
<div className={styles.rows}>
|
||||
{rows.map((row) => {
|
||||
// Only warn on delete: an apply result is meant to reference the variable.
|
||||
const stillReferences =
|
||||
isDelete &&
|
||||
row.included &&
|
||||
textContainsVariableReference(row.resultingText, variableName);
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { DashboardtypesDashboardSpecDTOPanels } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTOPanels,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getPanelIdsReferencingVariable } from '../utils/applyVariableToPanelsPatch';
|
||||
import {
|
||||
buildApplyVariableToPanelsPatch,
|
||||
buildSyncVariableToPanelsPatch,
|
||||
getPanelIdsReferencingVariable,
|
||||
} from '../utils/applyVariableToPanelsPatch';
|
||||
|
||||
/** Minimal builder-query panel wrapped in a CompositeQuery, with a given filter. */
|
||||
function compositePanel(filterExpression: string): unknown {
|
||||
@@ -61,12 +68,153 @@ function listPanel(filterExpression: string): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
/** A PromQL panel — no builder filter, must be skipped. */
|
||||
function promqlPanel(): unknown {
|
||||
return {
|
||||
kind: 'panel',
|
||||
spec: {
|
||||
display: { title: '' },
|
||||
plugin: { kind: 'signoz/TimeSeries', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'promql',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/PromQLQuery', spec: { query: 'up' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the first builder query's filter expression out of a patch op's value. */
|
||||
function expressionOf(
|
||||
op: DashboardtypesJSONPatchOperationDTO,
|
||||
): string | undefined {
|
||||
const queries = op.value as Array<{
|
||||
spec: { plugin: { kind: string; spec: any } };
|
||||
}>;
|
||||
const { plugin } = queries[0].spec;
|
||||
const builderSpec =
|
||||
plugin.kind === 'signoz/CompositeQuery'
|
||||
? plugin.spec.queries[0].spec
|
||||
: plugin.spec;
|
||||
return builderSpec.filter?.expression;
|
||||
}
|
||||
|
||||
function panels(
|
||||
map: Record<string, unknown>,
|
||||
): DashboardtypesDashboardSpecDTOPanels {
|
||||
return map as DashboardtypesDashboardSpecDTOPanels;
|
||||
}
|
||||
|
||||
describe('buildApplyVariableToPanelsPatch', () => {
|
||||
it('appends the clause to an existing filter (AND-joined)', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('env = "prod"') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0]).toMatchObject({
|
||||
op: 'replace',
|
||||
path: '/spec/panels/p1/spec/queries',
|
||||
});
|
||||
expect(expressionOf(ops[0])).toBe('env = "prod" AND service.name IN $svc');
|
||||
});
|
||||
|
||||
it('sets the clause when the filter is empty', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('service.name IN $svc');
|
||||
});
|
||||
|
||||
it('applies to a bare BuilderQuery (LIST) panel', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: listPanel('') }),
|
||||
'k8s.pod.name',
|
||||
'pod',
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('k8s.pod.name IN $pod');
|
||||
});
|
||||
|
||||
it('is idempotent — re-applying does not duplicate the clause', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('service.name IN $svc') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('only targets the requested panels', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel(''), p2: compositePanel('') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
['p2'],
|
||||
);
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0].path).toBe('/spec/panels/p2/spec/queries');
|
||||
});
|
||||
|
||||
it('skips PromQL panels (no builder filter)', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: promqlPanel() }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns nothing when attribute or name is missing', () => {
|
||||
expect(
|
||||
buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('') }),
|
||||
'',
|
||||
'svc',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSyncVariableToPanelsPatch', () => {
|
||||
it('adds to selected panels and removes from the rest', () => {
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
panels({
|
||||
p1: compositePanel('service.name IN $svc'), // has it, not selected → remove
|
||||
p2: compositePanel(''), // selected → add
|
||||
p3: compositePanel('service.name IN $svc'), // has it, selected → unchanged
|
||||
}),
|
||||
'service.name',
|
||||
'svc',
|
||||
['p2', 'p3'],
|
||||
);
|
||||
const byPath = Object.fromEntries(ops.map((op) => [op.path, op]));
|
||||
expect(Object.keys(byPath).sort()).toStrictEqual([
|
||||
'/spec/panels/p1/spec/queries',
|
||||
'/spec/panels/p2/spec/queries',
|
||||
]);
|
||||
expect(expressionOf(byPath['/spec/panels/p1/spec/queries'])).toBe('');
|
||||
expect(expressionOf(byPath['/spec/panels/p2/spec/queries'])).toBe(
|
||||
'service.name IN $svc',
|
||||
);
|
||||
});
|
||||
|
||||
it('removing keeps other clauses intact', () => {
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('env = "prod" AND service.name IN $svc') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
[],
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('env = "prod"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPanelIdsReferencingVariable', () => {
|
||||
it('returns only panels whose filter references the variable', () => {
|
||||
const ids = getPanelIdsReferencingVariable(
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useVariableListActions } from '../hooks/useVariableListActions';
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../variableFormModel';
|
||||
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'd1' }),
|
||||
}));
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
function builderPanel(name: string, expression: string): unknown {
|
||||
return {
|
||||
spec: {
|
||||
display: { name },
|
||||
queries: [
|
||||
{
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/BuilderQuery', spec: { filter: { expression } } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dashboard(
|
||||
panels: Record<string, unknown>,
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return {
|
||||
spec: { panels, variables: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function dynamicVar(name: string, attribute: string): VariableFormModel {
|
||||
return {
|
||||
...emptyVariableFormModel(),
|
||||
name,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: attribute,
|
||||
};
|
||||
}
|
||||
|
||||
function renderActions(
|
||||
dash: DashboardtypesGettableDashboardV2DTO,
|
||||
variables: VariableFormModel[],
|
||||
) {
|
||||
return renderHook(() =>
|
||||
useVariableListActions({
|
||||
dashboard: dash,
|
||||
variables,
|
||||
setVariables: jest.fn(),
|
||||
isEditing: null,
|
||||
setIsEditing: jest.fn(),
|
||||
save: jest.fn().mockResolvedValue(true),
|
||||
patchAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('useVariableListActions — apply to all', () => {
|
||||
it('marks a variable applied-to-all only when every panel already references it', () => {
|
||||
const notApplied = renderActions(dashboard({ p1: builderPanel('P1', '') }), [
|
||||
dynamicVar('pod', 'k8s.pod.name'),
|
||||
]);
|
||||
expect(notApplied.result.current.appliedToAllNames.has('pod')).toBe(false);
|
||||
|
||||
const applied = renderActions(
|
||||
dashboard({ p1: builderPanel('P1', 'k8s.pod.name IN $pod') }),
|
||||
[dynamicVar('pod', 'k8s.pod.name')],
|
||||
);
|
||||
expect(applied.result.current.appliedToAllNames.has('pod')).toBe(true);
|
||||
});
|
||||
|
||||
it('requestApplyToAll opens an apply-mode impact for all panels', () => {
|
||||
const { result } = renderActions(
|
||||
dashboard({ p1: builderPanel('P1', ''), p2: builderPanel('P2', '') }),
|
||||
[dynamicVar('pod', 'k8s.pod.name')],
|
||||
);
|
||||
|
||||
act(() => result.current.requestApplyToAll(0));
|
||||
|
||||
const { impact } = result.current;
|
||||
expect(impact?.mode).toBe('apply');
|
||||
expect(impact?.origin).toBe('applyToAll');
|
||||
expect(impact?.variableName).toBe('pod');
|
||||
expect(impact?.usages.map((u) => u.sourceId).sort()).toStrictEqual([
|
||||
'p1',
|
||||
'p2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requestApplyToAll is a no-op when nothing is left to apply', () => {
|
||||
const { result } = renderActions(
|
||||
dashboard({ p1: builderPanel('P1', 'k8s.pod.name IN $pod') }),
|
||||
[dynamicVar('pod', 'k8s.pod.name')],
|
||||
);
|
||||
|
||||
act(() => result.current.requestApplyToAll(0));
|
||||
expect(result.current.impact).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../variableFormModel';
|
||||
import {
|
||||
findApplyUsages,
|
||||
findVariableUsages,
|
||||
isVariableAppliedToAllPanels,
|
||||
} from '../utils/variableUsages';
|
||||
import { findVariableUsages } from '../utils/variableUsages';
|
||||
|
||||
// Identity adapter so `spec.variables` can be plain form models in the test.
|
||||
jest.mock('../variableAdapters', () => ({
|
||||
@@ -100,98 +96,3 @@ describe('findVariableUsages', () => {
|
||||
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findApplyUsages', () => {
|
||||
const dash = dashboard(
|
||||
{
|
||||
empty: builderPanel('Empty', ''),
|
||||
ored: builderPanel('Ored', "a = 'x' OR b = 'y'"),
|
||||
has: builderPanel('Has it', 'k8s.pod.name IN $pod'),
|
||||
prom: promqlPanel('Prom', 'up'),
|
||||
promRef: promqlPanel('Prom Ref', 'up{pod="$pod"}'),
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
it('appends the clause to selected builder panels, parenthesising an OR', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', [
|
||||
'empty',
|
||||
'ored',
|
||||
]);
|
||||
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
|
||||
expect(byId['panel:empty:0']).toBe('k8s.pod.name IN $pod');
|
||||
expect(byId['panel:ored:0']).toBe(
|
||||
"(a = 'x' OR b = 'y') AND k8s.pod.name IN $pod",
|
||||
);
|
||||
});
|
||||
|
||||
it('skips a selected panel that already carries the clause (idempotent)', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['has']);
|
||||
expect(usages).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('removes the clause from an unselected builder panel that has it', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['empty']);
|
||||
const has = usages.find((u) => u.id === 'panel:has:0');
|
||||
expect(has?.resultingText).toBe('');
|
||||
});
|
||||
|
||||
it('lists a selected PromQL panel as an editable, unchanged row', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['prom']);
|
||||
const prom = usages.find((u) => u.id === 'panel:prom:0');
|
||||
expect(prom?.kind).toBe('promql');
|
||||
// Never auto-injected — the row defaults to the current text for manual edits.
|
||||
expect(prom?.currentText).toBe('up');
|
||||
expect(prom?.resultingText).toBe('up');
|
||||
});
|
||||
|
||||
it('skips a selected non-builder panel that already references the variable', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', [
|
||||
'promRef',
|
||||
]);
|
||||
expect(usages.some((u) => u.sourceId === 'promRef')).toBe(false);
|
||||
});
|
||||
|
||||
it('never touches unselected non-builder panels', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['empty']);
|
||||
expect(usages.some((u) => u.sourceId === 'prom')).toBe(false);
|
||||
expect(usages.some((u) => u.sourceId === 'promRef')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns nothing when every selected query already references the variable', () => {
|
||||
// The "applied to all" signal: builder carries the clause, PromQL references it.
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', [
|
||||
'has',
|
||||
'promRef',
|
||||
]);
|
||||
expect(usages).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVariableAppliedToAllPanels', () => {
|
||||
it('is true only when every panel query references the variable', () => {
|
||||
const covered = dashboard(
|
||||
{
|
||||
b: builderPanel('B', 'k8s.pod.name IN $pod'),
|
||||
p: promqlPanel('P', 'up{pod="$pod"}'),
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(isVariableAppliedToAllPanels(covered, 'k8s.pod.name', 'pod')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is false when any panel query is missing the reference', () => {
|
||||
const missing = dashboard(
|
||||
{
|
||||
b: builderPanel('B', 'k8s.pod.name IN $pod'),
|
||||
p: promqlPanel('P', 'up'),
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(isVariableAppliedToAllPanels(missing, 'k8s.pod.name', 'pod')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.body {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.variableName {
|
||||
font-family: 'Space Mono', monospace;
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
|
||||
import styles from './ApplyToAllDialog.module.scss';
|
||||
|
||||
interface ApplyToAllDialogProps {
|
||||
open: boolean;
|
||||
variableName: string;
|
||||
isLoading: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Confirms applying a dynamic variable as a filter to every panel. */
|
||||
function ApplyToAllDialog({
|
||||
open,
|
||||
variableName,
|
||||
isLoading,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ApplyToAllDialogProps): JSX.Element {
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isLoading}
|
||||
onClick={onConfirm}
|
||||
testId="confirm-apply-to-all"
|
||||
>
|
||||
<Check size={12} />
|
||||
Apply to all
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Apply variable to all panels"
|
||||
width="narrow"
|
||||
showCloseButton={false}
|
||||
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
|
||||
style={{ zIndex: 1100 }}
|
||||
showOverlay={false}
|
||||
footer={footer}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
Add <span className={styles.variableName}>${variableName}</span> as a filter
|
||||
to every panel on this dashboard. Panels that already reference it are left
|
||||
unchanged.
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApplyToAllDialog;
|
||||
@@ -64,7 +64,7 @@ $grid-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
|
||||
.rowActions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
top: 8px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -22,8 +22,6 @@ interface VariableRowProps {
|
||||
onCancelDelete: () => void;
|
||||
/** Apply this variable's filter to all panels. Dynamic variables only. */
|
||||
onApplyToAll: (index: number) => void;
|
||||
/** True when this dynamic variable is already applied to every panel. */
|
||||
isAppliedToAll: boolean;
|
||||
}
|
||||
|
||||
/** A single draggable variable row in the two-column (name / description) table. */
|
||||
@@ -37,7 +35,6 @@ function VariableRow({
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onApplyToAll,
|
||||
isAppliedToAll,
|
||||
}: VariableRowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
@@ -124,17 +121,12 @@ function VariableRow({
|
||||
{variable.type === 'DYNAMIC' ? (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
title={
|
||||
isAppliedToAll
|
||||
? 'Already applied to all panels'
|
||||
: 'Add this variable as a filter to every panel'
|
||||
}
|
||||
title="Add this variable as a filter to every panel"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={isAppliedToAll}
|
||||
className={styles.applyAllButton}
|
||||
onClick={(): void => onApplyToAll(index)}
|
||||
testId={`variable-apply-all-${variable.name}`}
|
||||
|
||||
@@ -26,8 +26,6 @@ interface VariablesListProps {
|
||||
onCancelDelete: () => void;
|
||||
onMove: (from: number, to: number) => void;
|
||||
onApplyToAll: (index: number) => void;
|
||||
/** Names of dynamic variables already applied to every panel. */
|
||||
appliedToAllNames: Set<string>;
|
||||
}
|
||||
|
||||
function VariablesList({
|
||||
@@ -40,7 +38,6 @@ function VariablesList({
|
||||
onCancelDelete,
|
||||
onMove,
|
||||
onApplyToAll,
|
||||
appliedToAllNames,
|
||||
}: VariablesListProps): JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
|
||||
@@ -85,7 +82,6 @@ function VariablesList({
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onCancelDelete={onCancelDelete}
|
||||
onApplyToAll={onApplyToAll}
|
||||
isAppliedToAll={appliedToAllNames.has(variable.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -14,6 +13,7 @@ import type {
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { buildSyncVariableToPanelsPatch } from '../utils/applyVariableToPanelsPatch';
|
||||
import {
|
||||
VARIABLE_TYPE_EVENT_LABEL,
|
||||
type VariableFormModel,
|
||||
@@ -23,9 +23,7 @@ import {
|
||||
buildVariableImpactPatch,
|
||||
} from '../utils/variableImpactPatch';
|
||||
import {
|
||||
findApplyUsages,
|
||||
findVariableUsages,
|
||||
isVariableAppliedToAllPanels,
|
||||
type VariableImpactMode,
|
||||
type VariableUsage,
|
||||
} from '../utils/variableUsages';
|
||||
@@ -42,8 +40,6 @@ export interface VariableImpact {
|
||||
newName?: string;
|
||||
usages: VariableUsage[];
|
||||
nextVariables: VariableFormModel[];
|
||||
/** Where an `apply` impact came from — drives the confirm analytics event. */
|
||||
origin?: 'form' | 'applyToAll';
|
||||
}
|
||||
|
||||
interface UseVariableListActionsParams {
|
||||
@@ -68,9 +64,6 @@ interface UseVariableListActions {
|
||||
handleMove: (from: number, to: number) => void;
|
||||
requestDelete: (index: number) => void;
|
||||
handleConfirmDelete: (index: number) => void;
|
||||
requestApplyToAll: (index: number) => void;
|
||||
/** Names of dynamic variables already applied to every panel (button disabled). */
|
||||
appliedToAllNames: Set<string>;
|
||||
handleImpactConfirm: (resolvedUsages: VariableUsage[]) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -114,52 +107,60 @@ export function useVariableListActions({
|
||||
next[editingIndex] = formModel;
|
||||
}
|
||||
|
||||
const isRename = !!oldName && oldName !== formModel.name;
|
||||
// Both rename and apply-to-panels edits are reviewed in the impact dialog
|
||||
// before persisting — never applied silently.
|
||||
const renameUsages = isRename
|
||||
? findVariableUsages(dashboard, oldName as string, 'rename', formModel.name)
|
||||
: [];
|
||||
const applyUsages =
|
||||
formModel.type === 'DYNAMIC' && formModel.dynamicAttribute
|
||||
? findApplyUsages(
|
||||
dashboard,
|
||||
formModel.dynamicAttribute,
|
||||
formModel.name,
|
||||
oldName ?? formModel.name,
|
||||
selectedPanelIds,
|
||||
)
|
||||
: [];
|
||||
|
||||
// Apply usages win per (panel, envelope) over a plain rename rewrite.
|
||||
const byId = new Map<string, VariableUsage>();
|
||||
renameUsages.forEach((usage) => byId.set(usage.id, usage));
|
||||
applyUsages.forEach((usage) => byId.set(usage.id, usage));
|
||||
const usages = [...byId.values()];
|
||||
|
||||
if (usages.length > 0) {
|
||||
setIsEditing(null);
|
||||
setImpact({
|
||||
mode: isRename ? 'rename' : 'apply',
|
||||
variableName: oldName ?? formModel.name,
|
||||
newName: formModel.name,
|
||||
usages,
|
||||
nextVariables: next,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No cross-query impact — persist directly; keep the form open on failure.
|
||||
void (async (): Promise<void> => {
|
||||
const saved = await save(next);
|
||||
if (!saved) {
|
||||
// A rename that other queries/variables reference must be reviewed first, so
|
||||
// the references are rewritten alongside the rename (never left dangling).
|
||||
if (oldName && oldName !== formModel.name) {
|
||||
const usages = findVariableUsages(
|
||||
dashboard,
|
||||
oldName,
|
||||
'rename',
|
||||
formModel.name,
|
||||
);
|
||||
if (usages.length > 0) {
|
||||
setIsEditing(null);
|
||||
setImpact({
|
||||
mode: 'rename',
|
||||
variableName: oldName,
|
||||
newName: formModel.name,
|
||||
usages,
|
||||
nextVariables: next,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsEditing(null);
|
||||
setVariables(next);
|
||||
}
|
||||
|
||||
setIsEditing(null);
|
||||
setVariables(next);
|
||||
void (async (): Promise<void> => {
|
||||
const saved = await save(next);
|
||||
if (!saved || formModel.type !== 'DYNAMIC') {
|
||||
return;
|
||||
}
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
dashboard.spec.panels,
|
||||
formModel.dynamicAttribute,
|
||||
formModel.name,
|
||||
selectedPanelIds,
|
||||
);
|
||||
if (ops.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
} catch {
|
||||
toast.error('Could not update panels');
|
||||
}
|
||||
})();
|
||||
},
|
||||
[dashboard, isEditing, save, setIsEditing, setVariables, variables],
|
||||
[
|
||||
dashboard,
|
||||
isEditing,
|
||||
patchAsync,
|
||||
save,
|
||||
setIsEditing,
|
||||
setVariables,
|
||||
variables,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMove = useCallback(
|
||||
@@ -217,57 +218,6 @@ export function useVariableListActions({
|
||||
[dashboard, variables],
|
||||
);
|
||||
|
||||
// "Apply to all": review the additive changes across every panel before applying.
|
||||
const requestApplyToAll = useCallback(
|
||||
(index: number): void => {
|
||||
const variable = variables[index];
|
||||
if (!variable || variable.type !== 'DYNAMIC' || !variable.dynamicAttribute) {
|
||||
return;
|
||||
}
|
||||
const allPanelIds = Object.keys(dashboard.spec.panels ?? {});
|
||||
const usages = findApplyUsages(
|
||||
dashboard,
|
||||
variable.dynamicAttribute,
|
||||
variable.name,
|
||||
variable.name,
|
||||
allPanelIds,
|
||||
);
|
||||
if (usages.length === 0) {
|
||||
return;
|
||||
}
|
||||
setImpact({
|
||||
mode: 'apply',
|
||||
variableName: variable.name,
|
||||
newName: variable.name,
|
||||
usages,
|
||||
nextVariables: variables,
|
||||
origin: 'applyToAll',
|
||||
});
|
||||
},
|
||||
[dashboard, variables],
|
||||
);
|
||||
|
||||
// A dynamic variable is "applied to all" when every panel query already
|
||||
// references it — i.e. the apply review would be empty. Disables the button.
|
||||
const appliedToAllNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
variables.forEach((variable) => {
|
||||
if (variable.type !== 'DYNAMIC' || !variable.dynamicAttribute) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isVariableAppliedToAllPanels(
|
||||
dashboard,
|
||||
variable.dynamicAttribute,
|
||||
variable.name,
|
||||
)
|
||||
) {
|
||||
names.add(variable.name);
|
||||
}
|
||||
});
|
||||
return names;
|
||||
}, [dashboard, variables]);
|
||||
|
||||
// Applies a resolved rename/delete: the variables array (rename/delete + edited
|
||||
// variable queries) and each touched panel's queries, in one atomic patch.
|
||||
const handleImpactConfirm = useCallback(
|
||||
@@ -287,15 +237,11 @@ export function useVariableListActions({
|
||||
setVariables(nextVariables);
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
let message: string;
|
||||
if (impact.mode === 'rename') {
|
||||
message = `Renamed to $${impact.newName}`;
|
||||
} else if (impact.mode === 'apply') {
|
||||
message = `Applied $${impact.variableName} to panels`;
|
||||
} else {
|
||||
message = `Deleted $${impact.variableName}`;
|
||||
}
|
||||
toast.success(message);
|
||||
toast.success(
|
||||
impact.mode === 'rename'
|
||||
? `Renamed to $${impact.newName}`
|
||||
: `Deleted $${impact.variableName}`,
|
||||
);
|
||||
if (impact.mode === 'delete') {
|
||||
const deleted = variables.find((v) => v.name === impact.variableName);
|
||||
void logEvent(DashboardDetailEvents.VariableDeleted, {
|
||||
@@ -305,22 +251,13 @@ export function useVariableListActions({
|
||||
hadReferences: true,
|
||||
dashboardId,
|
||||
});
|
||||
} else if (impact.mode === 'apply' && impact.origin === 'applyToAll') {
|
||||
void logEvent(DashboardDetailEvents.ApplyToAllConfirmed, {
|
||||
variableType: 'dynamic',
|
||||
dashboardId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
let message: string;
|
||||
if (impact.mode === 'rename') {
|
||||
message = 'Could not rename the variable';
|
||||
} else if (impact.mode === 'apply') {
|
||||
message = 'Could not apply the variable to panels';
|
||||
} else {
|
||||
message = 'Could not delete the variable';
|
||||
}
|
||||
toast.error(message);
|
||||
toast.error(
|
||||
impact.mode === 'rename'
|
||||
? 'Could not rename the variable'
|
||||
: 'Could not delete the variable',
|
||||
);
|
||||
}
|
||||
setImpact(null);
|
||||
},
|
||||
@@ -336,8 +273,6 @@ export function useVariableListActions({
|
||||
handleMove,
|
||||
requestDelete,
|
||||
handleConfirmDelete,
|
||||
requestApplyToAll,
|
||||
appliedToAllNames,
|
||||
handleImpactConfirm,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import settingsStyles from '../DashboardSettings.module.scss';
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { getPanelIdsReferencingVariable } from './utils/applyVariableToPanelsPatch';
|
||||
import {
|
||||
buildApplyVariableToPanelsPatch,
|
||||
getPanelIdsReferencingVariable,
|
||||
} from './utils/applyVariableToPanelsPatch';
|
||||
import { useSaveVariables } from './hooks/useSaveVariables';
|
||||
import { useVariableListActions } from './hooks/useVariableListActions';
|
||||
import { dtoToFormModel } from './variableAdapters';
|
||||
@@ -18,6 +24,7 @@ import VariableImpactDialog from './VariableImpactDialog/VariableImpactDialog';
|
||||
import VariablesList from './components/VariablesList/VariablesList';
|
||||
import styles from './Variables.module.scss';
|
||||
import AddVariableButton from './components/AddVariableButton';
|
||||
import ApplyToAllDialog from './components/ApplyToAllDialog/ApplyToAllDialog';
|
||||
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
|
||||
import { EditingState } from './types';
|
||||
|
||||
@@ -27,6 +34,7 @@ interface VariablesSettingsProps {
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
// The drawer destroys on close, so reading this once on mount is enough to
|
||||
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
|
||||
const openAddOnMount = useDashboardStore(
|
||||
@@ -51,6 +59,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const [isEditing, setIsEditing] = useState<EditingState>(
|
||||
openAddOnMount && isEditable ? { type: 'new' } : null,
|
||||
);
|
||||
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
|
||||
|
||||
const {
|
||||
confirmDeleteIndex,
|
||||
@@ -61,8 +70,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
handleMove,
|
||||
requestDelete,
|
||||
handleConfirmDelete,
|
||||
requestApplyToAll,
|
||||
appliedToAllNames,
|
||||
handleImpactConfirm,
|
||||
} = useVariableListActions({
|
||||
dashboard,
|
||||
@@ -109,6 +116,36 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
);
|
||||
}, [editingFormModel, dashboard.spec.panels]);
|
||||
|
||||
const applyToAllVariable =
|
||||
applyToAllIndex === null ? null : variables[applyToAllIndex];
|
||||
|
||||
const handleConfirmApplyToAll = async (): Promise<void> => {
|
||||
if (!applyToAllVariable) {
|
||||
return;
|
||||
}
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
dashboard.spec.panels,
|
||||
applyToAllVariable.dynamicAttribute,
|
||||
applyToAllVariable.name,
|
||||
);
|
||||
if (ops.length === 0) {
|
||||
toast.info('No panels needed this filter.');
|
||||
setApplyToAllIndex(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
toast.success(`Applied $${applyToAllVariable.name} to all panels`);
|
||||
void logEvent(DashboardDetailEvents.ApplyToAllConfirmed, {
|
||||
variableType: 'dynamic',
|
||||
dashboardId,
|
||||
});
|
||||
} catch {
|
||||
toast.error('Could not apply the variable to panels');
|
||||
}
|
||||
setApplyToAllIndex(null);
|
||||
};
|
||||
|
||||
if (editingFormModel) {
|
||||
return (
|
||||
<VariableForm
|
||||
@@ -139,14 +176,20 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
onApplyToAll={requestApplyToAll}
|
||||
appliedToAllNames={appliedToAllNames}
|
||||
onApplyToAll={(index): void => setApplyToAllIndex(index)}
|
||||
/>
|
||||
<div className={styles.footer}>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ApplyToAllDialog
|
||||
open={applyToAllVariable !== null}
|
||||
variableName={applyToAllVariable?.name ?? ''}
|
||||
isLoading={isPatching}
|
||||
onConfirm={(): void => void handleConfirmApplyToAll()}
|
||||
onClose={(): void => setApplyToAllIndex(null)}
|
||||
/>
|
||||
<VariableImpactDialog
|
||||
open={impact !== null}
|
||||
mode={impact?.mode ?? 'delete'}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTOPanels,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
// Injects/removes a dynamic variable's filter (`attribute IN $name`) in panel
|
||||
// builder queries as JSON-Patch ops. Only builder queries carry a filter.
|
||||
|
||||
function clauseFor(attribute: string, variableName: string): string {
|
||||
return `${attribute} IN $${variableName}`;
|
||||
}
|
||||
|
||||
/** Runs `fn` on every builder-query spec in a panel's single query (Composite or bare Builder). */
|
||||
function forEachBuilderSpec(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
fn: (spec: Querybuildertypesv5BuilderQuerySpecDTO) => void,
|
||||
@@ -33,6 +39,48 @@ function forEachBuilderSpec(
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends the clause to every builder query's filter. Returns whether anything changed. */
|
||||
function addClause(queries: DashboardtypesQueryDTO[], clause: string): boolean {
|
||||
let changed = false;
|
||||
forEachBuilderSpec(queries, (spec) => {
|
||||
const existing = spec.filter?.expression?.trim();
|
||||
// Idempotent: a repeated apply must not stack duplicate clauses.
|
||||
if (existing?.includes(clause)) {
|
||||
return;
|
||||
}
|
||||
spec.filter = {
|
||||
expression: existing ? `${existing} AND ${clause}` : clause,
|
||||
};
|
||||
changed = true;
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Removes the managed clause (an ` AND `-joined part) from every builder filter. */
|
||||
function removeClause(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
clause: string,
|
||||
): boolean {
|
||||
let changed = false;
|
||||
forEachBuilderSpec(queries, (spec) => {
|
||||
const existing = spec.filter?.expression;
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
const parts = existing
|
||||
.split(' AND ')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const kept = parts.filter((part) => part !== clause);
|
||||
if (kept.length !== parts.length) {
|
||||
spec.filter = { expression: kept.join(' AND ') };
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Whether any builder query in the panel already carries the clause. */
|
||||
function panelHasClause(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
clause: string,
|
||||
@@ -46,6 +94,74 @@ function panelHasClause(
|
||||
return has;
|
||||
}
|
||||
|
||||
function replaceQueriesOp(
|
||||
panelId: string,
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: `/spec/panels/${panelId}/spec/queries`,
|
||||
value: queries,
|
||||
};
|
||||
}
|
||||
|
||||
/** Add-only: inject the variable's filter into the given panels (default: all). */
|
||||
export function buildApplyVariableToPanelsPatch(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
targetPanelIds?: string[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
if (!attribute || !variableName) {
|
||||
return [];
|
||||
}
|
||||
const clause = clauseFor(attribute, variableName);
|
||||
const ids = targetPanelIds ?? Object.keys(panels);
|
||||
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
ids.forEach((id) => {
|
||||
const panel = panels[id];
|
||||
if (!panel?.spec?.queries?.length) {
|
||||
return;
|
||||
}
|
||||
const queries = cloneDeep(panel.spec.queries);
|
||||
if (addClause(queries, clause)) {
|
||||
ops.push(replaceQueriesOp(id, queries));
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Full sync: selected panels get the clause; every other panel has it removed. */
|
||||
export function buildSyncVariableToPanelsPatch(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
selectedPanelIds: string[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
if (!attribute || !variableName) {
|
||||
return [];
|
||||
}
|
||||
const clause = clauseFor(attribute, variableName);
|
||||
const selected = new Set(selectedPanelIds);
|
||||
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
Object.keys(panels).forEach((id) => {
|
||||
const panel = panels[id];
|
||||
if (!panel?.spec?.queries?.length) {
|
||||
return;
|
||||
}
|
||||
const queries = cloneDeep(panel.spec.queries);
|
||||
const changed = selected.has(id)
|
||||
? addClause(queries, clause)
|
||||
: removeClause(queries, clause);
|
||||
if (changed) {
|
||||
ops.push(replaceQueriesOp(id, queries));
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Panel ids whose queries currently reference the variable — pre-populates the picker. */
|
||||
export function getPanelIdsReferencingVariable(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
|
||||
@@ -2,10 +2,7 @@ import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
appendAndClause,
|
||||
removeVariableFromExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { removeVariableFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
rewriteVariableReferences,
|
||||
textContainsVariableReference,
|
||||
@@ -21,7 +18,8 @@ export type VariableUsageKind =
|
||||
| 'clickhouse'
|
||||
| 'variable';
|
||||
|
||||
export type VariableImpactMode = 'rename' | 'delete' | 'apply';
|
||||
/** Whether the impact is a rename (rewrite refs) or a delete (remove refs). */
|
||||
export type VariableImpactMode = 'rename' | 'delete';
|
||||
|
||||
/**
|
||||
* One place a variable is referenced — a panel query's builder filter expression,
|
||||
@@ -163,118 +161,3 @@ export function findVariableUsages(
|
||||
|
||||
return usages;
|
||||
}
|
||||
|
||||
// `matchName` is the name in the current query text (the old name during a
|
||||
// simultaneous rename); `injectName` is written into newly added clauses. Equal
|
||||
// outside of a rename.
|
||||
export function findApplyUsages(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
attribute: string,
|
||||
injectName: string,
|
||||
matchName: string,
|
||||
selectedPanelIds: string[],
|
||||
): VariableUsage[] {
|
||||
if (!attribute || !injectName) {
|
||||
return [];
|
||||
}
|
||||
const clause = `${attribute} IN $${injectName}`;
|
||||
const existingClause = `${attribute} IN $${matchName}`;
|
||||
const selected = new Set(selectedPanelIds);
|
||||
const usages: VariableUsage[] = [];
|
||||
|
||||
Object.entries(dashboard.spec.panels ?? {}).forEach(([panelId, panel]) => {
|
||||
const queries = panel?.spec?.queries;
|
||||
if (!queries?.length) {
|
||||
return;
|
||||
}
|
||||
toQueryEnvelopes(queries).forEach((envelope, index) => {
|
||||
const pushUsage = (
|
||||
kind: VariableUsageKind,
|
||||
currentText: string,
|
||||
resultingText: string,
|
||||
): void => {
|
||||
usages.push({
|
||||
id: `panel:${panelId}:${index}`,
|
||||
sourceType: 'panel',
|
||||
sourceId: panelId,
|
||||
sourceLabel: panel.spec?.display?.name || panelId,
|
||||
kind,
|
||||
envelopeIndex: index,
|
||||
currentText,
|
||||
resultingText,
|
||||
});
|
||||
};
|
||||
|
||||
if (envelope.type === 'builder_query') {
|
||||
const spec = envelope.spec as
|
||||
| { filter?: { expression?: string } }
|
||||
| undefined;
|
||||
const current = spec?.filter?.expression ?? '';
|
||||
if (selected.has(panelId)) {
|
||||
// Already carries the clause (under either name) — a rename usage, if
|
||||
// any, fixes the name; don't append a duplicate.
|
||||
if (current.includes(clause) || current.includes(existingClause)) {
|
||||
return;
|
||||
}
|
||||
pushUsage('builder', current, appendAndClause(current, clause));
|
||||
} else {
|
||||
const next = removeVariableFromExpression(current, matchName);
|
||||
if (next !== current) {
|
||||
pushUsage('builder', current, next);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// PromQL/ClickHouse can't carry the managed clause — never auto-inject or
|
||||
// remove. Selected panels still get an editable row defaulting to the
|
||||
// current text, unless the query already references the variable.
|
||||
if (!selected.has(panelId)) {
|
||||
return;
|
||||
}
|
||||
const ref = envelopeReferenceText(envelope);
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
textContainsVariableReference(ref.text, injectName) ||
|
||||
textContainsVariableReference(ref.text, matchName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pushUsage(ref.kind, ref.text, ref.text);
|
||||
});
|
||||
});
|
||||
|
||||
return usages;
|
||||
}
|
||||
|
||||
// Cheap yes/no check for the "Apply to all" disabled state: does every panel query
|
||||
// already reference the variable? Plain string checks — no ANTLR, no rewriting.
|
||||
export function isVariableAppliedToAllPanels(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
): boolean {
|
||||
if (!attribute || !variableName) {
|
||||
return false;
|
||||
}
|
||||
const clause = `${attribute} IN $${variableName}`;
|
||||
const panels = dashboard.spec.panels ?? {};
|
||||
return Object.values(panels).every((panel) => {
|
||||
const queries = panel?.spec?.queries;
|
||||
if (!queries?.length) {
|
||||
return true;
|
||||
}
|
||||
return toQueryEnvelopes(queries).every((envelope) => {
|
||||
if (envelope.type === 'builder_query') {
|
||||
const spec = envelope.spec as
|
||||
| { filter?: { expression?: string } }
|
||||
| undefined;
|
||||
return (spec?.filter?.expression ?? '').includes(clause);
|
||||
}
|
||||
const ref = envelopeReferenceText(envelope);
|
||||
return ref ? textContainsVariableReference(ref.text, variableName) : true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
padding: 12px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Modal } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import styles from './SectionTitleModal.module.scss';
|
||||
|
||||
interface SectionTitleModalProps {
|
||||
open: boolean;
|
||||
@@ -19,7 +17,7 @@ interface SectionTitleModalProps {
|
||||
onSubmit: (title: string) => void;
|
||||
}
|
||||
|
||||
/** Title-entry modal shared by section create and rename (mirrors RenameDashboardModal). */
|
||||
/** Title-entry modal shared by section create and rename. */
|
||||
function SectionTitleModal({
|
||||
open,
|
||||
heading,
|
||||
@@ -39,51 +37,22 @@ function SectionTitleModal({
|
||||
}
|
||||
}, [open, initialValue]);
|
||||
|
||||
// `!isSaving` also guards a second submit (e.g. a double Enter) while a request
|
||||
// is in flight — otherwise two sections would be created.
|
||||
const canSave = value.trim().length > 0 && !isSaving;
|
||||
|
||||
const submit = (): void => {
|
||||
if (!canSave) {
|
||||
return;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
onSubmit(trimmed);
|
||||
}
|
||||
onSubmit(value.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title={heading}
|
||||
<Modal
|
||||
open={open}
|
||||
width="narrow"
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="section-title-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={!canSave}
|
||||
loading={isSaving}
|
||||
onClick={submit}
|
||||
testId="section-title-submit"
|
||||
>
|
||||
{okText}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
title={heading}
|
||||
onCancel={onClose}
|
||||
onOk={submit}
|
||||
okText={okText}
|
||||
okButtonProps={{ disabled: isSaving || !value.trim() }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
testId="section-title-input"
|
||||
@@ -93,13 +62,13 @@ function SectionTitleModal({
|
||||
placeholder={placeholder}
|
||||
onChange={(e): void => setValue(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' && canSave) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogWrapper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ interface Params {
|
||||
}
|
||||
|
||||
interface Result {
|
||||
addSection: (title: string) => Promise<boolean>;
|
||||
addSection: (title: string) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
@@ -38,10 +38,10 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
|
||||
const addSection = useCallback(
|
||||
async (title: string): Promise<boolean> => {
|
||||
async (title: string): Promise<void> => {
|
||||
const trimmed = title.trim();
|
||||
if (!dashboardId || !trimmed) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
const isFirstSection = !layouts || layouts.length === 0;
|
||||
const op = isFirstSection
|
||||
@@ -58,10 +58,8 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
// key it the way `getSectionStableId` does so it reveals itself on render.
|
||||
const newIndex = isFirstSection ? 0 : layouts.length;
|
||||
setScrollTargetId(getSectionStableId([], newIndex));
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { compactGridItems } from '../../../layoutCompaction';
|
||||
import { replaceSectionItemsOp } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { GridItem } from '../../../utils';
|
||||
@@ -77,9 +76,7 @@ export function usePersistLayout({ layoutIndex, items }: Params): Result {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
// Compact to the snapped, non-overlapping layout RGL shows on-screen, so
|
||||
// the persisted geometry can never trip the backend's no-overlap check.
|
||||
const nextItems = compactGridItems(mergeRglLayout(rglLayout, items));
|
||||
const nextItems = mergeRglLayout(rglLayout, items);
|
||||
if (!hasGeometryChanged(nextItems, items)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,47 +86,6 @@
|
||||
:global(.ant-select-selection-overflow) {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
// The shared NewSelect selector scrolls (max-height: 200px; overflow: auto),
|
||||
// which surfaces horizontal + vertical scrollbars inside the fixed-width
|
||||
// variable pill. Clip instead — the value is a single line (maxTagCount + "+N"),
|
||||
// so there is nothing to scroll to.
|
||||
:global(.ant-select-selector) {
|
||||
max-height: none !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
// Let the first tag shrink and truncate so the layout stays [tag…][+N]. Without
|
||||
// this the "+N" overflow item wins the row's space and the (longer) first tag
|
||||
// gets offset/clipped — visible after switching a value from ALL to a subset.
|
||||
:global(.ant-select-selection-overflow-item) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// The shared .custom-multiselect forces the search box to a 60px floor
|
||||
// (min-width: 60px !important). In this narrow variable pill that reserved
|
||||
// width is what offsets/clips the tag row, so collapse it here — the extra
|
||||
// selectors out-specify the shared rule's !important. It still expands to fit
|
||||
// what the user types while the dropdown is open.
|
||||
:global(.ant-select-selector .ant-select-selection-search) {
|
||||
min-width: 0 !important;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
:global(
|
||||
.ant-select-selector
|
||||
.ant-select-selection-search
|
||||
.ant-select-selection-search-input
|
||||
) {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.overflowTooltip {
|
||||
|
||||
@@ -107,18 +107,6 @@ describe('reconcileWithOptions', () => {
|
||||
).toStrictEqual({ value: ['a', 'b'], allSelected: false });
|
||||
});
|
||||
|
||||
it('preserves a user-entered single value not in the options (freeform)', () => {
|
||||
// A typed value that isn't among the fetched options must survive a refetch
|
||||
// (e.g. a time-range change) rather than being reset to the default.
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
model({ type: 'QUERY' }),
|
||||
{ value: 'typed-value', allSelected: false },
|
||||
['a', 'b'],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the configured default (else first) when invalid', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
.addVariableNameWithIcon {
|
||||
flex: none;
|
||||
border-style: dashed !important;
|
||||
border-color: var(--l3-border) !important;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.addVariableIcon {
|
||||
.addVariable {
|
||||
flex: none;
|
||||
border-style: dashed !important;
|
||||
border-color: var(--l3-border) !important;
|
||||
|
||||
@@ -16,7 +16,7 @@ function AddVariableFull(): JSX.Element {
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className={styles.addVariableNameWithIcon}
|
||||
className={styles.addVariable}
|
||||
prefix={<Plus size={14} />}
|
||||
testId="dashboard-variables-add"
|
||||
onClick={(): void =>
|
||||
|
||||
@@ -19,7 +19,7 @@ function AddVariableIcon(): JSX.Element {
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.addVariableIcon}
|
||||
className={styles.addVariable}
|
||||
aria-label="Add variable"
|
||||
testId="dashboard-variables-add"
|
||||
onClick={(): void =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.variableItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 180px;
|
||||
width: 160px;
|
||||
flex-direction: column;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { SolidInfoCircle } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
|
||||
import { Tooltip } from 'antd';
|
||||
@@ -99,7 +99,7 @@ function VariableSelector({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Info className={styles.infoIcon} size={12} />
|
||||
<SolidInfoCircle className={styles.infoIcon} size={12} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import type { OptionData } from 'components/NewSelect/types';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import type { VariableSelection } from '../../selectionTypes';
|
||||
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
|
||||
interface ValueSelectorProps {
|
||||
@@ -17,13 +16,17 @@ interface ValueSelectorProps {
|
||||
loading?: boolean;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
emptyFallback: VariableSelection;
|
||||
testId?: string;
|
||||
/** Option-fetch error surfaced in the dropdown, with a retry action. */
|
||||
errorMessage?: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
|
||||
* shared NewSelect components, which provide search, the "ALL" option and
|
||||
* apply-on-close batching (so multi-select edits don't cascade per toggle).
|
||||
*/
|
||||
function ValueSelector({
|
||||
options,
|
||||
variableType,
|
||||
@@ -32,7 +35,6 @@ function ValueSelector({
|
||||
loading,
|
||||
selection,
|
||||
onChange,
|
||||
emptyFallback,
|
||||
testId,
|
||||
errorMessage,
|
||||
onRetry,
|
||||
@@ -42,92 +44,56 @@ function ValueSelector({
|
||||
[options],
|
||||
);
|
||||
|
||||
// All-selected → the full option set so CustomMultiSelect engages its "all"
|
||||
// path (overlay when closed, every option checked when open). The scalar
|
||||
// sentinel would instead render a literal `__ALL__` row.
|
||||
const committedValues = useMemo<string[]>(
|
||||
() =>
|
||||
selection.allSelected
|
||||
? options
|
||||
: (Array.isArray(selection.value) ? selection.value : []).map(String),
|
||||
[selection, options],
|
||||
);
|
||||
|
||||
// Buffer edits while the dropdown is open; the committed selection is shown
|
||||
// when closed. This defers the dependent cascade to a single commit-on-close.
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string[]>(committedValues);
|
||||
|
||||
const commit = (values: string[]): void => {
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
const next: VariableSelection =
|
||||
values.length === 0 ? emptyFallback : { value: values, allSelected: isAll };
|
||||
|
||||
// Closing without actually changing the selection must not re-fire onChange —
|
||||
// that would needlessly re-cascade to dependent variables/panels.
|
||||
if (areSelectionsEqual(next, selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent(
|
||||
DashboardDetailEvents.VariableValueSelected,
|
||||
{ variableType, multiSelect: true, selectionCount: values.length },
|
||||
'track',
|
||||
true,
|
||||
);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
if (multiSelect) {
|
||||
// All-selected → hand CustomMultiSelect the full option set so it engages its
|
||||
// "all" path (overlay when closed, every option checked when open). Passing the
|
||||
// scalar sentinel instead makes it render a literal `__ALL__` row.
|
||||
const value = selection.allSelected
|
||||
? options
|
||||
: (Array.isArray(selection.value) ? selection.value : []).map(String);
|
||||
return (
|
||||
<CustomMultiSelect
|
||||
className={styles.control}
|
||||
data-testid={testId}
|
||||
options={optionData}
|
||||
value={isOpen ? draft : committedValues}
|
||||
value={value}
|
||||
loading={loading}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={onRetry}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select value"
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={10}
|
||||
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
|
||||
maxTagCount={2}
|
||||
maxTagTextLength={20}
|
||||
// Offer ALL only once options load, else a concrete value reads as "all".
|
||||
enableAllSelection={showAllOption && options.length > 0}
|
||||
onDropdownVisibleChange={(open): void => {
|
||||
if (open) {
|
||||
setDraft(committedValues);
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
commit(draft);
|
||||
}}
|
||||
onChange={(next): void => {
|
||||
const values = Array.isArray(next)
|
||||
? next.map(String)
|
||||
: next
|
||||
? [String(next)]
|
||||
: [];
|
||||
setDraft(values);
|
||||
void logEvent(
|
||||
DashboardDetailEvents.VariableValueSelected,
|
||||
{ variableType, multiSelect: true, selectionCount: values.length },
|
||||
'track',
|
||||
true,
|
||||
);
|
||||
if (values.length === 0) {
|
||||
onChange({ value: [], allSelected: false });
|
||||
return;
|
||||
}
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
onChange({ value: values, allSelected: isAll });
|
||||
}}
|
||||
onClear={(): void => {
|
||||
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
|
||||
variableType,
|
||||
});
|
||||
setDraft([]);
|
||||
// A clear on the closed control falls back to the default immediately;
|
||||
// while open it just empties the draft (committed on close).
|
||||
if (!isOpen) {
|
||||
onChange(emptyFallback);
|
||||
}
|
||||
onChange({ value: [], allSelected: false });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
VARIABLE_TYPE_EVENT_LABEL,
|
||||
type VariableFormModel,
|
||||
@@ -8,10 +6,6 @@ import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../../selectionTypes';
|
||||
import {
|
||||
reconcileWithOptions,
|
||||
resolveDefaultSelection,
|
||||
} from '../../utils/resolveVariableSelection';
|
||||
import { useAutoSelect } from '../../hooks/useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
import { useVariableOptions } from '../../hooks/useVariableOptions';
|
||||
@@ -50,12 +44,6 @@ function VariableValueControl({
|
||||
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
|
||||
// The selection to fall back to when the multi-select closes empty
|
||||
const emptyFallback = useMemo<VariableSelection>(() => {
|
||||
const seed = resolveDefaultSelection(variable);
|
||||
return reconcileWithOptions(variable, seed, options) ?? seed;
|
||||
}, [variable, options]);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
@@ -67,7 +55,6 @@ function VariableValueControl({
|
||||
onRetry={onRetry}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
emptyFallback={emptyFallback}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -53,6 +53,19 @@ function isAllDefault(
|
||||
);
|
||||
}
|
||||
|
||||
function isValidSingle(
|
||||
value: SelectedVariableValue,
|
||||
options: string[],
|
||||
): boolean {
|
||||
return (
|
||||
!Array.isArray(value) &&
|
||||
value !== '' &&
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
options.includes(String(value))
|
||||
);
|
||||
}
|
||||
|
||||
/** The configured default (or first option) as a fresh selection. */
|
||||
function fillDefault(
|
||||
model: VariableFormModel,
|
||||
@@ -154,17 +167,8 @@ export function reconcileWithOptions(
|
||||
: fillDefault(model, options);
|
||||
}
|
||||
|
||||
if (!model.multiSelect) {
|
||||
// Preserve any non-empty single value across a refetch — including a user-typed
|
||||
// value that isn't in the fetched options (freeform). Only fall back to the
|
||||
// default/first option when there is no value yet, so e.g. a time-range change
|
||||
// (which refetches options) never wipes a value the user didn't change.
|
||||
const hasValue =
|
||||
!Array.isArray(current.value) &&
|
||||
current.value !== '' &&
|
||||
current.value !== null &&
|
||||
current.value !== undefined;
|
||||
return hasValue ? null : fillDefault(model, options);
|
||||
if (!model.multiSelect && isValidSingle(current.value, options)) {
|
||||
return null;
|
||||
}
|
||||
return fillDefault(model, options);
|
||||
}
|
||||
@@ -184,20 +188,3 @@ export function configuredDefaultValue(
|
||||
}
|
||||
return configuredDefault(model.defaultValue);
|
||||
}
|
||||
|
||||
/** Normalize a selection's value to an order-independent key of its members. */
|
||||
function valueSetKey(value: SelectedVariableValue): string {
|
||||
const list = Array.isArray(value) ? value : value == null ? [] : [value];
|
||||
return list.map(String).sort().join('||');
|
||||
}
|
||||
|
||||
/** True when two selections carry the same ALL flag and the same value set. */
|
||||
export function areSelectionsEqual(
|
||||
a: VariableSelection,
|
||||
b: VariableSelection,
|
||||
): boolean {
|
||||
return (
|
||||
!!a.allSelected === !!b.allSelected &&
|
||||
valueSetKey(a.value) === valueSetKey(b.value)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { compactGridItems, compactSpecLayouts } from '../layoutCompaction';
|
||||
import type { GridItem } from '../utils';
|
||||
|
||||
const item = (
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width = 6,
|
||||
height = 2,
|
||||
): GridItem => ({ id, x, y, width, height, panel: undefined });
|
||||
|
||||
describe('compactGridItems', () => {
|
||||
it('pulls a floating item up to the top', () => {
|
||||
const [a] = compactGridItems([item('a', 0, 5)]);
|
||||
expect(a.y).toBe(0);
|
||||
});
|
||||
|
||||
it('resolves an overlap by pushing the colliding item down', () => {
|
||||
// Order is preserved, so [0] is 'a' and [1] is 'b'.
|
||||
const result = compactGridItems([item('a', 0, 0), item('b', 0, 1)]);
|
||||
// a occupies rows 0-1 (height 2), so b must sit at row 2 — no overlap.
|
||||
expect(result[0].y).toBe(0);
|
||||
expect(result[1].y).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves item order and the panel reference', () => {
|
||||
const panel = { kind: 'panel' } as unknown as GridItem['panel'];
|
||||
const result = compactGridItems([
|
||||
{ ...item('a', 0, 0), panel },
|
||||
item('b', 6, 0),
|
||||
]);
|
||||
expect(result.map((i) => i.id)).toStrictEqual(['a', 'b']);
|
||||
expect(result[0].panel).toBe(panel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactSpecLayouts', () => {
|
||||
const grid = (
|
||||
items: { x: number; y: number; width: number; height: number; ref: string }[],
|
||||
): DashboardtypesLayoutDTO =>
|
||||
({
|
||||
kind: 'Grid',
|
||||
spec: {
|
||||
items: items.map((i) => ({
|
||||
x: i.x,
|
||||
y: i.y,
|
||||
width: i.width,
|
||||
height: i.height,
|
||||
content: { $ref: i.ref },
|
||||
})),
|
||||
},
|
||||
}) as unknown as DashboardtypesLayoutDTO;
|
||||
|
||||
it('compacts overlapping items and keeps their panel refs', () => {
|
||||
const [layout] = compactSpecLayouts([
|
||||
grid([
|
||||
{ x: 0, y: 0, width: 6, height: 2, ref: '#/spec/panels/a' },
|
||||
{ x: 0, y: 1, width: 6, height: 2, ref: '#/spec/panels/b' },
|
||||
]),
|
||||
]);
|
||||
const items = layout.spec?.items ?? [];
|
||||
expect(items[0]?.y).toBe(0);
|
||||
expect(items[1]?.y).toBe(2);
|
||||
expect(items[1]?.content?.$ref).toBe('#/spec/panels/b');
|
||||
});
|
||||
|
||||
it('passes a non-Grid layout through untouched', () => {
|
||||
const other = { kind: 'Other' } as unknown as DashboardtypesLayoutDTO;
|
||||
expect(compactSpecLayouts([other])[0]).toBe(other);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
// Cap the dashboard-name segment so a long title ellipsizes on one line instead
|
||||
// of stretching the breadcrumb row.
|
||||
.title {
|
||||
display: inline-block;
|
||||
max-width: 480px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@signozhq/ui/breadcrumb';
|
||||
|
||||
import styles from './DashboardPageBreadcrumbs.module.scss';
|
||||
|
||||
interface DashboardPageBreadcrumbsProps {
|
||||
title: string;
|
||||
image: string;
|
||||
@@ -62,9 +60,7 @@ function DashboardPageBreadcrumbs({
|
||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink icon={<img src={image} alt="dashboard-icon" />}>
|
||||
<span className={styles.title} title={title}>
|
||||
{title}
|
||||
</span>
|
||||
{title}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import * as ReactGridLayout from 'react-grid-layout';
|
||||
import type { Layout } from 'react-grid-layout';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { GRID_COLS } from './patchOps';
|
||||
import type { GridItem } from './utils';
|
||||
|
||||
// `utils.compact` is exported by react-grid-layout at runtime — it is the exact
|
||||
// vertical compaction the grid applies on-screen while dragging — but it is absent
|
||||
// from the package's TypeScript types, so it is reached through a typed cast.
|
||||
const { compact } = (
|
||||
ReactGridLayout as unknown as {
|
||||
utils: {
|
||||
compact: (
|
||||
layout: Layout[],
|
||||
compactType: 'vertical' | 'horizontal',
|
||||
cols: number,
|
||||
) => Layout[];
|
||||
};
|
||||
}
|
||||
).utils;
|
||||
|
||||
/** Vertically compact geometry so no two items overlap (mirrors on-screen RGL). */
|
||||
function compactVertically(layout: Layout[]): Layout[] {
|
||||
return compact(layout, 'vertical', GRID_COLS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a section's grid items to a non-overlapping, vertically-compacted layout —
|
||||
* the same normalization RGL applies mid-drag — so a persisted layout can never be
|
||||
* rejected by the backend's no-overlap validation. Panel refs and every other item
|
||||
* field are preserved; item order is unchanged (only geometry updates).
|
||||
*/
|
||||
export function compactGridItems(items: GridItem[]): GridItem[] {
|
||||
const compacted = compactVertically(
|
||||
items.map((item) => ({
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})),
|
||||
);
|
||||
const byId = new Map(compacted.map((entry) => [entry.i, entry]));
|
||||
return items.map((item) => {
|
||||
const entry = byId.get(item.id);
|
||||
return entry
|
||||
? { ...item, x: entry.x, y: entry.y, width: entry.w, height: entry.h }
|
||||
: item;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact every Grid section in a `spec.layouts` array — used on JSON-editor save,
|
||||
* where hand-edited items can overlap. Items are keyed by index (spec items carry
|
||||
* no id of their own); non-Grid or empty layouts pass through untouched.
|
||||
*/
|
||||
export function compactSpecLayouts(
|
||||
layouts: DashboardtypesLayoutDTO[],
|
||||
): DashboardtypesLayoutDTO[] {
|
||||
return layouts.map((layout) => {
|
||||
const items = layout?.kind === 'Grid' ? (layout.spec?.items ?? []) : [];
|
||||
if (items.length === 0) {
|
||||
return layout;
|
||||
}
|
||||
const compacted = compactVertically(
|
||||
items.map((item, index) => ({
|
||||
i: String(index),
|
||||
x: item.x ?? 0,
|
||||
y: item.y ?? 0,
|
||||
w: item.width ?? 6,
|
||||
h: item.height ?? 6,
|
||||
})),
|
||||
);
|
||||
const byIndex = new Map(compacted.map((entry) => [entry.i, entry]));
|
||||
const nextItems = items.map((item, index) => {
|
||||
const entry = byIndex.get(String(index));
|
||||
return entry
|
||||
? { ...item, x: entry.x, y: entry.y, width: entry.w, height: entry.h }
|
||||
: item;
|
||||
});
|
||||
return { ...layout, spec: { ...layout.spec, items: nextItems } };
|
||||
});
|
||||
}
|
||||
@@ -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'>;
|
||||
|
||||
@@ -42,8 +42,3 @@
|
||||
.menuItemWrap button:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.deleteName {
|
||||
color: var(--danger-background);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,10 @@ import { useCopyToClipboard } from 'react-use';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
cloneDashboardV2,
|
||||
getGetDashboardV2QueryKey,
|
||||
invalidateListDashboardsForUserV2,
|
||||
lockDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
@@ -111,17 +109,6 @@ function ActionsPopover({
|
||||
action: isLocked ? 'unlock' : 'lock',
|
||||
dashboardId,
|
||||
});
|
||||
// Patch the detail-page cache too: it uses staleTime:Infinity +
|
||||
// refetchOnMount:false, so without this, returning to the dashboard would
|
||||
// still show the stale (pre-toggle) lock state.
|
||||
const key = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
|
||||
if (cached) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(key, {
|
||||
...cached,
|
||||
data: { ...cached.data, locked: !isLocked },
|
||||
});
|
||||
}
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Modal, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
import { CircleAlert, Trash2 } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -18,7 +18,6 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { useDeleteConfirm } from '../DeleteConfirmModal/useDeleteConfirm';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
interface Props {
|
||||
@@ -42,7 +41,7 @@ function DeleteActionItem({
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const queryClient = useQueryClient();
|
||||
const { contextHolder, confirmDelete } = useDeleteConfirm();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
const isAuthor = user?.email === createdBy;
|
||||
const isDisabled = isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor);
|
||||
@@ -50,12 +49,14 @@ function DeleteActionItem({
|
||||
const { mutate: runDelete } = useMutation({
|
||||
mutationFn: () => deleteDashboardV2({ id: dashboardId }),
|
||||
onSuccess: async () => {
|
||||
toast.success(
|
||||
t('dashboard:delete_dashboard_success', { name: dashboardName }),
|
||||
);
|
||||
void logEvent(DashboardListEvents.RowAction, {
|
||||
action: 'delete',
|
||||
dashboardId,
|
||||
});
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
toast.success('Dashboard deleted successfully');
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
@@ -63,24 +64,39 @@ function DeleteActionItem({
|
||||
});
|
||||
|
||||
const openConfirm = useCallback((): void => {
|
||||
confirmDelete({
|
||||
modal.confirm({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Are you sure you want to delete the
|
||||
<Typography.Text className={styles.deleteName}>
|
||||
<span style={{ color: 'var(--danger-background)', fontWeight: 500 }}>
|
||||
{' '}
|
||||
{dashboardName}{' '}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
dashboard?
|
||||
</Typography.Title>
|
||||
),
|
||||
// Keeps the Delete button loading until the mutation settles, then closes.
|
||||
onConfirm: () =>
|
||||
icon: (
|
||||
<CircleAlert
|
||||
style={{ color: 'var(--danger-background)', marginInlineEnd: '12px' }}
|
||||
size="3xl"
|
||||
/>
|
||||
),
|
||||
okText: 'Delete',
|
||||
okButtonProps: { danger: true },
|
||||
// Returning a promise keeps the Delete button in a loading state and blocks
|
||||
// re-clicks until the mutation settles, then closes the confirm.
|
||||
onOk: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
runDelete(undefined, { onSettled: () => resolve() });
|
||||
}),
|
||||
cancelButtonProps: {
|
||||
onClick: (e): void => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
},
|
||||
centered: true,
|
||||
});
|
||||
}, [confirmDelete, dashboardName, runDelete]);
|
||||
}, [modal, dashboardName, runDelete]);
|
||||
|
||||
const tooltip = ((): string => {
|
||||
if (!isLocked) {
|
||||
|
||||
@@ -59,6 +59,14 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tagsWithActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -130,10 +138,11 @@
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
// Cap the name tooltip so a long name wraps instead of spanning the page.
|
||||
.nameTooltip {
|
||||
max-width: 480px;
|
||||
overflow-wrap: break-word;
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.details {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
@@ -20,7 +21,6 @@ import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import { lastUpdatedLabel, tagsToStrings } from '../../utils/helpers';
|
||||
import ActionsPopover from '../ActionsPopover/ActionsPopover';
|
||||
import DashboardRowTags from './DashboardRowTags/DashboardRowTags';
|
||||
import LegacyDashboardDialog from '../LegacyDashboardDialog/LegacyDashboardDialog';
|
||||
|
||||
import styles from './DashboardRow.module.scss';
|
||||
@@ -129,12 +129,7 @@ function DashboardRow({
|
||||
<div className={styles.titleWithAction}>
|
||||
<div className={styles.titleBlock}>
|
||||
{name.length > 50 ? (
|
||||
<TooltipSimple
|
||||
title={name}
|
||||
side="bottom"
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.nameTooltip }}
|
||||
>
|
||||
<TooltipSimple title={name} side="bottom" disableHoverableContent>
|
||||
{titleLink}
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
@@ -152,7 +147,18 @@ function DashboardRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DashboardRowTags tags={tags} />
|
||||
<div className={styles.tagsWithActions}>
|
||||
{tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLocked && (
|
||||
<TooltipSimple
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
.tags {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extraTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* Recolor via the tooltip's own CSS variables so the arrow (whose fill reads
|
||||
--tooltip-background) turns black along with the content. */
|
||||
.extraTagsTooltip {
|
||||
--tooltip-background: var(--bg-ink-500, #0b0c0e);
|
||||
--tooltip-border-color: var(--bg-ink-500, #0b0c0e);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
|
||||
import styles from './DashboardRowTags.module.scss';
|
||||
|
||||
const MAX_VISIBLE_TAGS = 3;
|
||||
|
||||
interface DashboardRowTagsProps {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboards-list row tag chips: the first few inline, the rest revealed on
|
||||
* hover of a "+N" chip.
|
||||
*/
|
||||
function DashboardRowTags({ tags }: DashboardRowTagsProps): JSX.Element | null {
|
||||
if (tags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extra = tags.slice(MAX_VISIBLE_TAGS);
|
||||
|
||||
return (
|
||||
<div className={styles.tags}>
|
||||
{tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{extra.length > 0 && (
|
||||
<TooltipSimple
|
||||
side="bottom"
|
||||
arrow
|
||||
tooltipContentProps={{ className: styles.extraTagsTooltip }}
|
||||
title={
|
||||
<div className={styles.extraTags}>
|
||||
{extra.map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Stop the click so hovering/clicking the chip doesn't navigate the row. */}
|
||||
<span role="presentation" onClick={(e): void => e.stopPropagation()}>
|
||||
<TagBadge>+{extra.length}</TagBadge>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardRowTags;
|
||||
@@ -115,9 +115,9 @@ function DashboardsList(): JSX.Element {
|
||||
void setPage(1);
|
||||
}, [resetView, setPage]);
|
||||
const handleRemoveView = useCallback(
|
||||
(id: string): Promise<void> => {
|
||||
(id: string): void => {
|
||||
removeView(id);
|
||||
void setPage(1);
|
||||
return removeView(id);
|
||||
},
|
||||
[removeView, setPage],
|
||||
);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// Vertically centre the alert icon against the confirm body, matching the v1
|
||||
// dashboard delete button.
|
||||
.deleteModal :global(.ant-modal-confirm-body) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--danger-background);
|
||||
margin-inline-end: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
|
||||
import styles from './DeleteConfirmModal.module.scss';
|
||||
|
||||
interface ConfirmDeleteOptions {
|
||||
title: ReactNode;
|
||||
content?: ReactNode;
|
||||
confirmText?: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface UseDeleteConfirm {
|
||||
/** Must be rendered in the calling component for the modal to appear. */
|
||||
contextHolder: ReactNode;
|
||||
confirmDelete: (options: ConfirmDeleteOptions) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared destructive confirmation for deleting dashboards and saved views, so
|
||||
* both get the same alert icon, aligned title, danger button, and a working
|
||||
* Cancel (antd closes on Cancel as long as we don't override its `onClick`).
|
||||
*/
|
||||
export function useDeleteConfirm(): UseDeleteConfirm {
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
const confirmDelete = ({
|
||||
title,
|
||||
content,
|
||||
confirmText = 'Delete',
|
||||
onConfirm,
|
||||
}: ConfirmDeleteOptions): void => {
|
||||
modal.confirm({
|
||||
className: styles.deleteModal,
|
||||
title,
|
||||
content,
|
||||
icon: <CircleAlert className={styles.icon} size="3xl" />,
|
||||
okText: confirmText,
|
||||
okButtonProps: { danger: true },
|
||||
// Returning a promise keeps Delete in a loading state until the action
|
||||
// settles, then antd closes the modal.
|
||||
onOk: () => Promise.resolve(onConfirm()),
|
||||
centered: true,
|
||||
});
|
||||
};
|
||||
|
||||
return { contextHolder, confirmDelete };
|
||||
}
|
||||
@@ -128,8 +128,7 @@
|
||||
|
||||
.requestForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
margin-top: 16px;
|
||||
@@ -137,15 +136,6 @@
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.requestHeader {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.requestRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.requestInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -80,49 +80,39 @@ function TemplatesPanel(): JSX.Element {
|
||||
|
||||
{isCloudUser && (
|
||||
<div className={styles.requestForm}>
|
||||
<Typography
|
||||
variant="text"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
className={styles.requestHeader}
|
||||
>
|
||||
Request a new template
|
||||
</Typography>
|
||||
<div className={styles.requestRow}>
|
||||
<Input
|
||||
className={styles.requestInput}
|
||||
placeholder="Enter dashboard name..."
|
||||
value={name}
|
||||
testId="request-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleRequest();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={submitting || requestName.length === 0}
|
||||
testId="request-dashboard-submit"
|
||||
prefix={
|
||||
submitting ? (
|
||||
<LoaderCircle size={14} className={styles.spinner} />
|
||||
) : (
|
||||
<Check size={14} />
|
||||
)
|
||||
}
|
||||
onClick={(): void => {
|
||||
<Input
|
||||
className={styles.requestInput}
|
||||
placeholder="Enter dashboard name..."
|
||||
value={name}
|
||||
testId="request-dashboard-name"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleRequest();
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={submitting || requestName.length === 0}
|
||||
testId="request-dashboard-submit"
|
||||
prefix={
|
||||
submitting ? (
|
||||
<LoaderCircle size={14} className={styles.spinner} />
|
||||
) : (
|
||||
<Check size={14} />
|
||||
)
|
||||
}
|
||||
onClick={(): void => {
|
||||
void handleRequest();
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { type ChangeEvent, useCallback, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Bookmark, PenLine, Plus, Search, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
Bookmark,
|
||||
CircleAlert,
|
||||
PenLine,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
|
||||
import type { SavedView } from '../../types';
|
||||
import { type BuiltinView } from '../../utils/views';
|
||||
import { useDeleteConfirm } from '../DeleteConfirmModal/useDeleteConfirm';
|
||||
import ViewNamePopover from './ViewNamePopover';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
@@ -29,7 +36,7 @@ interface Props {
|
||||
onSave: (name: string) => void;
|
||||
onSaveChanges: () => void;
|
||||
onReset: () => void;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
@@ -61,7 +68,7 @@ function ViewsRail({
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const { contextHolder, confirmDelete } = useDeleteConfirm();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesQuery = (label: string): boolean =>
|
||||
@@ -77,9 +84,9 @@ function ViewsRail({
|
||||
const noMatches =
|
||||
!!q && personal.length === 0 && system.length === 0 && custom.length === 0;
|
||||
|
||||
const onConfirmDelete = useCallback(
|
||||
const confirmDelete = useCallback(
|
||||
(id: string, label: string): void => {
|
||||
confirmDelete({
|
||||
const { destroy } = modal.confirm({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Delete the{' '}
|
||||
@@ -88,15 +95,27 @@ function ViewsRail({
|
||||
</Typography.Title>
|
||||
),
|
||||
content: 'This removes the saved view. Your dashboards are not affected.',
|
||||
// Return the delete promise so the modal's Delete button stays loading
|
||||
// until the backend call settles, then closes — matching dashboard delete.
|
||||
onConfirm: (): Promise<void> => {
|
||||
void logEvent(DashboardListEvents.ViewDeleted, {});
|
||||
return onDelete(id);
|
||||
icon: (
|
||||
<CircleAlert
|
||||
style={{ color: 'var(--danger-background)', marginInlineEnd: '12px' }}
|
||||
size="3xl"
|
||||
/>
|
||||
),
|
||||
okText: 'Delete',
|
||||
okButtonProps: {
|
||||
danger: true,
|
||||
onClick: (e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void logEvent(DashboardListEvents.ViewDeleted, {});
|
||||
onDelete(id);
|
||||
destroy();
|
||||
},
|
||||
},
|
||||
centered: true,
|
||||
});
|
||||
},
|
||||
[confirmDelete, onDelete],
|
||||
[modal, onDelete],
|
||||
);
|
||||
|
||||
const handleSaveAsView = (name: string): void => {
|
||||
@@ -163,7 +182,7 @@ function ViewsRail({
|
||||
title="Delete view"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onConfirmDelete(row.id, row.label);
|
||||
confirmDelete(row.id, row.label);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface UseActiveViewResult {
|
||||
saveView: (name: string) => void;
|
||||
saveActiveView: () => void;
|
||||
resetView: () => void;
|
||||
removeView: (id: string) => Promise<void>;
|
||||
removeView: (id: string) => void;
|
||||
renameView: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
@@ -152,14 +152,8 @@ export function useActiveView({
|
||||
}, [canonicalQuery, setQuery, activeCustom, setSortColumn, setSortOrder]);
|
||||
|
||||
const removeView = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
try {
|
||||
await deleteView(id);
|
||||
} catch {
|
||||
// Failure is surfaced by the delete mutation's error toast; still
|
||||
// settle so the confirm modal stops loading and closes.
|
||||
return;
|
||||
}
|
||||
(id: string): void => {
|
||||
deleteView(id);
|
||||
if (activeViewId === id) {
|
||||
void setActiveViewId(BuiltinViewId.All);
|
||||
setQuery('');
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface UseSavedViewsResult {
|
||||
isLoading: boolean;
|
||||
createView: (input: SavedViewInput) => Promise<SavedView | null>;
|
||||
updateView: (id: string, input: SavedViewInput) => void;
|
||||
deleteView: (id: string) => Promise<void>;
|
||||
deleteView: (id: string) => void;
|
||||
}
|
||||
|
||||
// Org-shared saved views, backed by the Views API. Exposes the list plus
|
||||
@@ -127,8 +127,9 @@ export function useSavedViews(): UseSavedViewsResult {
|
||||
);
|
||||
|
||||
const deleteView = useCallback(
|
||||
(id: string): Promise<void> =>
|
||||
deleteMutation.mutateAsync({ pathParams: { id } }).then(() => undefined),
|
||||
(id: string): void => {
|
||||
deleteMutation.mutate({ pathParams: { id } });
|
||||
},
|
||||
[deleteMutation],
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,8 +182,7 @@ func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans, selectFields)
|
||||
start, end := flamegraphTrace.TimeRange()
|
||||
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), start, end, false), nil
|
||||
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start, summary.End, false), nil
|
||||
}
|
||||
|
||||
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
|
||||
@@ -199,8 +198,6 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
|
||||
flamegraphTrace := spantypes.NewFlamegraphTraceFromMinimal(minimalSpans)
|
||||
minimalSpans = nil //nolint:ineffassign,wastedassign // release backing array before further db calls
|
||||
|
||||
start, end := flamegraphTrace.TimeRange()
|
||||
|
||||
cfg := m.config.Flamegraph
|
||||
selectedSpans := flamegraphTrace.GetSelectedLevels(selectedSpanID, cfg.MaxSelectedLevels, cfg.MaxSpansPerLevel, cfg.SamplingTopLatencySpansCount, cfg.SamplingBucketCount)
|
||||
if len(selectedSpans) == 0 {
|
||||
@@ -213,5 +210,5 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
|
||||
}
|
||||
|
||||
enrichedSpans := flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields)
|
||||
return spantypes.NewGettableFlamegraphTrace(enrichedSpans, start, end, true), nil
|
||||
return spantypes.NewGettableFlamegraphTrace(enrichedSpans, summary.Start, summary.End, true), nil
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func newProvider(
|
||||
telemetryaudit.LogAttributeKeysTblName,
|
||||
telemetryaudit.LogResourceKeysTblName,
|
||||
telemetrymetadata.DBName,
|
||||
telemetrymetadata.AttributesMetadataLocalTableName,
|
||||
telemetrymetadata.AttributesMetadataTableName,
|
||||
telemetrymetadata.ColumnEvolutionMetadataTableName,
|
||||
flagger,
|
||||
)
|
||||
|
||||
@@ -222,7 +222,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddTelemetryTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddTagRelationRankFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -447,7 +447,7 @@ func New(
|
||||
telemetryaudit.LogAttributeKeysTblName,
|
||||
telemetryaudit.LogResourceKeysTblName,
|
||||
telemetrymetadata.DBName,
|
||||
telemetrymetadata.AttributesMetadataLocalTableName,
|
||||
telemetrymetadata.AttributesMetadataTableName,
|
||||
telemetrymetadata.ColumnEvolutionMetadataTableName,
|
||||
flagger,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -96,8 +96,11 @@ func (c *conditionBuilder) conditionForKey(
|
||||
|
||||
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
|
||||
|
||||
// key must exists to apply main filter
|
||||
expr := `if(mapContains(%s, %s), %s, true)`
|
||||
// key must exist to apply the main filter. for positive operators the
|
||||
// absent-key rows are excluded (fallback false); for negative operators
|
||||
// they are kept (fallback true) so rows legitimately lacking the key match.
|
||||
keyMissingFallback := operator.IsNegativeOperator()
|
||||
expr := `if(mapContains(%s, %s), %s, %t)`
|
||||
|
||||
var cond string
|
||||
|
||||
@@ -171,5 +174,5 @@ func (c *conditionBuilder) conditionForKey(
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(expr, columns[0].Name, sb.Var(key.Name), cond), nil
|
||||
return fmt.Sprintf(expr, columns[0].Name, sb.Var(key.Name), cond, keyMissingFallback), nil
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestConditionFor(t *testing.T) {
|
||||
},
|
||||
operator: qbtypes.FilterOperatorILike,
|
||||
value: "%admin%",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), LOWER(attributes['user.id']) LIKE LOWER(?), true)",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), LOWER(attributes['user.id']) LIKE LOWER(?), false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
@@ -49,6 +49,150 @@ func TestConditionFor(t *testing.T) {
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), LOWER(attributes['user.id']) NOT LIKE LOWER(?), true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Equal operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "admin",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), attributes['user.id'] = ?, false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not Equal operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotEqual,
|
||||
value: "admin",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), attributes['user.id'] <> ?, true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "In operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorIn,
|
||||
value: []any{"admin", "root"},
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), (attributes['user.id'] = ? OR attributes['user.id'] = ?), false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not In operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotIn,
|
||||
value: []any{"admin", "root"},
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), (attributes['user.id'] <> ? AND attributes['user.id'] <> ?), true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Like operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorLike,
|
||||
value: "%admin%",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), attributes['user.id'] LIKE ?, false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not Like operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotLike,
|
||||
value: "%admin%",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), attributes['user.id'] NOT LIKE ?, true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Contains operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorContains,
|
||||
value: "admin",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), LOWER(attributes['user.id']) LIKE LOWER(?), false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not Contains operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotContains,
|
||||
value: "admin",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), LOWER(attributes['user.id']) NOT LIKE LOWER(?), true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Regexp operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorRegexp,
|
||||
value: "adm.*",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), match(attributes['user.id'], ?), false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not Regexp operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotRegexp,
|
||||
value: "adm.*",
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), NOT match(attributes['user.id'], ?), true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Exists operator - positive fallback false",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorExists,
|
||||
value: nil,
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), mapContains(attributes, 'user.id') = ?, false)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Not Exists operator - negative fallback true",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotExists,
|
||||
value: nil,
|
||||
expectedSQL: "WHERE if(mapContains(attributes, ?), mapContains(attributes, 'user.id') <> ?, true)",
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
||||
@@ -1436,6 +1436,11 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, orgID valuer.
|
||||
sb.Where(sb.LE("unix_milli", fieldValueSelector.EndUnixMilli))
|
||||
}
|
||||
|
||||
// scope to the requested signal's rows;
|
||||
if fieldValueSelector.Signal != telemetrytypes.SignalUnspecified {
|
||||
sb.Where(sb.E("data_source", fieldValueSelector.Signal.StringValue()))
|
||||
}
|
||||
|
||||
if fieldValueSelector.Value != "" {
|
||||
var conds []string
|
||||
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextAttribute &&
|
||||
@@ -1464,9 +1469,9 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, orgID valuer.
|
||||
}
|
||||
|
||||
if len(conds) != 0 {
|
||||
// see `expr` in condition_builder.go, if key doesn't exist we don't check for value
|
||||
// hence, this is join of conditions on resource and attributes
|
||||
sb.Where(sb.And(conds...))
|
||||
// the key may sit in the resource or attribute map (or both), so OR the
|
||||
// two conditions — match if the key's value in either map contains searchText.
|
||||
sb.Where(sb.Or(conds...))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func TestGetFirstSeenFromMetricMetadata(t *testing.T) {
|
||||
telemetryaudit.LogAttributeKeysTblName,
|
||||
telemetryaudit.LogResourceKeysTblName,
|
||||
DBName,
|
||||
AttributesMetadataLocalTableName,
|
||||
AttributesMetadataTableName,
|
||||
ColumnEvolutionMetadataTableName,
|
||||
flaggertest.New(t),
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 := §ion{}
|
||||
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 := §ion{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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user