From 5b004d1efc83792870ebe5c50ab286ad84fcce15 Mon Sep 17 00:00:00 2001 From: Ashwin Bhatkal Date: Sat, 25 Jul 2026 08:49:19 +0530 Subject: [PATCH 1/2] refactor(dashboard): store system icons as SVG assets with a safe image resolver (#12259) * feat(dashboard-v2): add file-based dashboard icons and logos with a safe image resolver * refactor(dashboard-v2): render dashboard icons via the safe resolver * fix(dashboard-v2): clamp dashboard icon size to 16px * fix(dashboard-v2): reject invalid dashboard image before saving * fix(dashboard-v2): validate dashboard image server-side * test(dashboard-v2): stub the Vite-only icon glob for jest and align image fixtures * test(dashboard-v2): update patch test for image validation * refactor(dashboard-v2): hoist dashboard image consts/regex and tidy validation tests * refactor(dashboard-v2): drop the validateImage doc comment --- frontend/__mocks__/iconAssetsMock.ts | 11 ++ frontend/jest.config.ts | 3 + frontend/src/assets/Icons/avocado.svg | 7 ++ frontend/src/assets/Icons/bagel.svg | 47 ++++++++ frontend/src/assets/Icons/basketball.svg | 8 ++ frontend/src/assets/Icons/bow.svg | 11 ++ frontend/src/assets/Icons/carousel-horse.svg | 39 +++++++ frontend/src/assets/Icons/cheese.svg | 17 +++ frontend/src/assets/Icons/cookie.svg | 42 ++++++++ frontend/src/assets/Icons/crane.svg | 14 +++ frontend/src/assets/Icons/dartboard.svg | 15 +++ frontend/src/assets/Icons/drum.svg | 21 ++++ frontend/src/assets/Icons/fries.svg | 28 +++++ frontend/src/assets/Icons/hamburger.svg | 33 ++++++ frontend/src/assets/Icons/headphones.svg | 27 +++++ frontend/src/assets/Icons/motorcycle.svg | 25 +++++ frontend/src/assets/Icons/ninja.svg | 100 ++++++++++++++++++ frontend/src/assets/Icons/orange.svg | 14 +++ frontend/src/assets/Icons/police-car.svg | 21 ++++ frontend/src/assets/Icons/siren.svg | 15 +++ .../DashboardInfo/DashboardInfo.module.scss | 5 + .../__tests__/useJsonEditor.test.tsx | 6 +- .../JsonEditorDrawer/useJsonEditor.ts | 20 +++- .../DashboardPageToolbar/index.tsx | 4 +- .../DashboardImagePicker.tsx | 37 +++---- .../DashboardSettings/Overview/index.tsx | 7 +- .../DashboardSettings/Overview/utils.ts | 1 - .../DashboardPageBreadcrumbs.module.scss | 8 ++ .../DashboardPageBreadcrumbs.tsx | 4 +- .../DashboardContainer/dashboardIcons.test.ts | 79 ++++++++++++++ .../DashboardContainer/dashboardIcons.ts | 89 ++++++++++++++++ .../DashboardContainer/iconAssets.ts | 39 +++++++ .../DashboardContainer/index.tsx | 4 +- .../components/DashboardRow/DashboardRow.tsx | 4 +- .../NewDashboardModal/BlankDashboardPanel.tsx | 4 +- .../NewDashboardModal/ImportJsonPanel.tsx | 14 +++ pkg/types/dashboardtypes/perses_dashboard.go | 29 +++++ .../perses_dashboard_image_test.go | 34 ++++++ .../perses_dashboard_patch_test.go | 9 +- 39 files changed, 853 insertions(+), 42 deletions(-) create mode 100644 frontend/__mocks__/iconAssetsMock.ts create mode 100644 frontend/src/assets/Icons/avocado.svg create mode 100644 frontend/src/assets/Icons/bagel.svg create mode 100644 frontend/src/assets/Icons/basketball.svg create mode 100644 frontend/src/assets/Icons/bow.svg create mode 100644 frontend/src/assets/Icons/carousel-horse.svg create mode 100644 frontend/src/assets/Icons/cheese.svg create mode 100644 frontend/src/assets/Icons/cookie.svg create mode 100644 frontend/src/assets/Icons/crane.svg create mode 100644 frontend/src/assets/Icons/dartboard.svg create mode 100644 frontend/src/assets/Icons/drum.svg create mode 100644 frontend/src/assets/Icons/fries.svg create mode 100644 frontend/src/assets/Icons/hamburger.svg create mode 100644 frontend/src/assets/Icons/headphones.svg create mode 100644 frontend/src/assets/Icons/motorcycle.svg create mode 100644 frontend/src/assets/Icons/ninja.svg create mode 100644 frontend/src/assets/Icons/orange.svg create mode 100644 frontend/src/assets/Icons/police-car.svg create mode 100644 frontend/src/assets/Icons/siren.svg create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/dashboardIcons.test.ts create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/dashboardIcons.ts create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/iconAssets.ts create mode 100644 pkg/types/dashboardtypes/perses_dashboard_image_test.go diff --git a/frontend/__mocks__/iconAssetsMock.ts b/frontend/__mocks__/iconAssetsMock.ts new file mode 100644 index 00000000000..66c9930f302 --- /dev/null +++ b/frontend/__mocks__/iconAssetsMock.ts @@ -0,0 +1,11 @@ +// Test stub for the Vite-only icon glob module: `import.meta.glob` can't be +// parsed by jest, so every test that transitively imports it is redirected here +// (see moduleNameMapper in jest.config.ts). Provides a minimal name → URL map so +// the resolver stays deterministic under test. +export const ICON_URLS: Record = { + 'eight-ball': 'mock-eight-ball-url', +}; + +export const LOGO_URLS: Record = { + 'aws-dark': 'mock-logo-url', +}; diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 4ec1ba01638..9b9fcd2f989 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -15,6 +15,9 @@ const config: Config.InitialOptions = { moduleNameMapper: { '\\.(png|jpg|jpeg|gif|svg|webp|avif|ico|bmp|tiff)$': '/__mocks__/fileMock.ts', + // The icon glob module uses `import.meta.glob` (Vite-only); jest can't parse + // it, so redirect any import of it to a stub. + '(^|/)iconAssets$': '/__mocks__/iconAssetsMock.ts', '^@/(.*)$': '/src/$1', '\\.(css|less|scss)$': '/__mocks__/cssMock.ts', '\\.module\\.mjs$': '/__mocks__/cssMock.ts', diff --git a/frontend/src/assets/Icons/avocado.svg b/frontend/src/assets/Icons/avocado.svg new file mode 100644 index 00000000000..a2d89a06a44 --- /dev/null +++ b/frontend/src/assets/Icons/avocado.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/Icons/bagel.svg b/frontend/src/assets/Icons/bagel.svg new file mode 100644 index 00000000000..9fb5fdaf1cd --- /dev/null +++ b/frontend/src/assets/Icons/bagel.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/basketball.svg b/frontend/src/assets/Icons/basketball.svg new file mode 100644 index 00000000000..ab352c8c63c --- /dev/null +++ b/frontend/src/assets/Icons/basketball.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/assets/Icons/bow.svg b/frontend/src/assets/Icons/bow.svg new file mode 100644 index 00000000000..92c20ab6695 --- /dev/null +++ b/frontend/src/assets/Icons/bow.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/carousel-horse.svg b/frontend/src/assets/Icons/carousel-horse.svg new file mode 100644 index 00000000000..71f79f68f4d --- /dev/null +++ b/frontend/src/assets/Icons/carousel-horse.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/cheese.svg b/frontend/src/assets/Icons/cheese.svg new file mode 100644 index 00000000000..47a1e3dc88e --- /dev/null +++ b/frontend/src/assets/Icons/cheese.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/cookie.svg b/frontend/src/assets/Icons/cookie.svg new file mode 100644 index 00000000000..29dd764aab1 --- /dev/null +++ b/frontend/src/assets/Icons/cookie.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/crane.svg b/frontend/src/assets/Icons/crane.svg new file mode 100644 index 00000000000..f44bf82fd9a --- /dev/null +++ b/frontend/src/assets/Icons/crane.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/dartboard.svg b/frontend/src/assets/Icons/dartboard.svg new file mode 100644 index 00000000000..5c9df780c22 --- /dev/null +++ b/frontend/src/assets/Icons/dartboard.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/drum.svg b/frontend/src/assets/Icons/drum.svg new file mode 100644 index 00000000000..bc8dab14961 --- /dev/null +++ b/frontend/src/assets/Icons/drum.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/fries.svg b/frontend/src/assets/Icons/fries.svg new file mode 100644 index 00000000000..2eee333f2f2 --- /dev/null +++ b/frontend/src/assets/Icons/fries.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/hamburger.svg b/frontend/src/assets/Icons/hamburger.svg new file mode 100644 index 00000000000..47cd184bb49 --- /dev/null +++ b/frontend/src/assets/Icons/hamburger.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/headphones.svg b/frontend/src/assets/Icons/headphones.svg new file mode 100644 index 00000000000..4413edc37e0 --- /dev/null +++ b/frontend/src/assets/Icons/headphones.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/motorcycle.svg b/frontend/src/assets/Icons/motorcycle.svg new file mode 100644 index 00000000000..3ea21bc374f --- /dev/null +++ b/frontend/src/assets/Icons/motorcycle.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/ninja.svg b/frontend/src/assets/Icons/ninja.svg new file mode 100644 index 00000000000..09b7ba14cd8 --- /dev/null +++ b/frontend/src/assets/Icons/ninja.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/orange.svg b/frontend/src/assets/Icons/orange.svg new file mode 100644 index 00000000000..ce6b8abe451 --- /dev/null +++ b/frontend/src/assets/Icons/orange.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/police-car.svg b/frontend/src/assets/Icons/police-car.svg new file mode 100644 index 00000000000..cfa63919b73 --- /dev/null +++ b/frontend/src/assets/Icons/police-car.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Icons/siren.svg b/frontend/src/assets/Icons/siren.svg new file mode 100644 index 00000000000..3d72eec5e65 --- /dev/null +++ b/frontend/src/assets/Icons/siren.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/DashboardInfo/DashboardInfo.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/DashboardInfo/DashboardInfo.module.scss index 513bceed1dc..e8d311637fe 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/DashboardInfo/DashboardInfo.module.scss +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/DashboardInfo/DashboardInfo.module.scss @@ -8,6 +8,11 @@ .dashboardImage { flex-shrink: 0; + // Fixed 16px box (matches the breadcrumb icon); object-fit keeps a custom + // base64 icon from distorting or ballooning past it. + width: 16px; + height: 16px; + object-fit: contain; } .dashboardTitle { diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/JsonEditorDrawer/__tests__/useJsonEditor.test.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/JsonEditorDrawer/__tests__/useJsonEditor.test.tsx index d65d1774040..861290e6eb4 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/JsonEditorDrawer/__tests__/useJsonEditor.test.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/JsonEditorDrawer/__tests__/useJsonEditor.test.tsx @@ -34,7 +34,7 @@ const dashboard = { id: 'dash-1', name: 'My dashboard', schemaVersion: 'v6', - image: 'icon.png', + image: '/assets/Icons/eight-ball', tags: [{ key: 'env', value: 'prod' }], spec: { display: { name: 'My dashboard' }, @@ -77,7 +77,7 @@ describe('useJsonEditor', () => { 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(parsed.image).toBe('/assets/Icons/eight-ball'); expect(parsed.id).toBeUndefined(); expect(parsed.name).toBeUndefined(); expect(parsed.schemaVersion).toBeUndefined(); @@ -167,7 +167,7 @@ describe('useJsonEditor', () => { // preserved from the original dashboard (redacted from the editor) name: 'My dashboard', schemaVersion: 'v6', - image: 'icon.png', + image: '/assets/Icons/eight-ball', // edited via the draft spec: editedSpec, tags: editedTags, diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/JsonEditorDrawer/useJsonEditor.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/JsonEditorDrawer/useJsonEditor.ts index af2c1731f7c..18246a2b26b 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/JsonEditorDrawer/useJsonEditor.ts +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/JsonEditorDrawer/useJsonEditor.ts @@ -13,6 +13,7 @@ import { toAPIError } from 'utils/errorUtils'; import { dashboardToUpdatable } from './dashboardToUpdatable'; import { findPanelLayoutIssues } from './danglingPanels'; import { compactSpecLayouts } from '../../layoutCompaction'; +import { isValidDashboardImage } from '../../dashboardIcons'; import { useDashboardStore } from '../../store/useDashboardStore'; export interface JsonValidity { @@ -108,9 +109,9 @@ export function useJsonEditor({ const validity = useMemo(() => { const lineCount = draft.split('\n').length; + let parsed: { image?: unknown }; try { - JSON.parse(draft); - return { valid: true, lineCount }; + parsed = JSON.parse(draft); } catch (error) { const message = error instanceof Error ? error.message : 'Invalid JSON'; return { @@ -120,6 +121,21 @@ export function useJsonEditor({ message, }; } + // `image` only renders for a system icon / logo path or a base64 image, so + // reject anything else (URLs, markup, …) at save rather than persist it. + const { image } = parsed; + if ( + image !== undefined && + (typeof image !== 'string' || !isValidDashboardImage(image)) + ) { + return { + valid: false, + lineCount, + message: + '"image" must be an /assets/Icons/ or /assets/Logos/ path, or a base64 image data URI', + }; + } + return { valid: true, lineCount }; }, [draft]); const isDirty = draft !== appliedText; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/index.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/index.tsx index ab011104f7a..9d9a9adef2a 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/index.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardPageToolbar/index.tsx @@ -13,7 +13,7 @@ import type { DashboardtypesJSONPatchOperationDTO, GetDashboardV2200, } from 'api/generated/services/sigNoz.schemas'; -import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils'; +import { resolveDashboardImage } from 'pages/DashboardPageV2/DashboardContainer/dashboardIcons'; import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events'; import { useAppContext } from 'providers/App/App'; @@ -55,7 +55,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element { const title = dashboard.spec.display.name; const description = dashboard.spec.display.description ?? ''; - const image = dashboard.image || Base64Icons[0]; + const image = resolveDashboardImage(dashboard.image); const tags = useMemo( () => (dashboard.tags ?? []).map((t) => diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardSettings/Overview/DashboardImagePicker/DashboardImagePicker.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardSettings/Overview/DashboardImagePicker/DashboardImagePicker.tsx index 72da9641890..41a37488ed4 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardSettings/Overview/DashboardImagePicker/DashboardImagePicker.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardSettings/Overview/DashboardImagePicker/DashboardImagePicker.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo, useState } from 'react'; import { Select, SelectContent, @@ -6,13 +5,16 @@ import { SelectTrigger, } from '@signozhq/ui/select'; import cx from 'classnames'; - -import { Base64Icons } from '../utils'; +import { + resolveDashboardImage, + SYSTEM_ICON_PATHS, +} from 'pages/DashboardPageV2/DashboardContainer/dashboardIcons'; import styles from './DashboardImagePicker.module.scss'; interface Props { - // The selected image — one of the base64 icon data-URIs. + // The selected image — a system icon path (`/assets/Icons/`) or, for + // dashboards imported with a custom icon, a legacy base64 data URI. image: string; onChange: (value: string) => void; // Consumers set the trigger's border-radius (e.g. rounded-left when joined to @@ -21,28 +23,15 @@ interface Props { } // Icon picker shared by the dashboard-details settings and the create-dashboard -// modal so both choose from the same `Base64Icons` set. +// modal so both choose from the same system icon set. A custom/legacy value is +// kept as the first option so it stays selected and round-trips. function DashboardImagePicker({ image, 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(() => - 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], - ); + const isCustom = !!image && !SYSTEM_ICON_PATHS.includes(image); + const options = isCustom ? [image, ...SYSTEM_ICON_PATHS] : SYSTEM_ICON_PATHS; return (