mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-06 22:50:38 +01:00
Compare commits
3 Commits
issue-5535
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cffe99d3c | ||
|
|
3cb50873bb | ||
|
|
f118f78d9c |
@@ -48,6 +48,13 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footerStatus {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.validation {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -57,6 +64,15 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.danglingWarning {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--bg-amber-400);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.validationValid {
|
||||
color: var(--bg-forest-400);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,18 @@ function JsonEditorDrawer({
|
||||
}: JsonEditorDrawerProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
|
||||
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
validity,
|
||||
isDirty,
|
||||
isSaving,
|
||||
danglingPanelIds,
|
||||
missingPanelRefs,
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
|
||||
const onCopy = useCallback((): void => {
|
||||
copyToClipboard(draft);
|
||||
@@ -60,6 +70,19 @@ function JsonEditorDrawer({
|
||||
const validationText = validity.valid
|
||||
? `Valid JSON · ${validity.lineCount} lines`
|
||||
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
|
||||
const plural = (n: number): string => (n === 1 ? '' : 's');
|
||||
const danglingWarning =
|
||||
danglingPanelIds.length > 0
|
||||
? `${danglingPanelIds.length} panel${plural(
|
||||
danglingPanelIds.length,
|
||||
)} not present in any section — they won't be shown after saving.`
|
||||
: null;
|
||||
const missingRefWarning =
|
||||
missingPanelRefs.length > 0
|
||||
? `${missingPanelRefs.length} layout item${plural(
|
||||
missingPanelRefs.length,
|
||||
)} reference a panel that no longer exists.`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -71,15 +94,33 @@ function JsonEditorDrawer({
|
||||
rootClassName={styles.root}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
<div className={styles.footerStatus}>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
{danglingWarning && (
|
||||
<Typography.Text
|
||||
className={styles.danglingWarning}
|
||||
data-testid="json-editor-dangling-warning"
|
||||
>
|
||||
{danglingWarning}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{missingRefWarning && (
|
||||
<Typography.Text
|
||||
className={styles.danglingWarning}
|
||||
data-testid="json-editor-missing-ref-warning"
|
||||
>
|
||||
{missingRefWarning}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.footerActions}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@@ -48,11 +48,13 @@ function hookValue(
|
||||
validity: { valid: true, lineCount: 3 },
|
||||
isDirty: true,
|
||||
isSaving: false,
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
format: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
apply: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
} as ReturnType<typeof useJsonEditor>;
|
||||
}
|
||||
|
||||
describe('JsonEditorDrawer', () => {
|
||||
@@ -81,6 +83,34 @@ describe('JsonEditorDrawer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('warns about dangling panels, and hides the warning when there are none', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
|
||||
);
|
||||
const { rerender } = render(
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
|
||||
);
|
||||
expect(screen.getByTestId('json-editor-dangling-warning')).toHaveTextContent(
|
||||
'2 panels not present in any section',
|
||||
);
|
||||
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ danglingPanelIds: [] }));
|
||||
rerender(
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('json-editor-dangling-warning'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns about layout refs to missing panels', () => {
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ missingPanelRefs: ['ghost'] }));
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
expect(
|
||||
screen.getByTestId('json-editor-missing-ref-warning'),
|
||||
).toHaveTextContent('1 layout item reference a panel that no longer exists');
|
||||
});
|
||||
|
||||
it('shows the error line and message when invalid', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { DashboardtypesDashboardSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { findPanelLayoutIssues } from '../danglingPanels';
|
||||
|
||||
const grid = (refs: string[]): unknown => ({
|
||||
kind: 'Grid',
|
||||
spec: { items: refs.map((r) => ({ content: { $ref: r } })) },
|
||||
});
|
||||
|
||||
// Cast a loose fixture to the spec type — the helper is defensive against the
|
||||
// untrusted, hand-edited JSON it runs on.
|
||||
const spec = (value: unknown): DashboardtypesDashboardSpecDTO =>
|
||||
value as DashboardtypesDashboardSpecDTO;
|
||||
|
||||
describe('findPanelLayoutIssues', () => {
|
||||
it('flags nothing when panels and layouts agree', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {}, b: {} },
|
||||
layouts: [grid(['#/spec/panels/a', '#/spec/panels/b'])],
|
||||
}),
|
||||
),
|
||||
).toStrictEqual({ danglingPanelIds: [], missingPanelRefs: [] });
|
||||
});
|
||||
|
||||
it('lists panels placed in no layout as dangling', () => {
|
||||
const result = findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {}, b: {}, c: {} },
|
||||
layouts: [grid(['#/spec/panels/a'])],
|
||||
}),
|
||||
);
|
||||
expect(result.danglingPanelIds.sort()).toStrictEqual(['b', 'c']);
|
||||
expect(result.missingPanelRefs).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('treats a removed/empty layout as orphaning every panel', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(
|
||||
spec({ panels: { a: {}, b: {} }, layouts: [] }),
|
||||
).danglingPanelIds.sort(),
|
||||
).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('lists layout refs to a panel that no longer exists as missing', () => {
|
||||
const result = findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {} },
|
||||
layouts: [grid(['#/spec/panels/a', '#/spec/panels/ghost'])],
|
||||
}),
|
||||
);
|
||||
expect(result.danglingPanelIds).toStrictEqual([]);
|
||||
expect(result.missingPanelRefs).toStrictEqual(['ghost']);
|
||||
});
|
||||
|
||||
it('handles empty / malformed specs', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(spec({ panels: {}, layouts: [] })),
|
||||
).toStrictEqual({
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
});
|
||||
expect(findPanelLayoutIssues(undefined)).toStrictEqual({
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -203,4 +203,47 @@ describe('useJsonEditor', () => {
|
||||
rerender({ isOpen: true });
|
||||
expect(result.current.draft).toBe(serialized);
|
||||
});
|
||||
|
||||
it('reports panels not placed in any layout as dangling', () => {
|
||||
const withDangling = {
|
||||
...dashboard,
|
||||
spec: { ...dashboard.spec, panels: { p1: {} }, layouts: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({
|
||||
dashboard: withDangling,
|
||||
isOpen: true,
|
||||
onApplied: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.danglingPanelIds).toStrictEqual(['p1']);
|
||||
expect(result.current.missingPanelRefs).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('reports layout refs to panels that no longer exist as missing', () => {
|
||||
const withMissing = {
|
||||
...dashboard,
|
||||
spec: {
|
||||
...dashboard.spec,
|
||||
panels: {},
|
||||
layouts: [
|
||||
{
|
||||
kind: 'Grid',
|
||||
spec: { items: [{ content: { $ref: '#/spec/panels/ghost' } }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({
|
||||
dashboard: withMissing,
|
||||
isOpen: true,
|
||||
onApplied: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.missingPanelRefs).toStrictEqual(['ghost']);
|
||||
expect(result.current.danglingPanelIds).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { extractPanelIdFromRef } from '../../utils';
|
||||
|
||||
export interface PanelLayoutIssues {
|
||||
// Panels defined in `spec.panels` that no layout places — they render nowhere.
|
||||
danglingPanelIds: string[];
|
||||
// Panel ids a layout item references that no longer exist in `spec.panels`.
|
||||
missingPanelRefs: string[];
|
||||
}
|
||||
|
||||
const referencedPanelIds = (
|
||||
layouts: DashboardtypesLayoutDTO[],
|
||||
): Set<string> => {
|
||||
const referenced = new Set<string>();
|
||||
layouts.forEach((layout) => {
|
||||
if (layout?.kind !== 'Grid') {
|
||||
return;
|
||||
}
|
||||
(layout.spec?.items ?? []).forEach((item) => {
|
||||
const id = extractPanelIdFromRef(item?.content?.$ref);
|
||||
if (id) {
|
||||
referenced.add(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
return referenced;
|
||||
};
|
||||
|
||||
// The two ways a hand-edited spec can desync panels and layouts: a panel with no
|
||||
// layout slot (renders nowhere), or a layout slot pointing at a panel that was
|
||||
// removed (broken reference). Guarded for untrusted, user-edited JSON.
|
||||
export function findPanelLayoutIssues(
|
||||
spec: DashboardtypesDashboardSpecDTO | undefined,
|
||||
): PanelLayoutIssues {
|
||||
const panels = spec?.panels ?? {};
|
||||
const panelIds = Object.keys(panels);
|
||||
const referenced = referencedPanelIds(spec?.layouts ?? []);
|
||||
|
||||
return {
|
||||
danglingPanelIds: panelIds.filter((id) => !referenced.has(id)),
|
||||
missingPanelRefs: [...referenced].filter((id) => !(id in panels)),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { updateDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTO,
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { dashboardToUpdatable } from './dashboardToUpdatable';
|
||||
import { findPanelLayoutIssues } from './danglingPanels';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
export interface JsonValidity {
|
||||
@@ -28,6 +32,10 @@ interface Result {
|
||||
validity: JsonValidity;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
// Panel ids in the draft's `spec.panels` referenced by no layout — orphaned.
|
||||
danglingPanelIds: string[];
|
||||
// Panel ids a layout references that are missing from the draft's `spec.panels`.
|
||||
missingPanelRefs: string[];
|
||||
format: () => void;
|
||||
reset: () => void;
|
||||
apply: () => Promise<void>;
|
||||
@@ -109,6 +117,23 @@ export function useJsonEditor({
|
||||
|
||||
const isDirty = draft !== appliedText;
|
||||
|
||||
const { danglingPanelIds, missingPanelRefs } = useMemo<{
|
||||
danglingPanelIds: string[];
|
||||
missingPanelRefs: string[];
|
||||
}>(() => {
|
||||
if (!validity.valid) {
|
||||
return { danglingPanelIds: [], missingPanelRefs: [] };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(draft) as {
|
||||
spec?: DashboardtypesDashboardSpecDTO;
|
||||
};
|
||||
return findPanelLayoutIssues(parsed.spec);
|
||||
} catch {
|
||||
return { danglingPanelIds: [], missingPanelRefs: [] };
|
||||
}
|
||||
}, [draft, validity.valid]);
|
||||
|
||||
const format = useCallback((): void => {
|
||||
try {
|
||||
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
|
||||
@@ -138,7 +163,7 @@ export function useJsonEditor({
|
||||
refetch();
|
||||
onApplied();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
showErrorModal(toAPIError(error as Parameters<typeof toAPIError>[0]));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -159,6 +184,8 @@ export function useJsonEditor({
|
||||
validity,
|
||||
isDirty,
|
||||
isSaving,
|
||||
danglingPanelIds,
|
||||
missingPanelRefs,
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
|
||||
Reference in New Issue
Block a user