From 7710ada15fe0bc44a39556c5803972a510ea7a3e Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:42:49 -0500 Subject: [PATCH] feat(dashboards): persist a default dataset for new tiles --- frontend/docs/api-contracts.md | 4 +- frontend/e2e/dashboards.spec.ts | 90 ++++++++++++++++++- frontend/e2e/sql-chart-races.spec.ts | 1 + .../features/dashboards/DashboardDialogs.tsx | 5 +- .../src/features/dashboards/DashboardForm.tsx | 39 +++++++- .../src/features/dashboards/DashboardView.tsx | 6 +- .../features/dashboards/DashboardsList.tsx | 1 + .../features/dashboards/DashboardsPage.tsx | 8 +- .../src/features/dashboards/TileEditor.tsx | 22 ++++- .../src/features/dashboards/helpers.test.ts | 16 ++++ frontend/src/features/dashboards/helpers.ts | 5 +- .../features/dashboards/importExport.test.ts | 9 ++ .../src/features/dashboards/importExport.ts | 3 + .../features/dashboards/importLocal.test.ts | 1 + .../src/features/dashboards/importLocal.ts | 1 + .../src/features/dashboards/tiles.test.ts | 7 ++ frontend/src/features/dashboards/tiles.ts | 4 +- frontend/src/features/sql/SqlChartPanel.tsx | 1 + frontend/src/lib/dashboardsContract.test.ts | 3 + frontend/src/lib/dashboardsContract.ts | 2 +- frontend/src/lib/demoDashboards.ts | 2 + frontend/src/lib/types.ts | 2 + src/users/dashboards.rs | 72 +++++++++++++++ 23 files changed, 288 insertions(+), 16 deletions(-) diff --git a/frontend/docs/api-contracts.md b/frontend/docs/api-contracts.md index 4de086ade..628b5c76f 100644 --- a/frontend/docs/api-contracts.md +++ b/frontend/docs/api-contracts.md @@ -131,6 +131,8 @@ The opt-in `e2e-live/alerts.spec.ts` ingests a unique OTLP metrics dataset, crea | `updateDashboard(id, body)` | `PUT /api/v1/dashboards/{id}` | Replaces the full document. A fresh GET compares `modified` before each save; a mismatch offers Reload or explicit Overwrite. | | `deleteDashboard(id)` | `DELETE /api/v1/dashboards/{id}` | Owner or admin; UI uses exact-title confirmation. | +Dashboards can store an optional `dataset` string in both the full document and list summary. The create/edit form calls it **Default dataset**: new tiles start with it, while existing tile datasets and queries remain unchanged. PromQL tiles inherit it only when metrics discovery confirms compatibility. Missing or null means no default; clearing the selection sends null. SQL chart creation and legacy local migration retain their source dataset. + Dashboard failures are plain text, including missing dashboards at **400**, unauthorized ownership at **400**, and RBAC at **403**. Only 401 triggers session recovery. Reads forward abort signals; responses pass shape guards. Bodies are never logged. The frontend never calls `add_tile` or query-parameter PUT. Favourites also use a complete GET/PUT with conflict detection. `author` is lowercase SHA-256 hex of the server user ID. `crypto.subtle` is preferred, with a tested dependency-free UTF-8 SHA-256 fallback for insecure non-localhost HTTP origins. Ownership controls editing; admins can delete another owner's dashboard but cannot update it. The latter is verified against Rust's strict body-update owner check and the disposable server: admin PUT of another user's dashboard returns 400 `Cannot perform this operation: Dashboard does not exist or you do not have permission to access it`. Reader roles have dashboard CRUD privileges, subject to ownership. @@ -162,7 +164,7 @@ PromQL reads also accept legacy `promqlQuery: {query, step, type}`, `promqlStep` The dashboard owns its URL time range (`range`, or absolute `start`/`end`), using 10m, 30m, 1h, 5h, 1d, 3d and custom. Save writes classic fixed form `{startTime:"1h",endTime:"now",type:"fixed",label:"Last 1 hour",interval:3600000,shiftInterval:1}` or `type:"custom"` with ISO bounds. Relative ranges remain anchored until a range change or Refresh, matching classic. Merely changing a variable selection never changes the document: `var-` URL parameters hold selected values. Text controls commit local drafts after 400ms, Enter or blur. Tile queries depend only on effective bounds, their referenced query/dataset variables and refresh; layout, unrelated edits and Save do not query. Stored variable definitions support `promql`, `promql_query`, `sql`, `list`, `text` and `dataset`; blank fields are omitted like classic. Definitions retain default values, while interpolation handles both `$name` and `${name}`, SQL literals/identifiers/dataset quoting and PromQL escaping/regex arrays. All uses `.*` for PromQL and `*` for list/SQL, comes first, and is selected when no default is stored. Only defined dashboard variable names count as references. Unknown tokens such as `$1`, `$__interval` and SQL/regex dollar literals pass through unchanged, matching classic. Dependencies wait for validated upstream URL values; cycle validation reads only consumed option-definition fields (queries, datasets and label filters), ignoring labels, defaults and extras. Unknown tokens in definitions are also allowed. Create alert requires concrete values and a single PromQL query; links interpolate dataset/query before handing off to Alerts. Classic builder alert links open SQL mode. Explore uses the existing SQL/Metrics URL contract. -Portable export exactly contains `{tags, variables, sections, tiles}` and removes each `tile_id`, matching classic (which omits title and timeRange). Import from file or paste accepts empty tiles and `promql_query`, preserves an input `timeRange` and unknown fields, strips `tenantId`, allocates fresh tile IDs and resolves append layouts to finite coordinates. Files over 5 MB are rejected before reading. Duplicate also strips `tenantId`; both preserve `dashboardType`, so a Report copy remains a Report. Each stored section has its own 12-column grid, after unsectioned and orphaned tiles, in section order. Vertical compaction resolves collisions and deletion gaps; moves choose neighbours from the displayed compacted `(y, x)` reading order, swap coordinates, then compact within that section. Display bounds never rewrite stored widths/heights. Null, missing or non-finite `y` appends within the section; `sections` itself stays unchanged. +Portable export contains `{tags, variables, sections, tiles}` plus `dataset` when a default is set and removes each `tile_id`, matching classic (which omits title and timeRange). Import from file or paste accepts empty tiles and `promql_query`, preserves an input `timeRange` and unknown fields, strips `tenantId`, allocates fresh tile IDs and resolves append layouts to finite coordinates. Files over 5 MB are rejected before reading. Duplicate also strips `tenantId`; both preserve `dashboardType`, so a Report copy remains a Report. Each stored section has its own 12-column grid, after unsectioned and orphaned tiles, in section order. Vertical compaction resolves collisions and deletion gaps; moves choose neighbours from the displayed compacted `(y, x)` reading order, swap coordinates, then compact within that section. Display bounds never rewrite stored widths/heights. Null, missing or non-finite `y` appends within the section; `sections` itself stays unchanged. Live mode uses only the server. Legacy browser dashboards are read solely for a per-identity migration notice. A pure planner resolves exact title collisions, three imports run at a time, each local dataset becomes a classic SQL histogram tile, and failures remain retryable. Local copies remain until the separate confirmed Remove local copies action. Demo mode owns independent in-memory SQL, PromQL/variables and non-owner fixtures per client, with matching validation messages and no localStorage persistence. diff --git a/frontend/e2e/dashboards.spec.ts b/frontend/e2e/dashboards.spec.ts index 24635fa59..c34e00978 100644 --- a/frontend/e2e/dashboards.spec.ts +++ b/frontend/e2e/dashboards.spec.ts @@ -145,8 +145,9 @@ async function mockServer( json: options.rows ?? [...documents.values()].map( - ({ title, author, dashboardId, created, modified, tags, isFavorite }) => ({ + ({ title, dataset, author, dashboardId, created, modified, tags, isFavorite }) => ({ title, + dataset, author, dashboardId, created, @@ -2217,3 +2218,90 @@ test('tile query failures stay in their tile with Retry and View query and no de page.getByRole('dialog', { name: 'Query for Errors', exact: true }).locator('pre'), ).toHaveText(String(classic.tiles[1].chartQuery)); }); + +test('dashboard default dataset survives reload, seeds new tiles and can be changed or cleared', async ({ + page, +}) => { + const server = await mockServer(page); + await page.goto('/dashboards'); + await page.getByRole('button', { name: 'Create dashboard', exact: true }).click(); + const form = page.getByRole('dialog', { name: 'Create dashboard', exact: true }); + await form.getByLabel('Dashboard title').fill('Dataset scoped dashboard'); + await form.getByLabel('Default dataset').selectOption('app-logs'); + await form.locator('[data-dialog-confirm]').click(); + await expect( + page.getByRole('heading', { name: 'Dataset scoped dashboard', exact: true }), + ).toBeVisible(); + expect(server.creates.at(-1)?.dataset).toBe('app-logs'); + await page.reload(); + await expect(page.getByText('Dataset: app-logs', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Add tile', exact: true }).click(); + const editor = page.getByRole('dialog', { name: 'Add tile', exact: true }); + await expect(editor.getByLabel('Dataset', { exact: true })).toHaveValue('app-logs'); + await editor.getByLabel('Tile title').fill('Scoped count'); + await editor + .getByRole('textbox', { name: 'SQL query', exact: true }) + .fill('SELECT COUNT(*) AS events FROM "app-logs"'); + await editor.getByLabel('Chart type').selectOption('query-value'); + await editor.getByRole('button', { name: 'Add tile', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('All changes saved', { exact: true })).toBeVisible(); + const originalTiles = structuredClone(server.writes.at(-1)?.tiles); + + await page.getByRole('button', { name: 'Dashboard actions', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Rename and tags', exact: true }).click(); + const metadata = page.getByRole('dialog', { name: 'Rename and tags', exact: true }); + await expect(metadata.getByLabel('Default dataset')).toHaveValue('app-logs'); + await metadata.getByLabel('Default dataset').selectOption('otel-metrics'); + await metadata.getByRole('button', { name: 'Apply', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('All changes saved', { exact: true })).toBeVisible(); + expect(server.writes.at(-1)?.dataset).toBe('otel-metrics'); + expect(server.writes.at(-1)?.tiles).toEqual(originalTiles); + await page.reload(); + await expect(page.getByText('Dataset: otel-metrics', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Add tile', exact: true }).click(); + await expect(editor.getByLabel('Dataset', { exact: true })).toHaveValue('otel-metrics'); + await editor.getByLabel('Query language').selectOption('promql'); + await expect(editor.getByLabel('Dataset', { exact: true })).toHaveValue('otel-metrics'); + await editor.getByLabel('Query language').selectOption('code'); + await expect(editor.getByLabel('Dataset', { exact: true })).toHaveValue('otel-metrics'); + await editor.getByRole('button', { name: 'Cancel', exact: true }).click(); + + await page.goto('/dashboards'); + const row = page + .getByRole('row') + .filter({ has: page.getByRole('link', { name: 'Dataset scoped dashboard', exact: true }) }); + await expect(row.getByText('Dataset: otel-metrics', { exact: true })).toBeVisible(); + await page + .getByRole('button', { name: 'Actions for Dataset scoped dashboard', exact: true }) + .click(); + await page.getByRole('menuitem', { name: 'Rename and tags', exact: true }).click(); + await metadata.getByLabel('Default dataset').selectOption(''); + await metadata.getByRole('button', { name: 'Update dashboard', exact: true }).click(); + await expect(metadata).not.toBeVisible(); + expect(server.writes.at(-1)?.dataset).toBeNull(); + expect(server.writes.at(-1)?.tiles).toEqual(originalTiles); + await page.getByRole('link', { name: 'Dataset scoped dashboard', exact: true }).click(); + await page.getByRole('button', { name: 'Add tile', exact: true }).click(); + await expect(editor.getByLabel('Dataset', { exact: true })).toHaveValue(''); +}); + +test('editing metadata retains a saved dashboard dataset unavailable to discovery', async ({ + page, +}) => { + const server = await mockServer(page, { + document: { ...structuredClone(classic), dataset: 'archived-logs' } as Dashboard, + denied: '/api/v1/logstream', + }); + await page.goto('/dashboards'); + await page.getByRole('button', { name: 'Actions for Service health', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Rename and tags', exact: true }).click(); + const form = page.getByRole('dialog', { name: 'Rename and tags', exact: true }); + await expect(form.getByLabel('Default dataset')).toHaveValue('archived-logs'); + await form.getByLabel('Dashboard title').fill('Archived health'); + await form.getByRole('button', { name: 'Update dashboard', exact: true }).click(); + await expect(page.getByRole('link', { name: 'Archived health', exact: true })).toBeVisible(); + expect(server.writes.at(-1)?.dataset).toBe('archived-logs'); + expect(server.writes.at(-1)?.tiles).toEqual(classic.tiles); +}); diff --git a/frontend/e2e/sql-chart-races.spec.ts b/frontend/e2e/sql-chart-races.spec.ts index 1e35a488c..07caf28d0 100644 --- a/frontend/e2e/sql-chart-races.spec.ts +++ b/frontend/e2e/sql-chart-races.spec.ts @@ -308,6 +308,7 @@ for (const target of ['new', 'existing'] as const) { } else { expect(write.body).toMatchObject({ title: 'SQL analysis', + dataset: 'app-logs', tags: [], variables: [], sections: [], diff --git a/frontend/src/features/dashboards/DashboardDialogs.tsx b/frontend/src/features/dashboards/DashboardDialogs.tsx index b58bb0de5..911426ef0 100644 --- a/frontend/src/features/dashboards/DashboardDialogs.tsx +++ b/frontend/src/features/dashboards/DashboardDialogs.tsx @@ -66,6 +66,7 @@ export function DashboardDialogs({ {dialog?.kind === 'tile' && ( { + onSubmit={(title, tags, description, dataset) => { workspace.changeDraft( - (current) => editDashboardMetadata(current, title, tags, description), + (current) => editDashboardMetadata(current, title, tags, description, dataset), 'Dashboard details applied. Save to keep this change.', ); workspace.setEditorDirty(false); diff --git a/frontend/src/features/dashboards/DashboardForm.tsx b/frontend/src/features/dashboards/DashboardForm.tsx index 98b0df6cf..8ab1b63eb 100644 --- a/frontend/src/features/dashboards/DashboardForm.tsx +++ b/frontend/src/features/dashboards/DashboardForm.tsx @@ -1,5 +1,7 @@ -import { useEffect, useId, useState } from 'react'; -import { Button, Dialog, Input, InlineError } from '../../components/ui'; +import { useCallback, useEffect, useId, useState } from 'react'; +import { Button, Dialog, Input, InlineError, Select } from '../../components/ui'; +import { useApp } from '../../app/AppProvider'; +import { useAsync } from '../../hooks/useAsync'; import type { Dashboard } from '../../lib/types'; import { dashboardError, tagsFromText } from './helpers'; @@ -17,9 +19,18 @@ export function DashboardForm({ pending: boolean; error?: string; onClose: () => void; - onSubmit: (title: string, tags: string[], description: string) => void; + onSubmit: ( + title: string, + tags: string[], + description: string, + dataset: string | null | undefined, + ) => void; onDirtyChange: (dirty: boolean) => void; }) { + const { client } = useApp(); + const datasets = useAsync(useCallback((signal) => client.listDatasets(signal), [client])); + const [dataset, setDataset] = useState(original?.dataset ?? ''); + const datasetNames = (datasets.data ?? []).map((entry) => entry.name); const [title, setTitle] = useState(original?.title ?? ''); const [tags, setTags] = useState((original?.tags ?? []).join(', ')); const [description, setDescription] = useState( @@ -27,6 +38,7 @@ export function DashboardForm({ ); const reason = useId(); const dirty = + dataset !== (original?.dataset ?? '') || title !== (original?.title ?? '') || tags !== (original?.tags ?? []).join(', ') || description !== (typeof original?.description === 'string' ? original.description : ''); @@ -55,6 +67,7 @@ export function DashboardForm({ ? (original?.tags ?? []) : tagsFromText(tags), description === original?.description ? description : description.trim(), + dataset || (original?.dataset == null ? original?.dataset : null), ); }} > @@ -66,6 +79,26 @@ export function DashboardForm({ required autoFocus /> + + {writable && ( diff --git a/frontend/src/features/dashboards/DashboardsList.tsx b/frontend/src/features/dashboards/DashboardsList.tsx index 746c6569c..dd812ddf9 100644 --- a/frontend/src/features/dashboards/DashboardsList.tsx +++ b/frontend/src/features/dashboards/DashboardsList.tsx @@ -126,6 +126,7 @@ export function DashboardsList({
+ {row.dataset && Dataset: {row.dataset}} {[...new Set(row.tags ?? [])].map((value) => ( {value} ))} diff --git a/frontend/src/features/dashboards/DashboardsPage.tsx b/frontend/src/features/dashboards/DashboardsPage.tsx index ba3d57f8d..42b9e6e66 100644 --- a/frontend/src/features/dashboards/DashboardsPage.tsx +++ b/frontend/src/features/dashboards/DashboardsPage.tsx @@ -174,15 +174,19 @@ export function DashboardsPage() { setForm(undefined); mutation.reset(); }} - onSubmit={(title, tags, description) => + onSubmit={(title, tags, description, dataset) => void mutation.run(async () => { if (form.original) - await save(form.original, metadataPatch(form.original, title, tags, description)); + await save( + form.original, + metadataPatch(form.original, title, tags, description, dataset), + ); else { const created = await client.createDashboard({ title, tags, description, + dataset, tiles: [], variables: [], sections: [], diff --git a/frontend/src/features/dashboards/TileEditor.tsx b/frontend/src/features/dashboards/TileEditor.tsx index 2f3aac3a5..b7646c23c 100644 --- a/frontend/src/features/dashboards/TileEditor.tsx +++ b/frontend/src/features/dashboards/TileEditor.tsx @@ -1,4 +1,4 @@ -import { useCallback, useId, useMemo, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; import { Button, Dialog, Input, Select } from '../../components/ui'; import { QueryState } from '../../components/explorer/QueryState'; import { useApp } from '../../app/AppProvider'; @@ -32,6 +32,7 @@ import { resolveDataset, type VariableValues } from './variables'; export function TileEditor({ original, + defaultDataset, sections, tiles, variables, @@ -46,6 +47,7 @@ export function TileEditor({ onDirty, }: { original?: DashboardTile; + defaultDataset?: string; sections?: unknown; tiles: DashboardTile[]; variables: DashboardVariable[]; @@ -60,7 +62,7 @@ export function TileEditor({ onDirty: (dirty: boolean) => void; }) { const { client } = useApp(); - const [initial] = useState(() => original ?? newTile(tiles)); + const [initial] = useState(() => original ?? newTile(tiles, defaultDataset)); const [draft, setDraft] = useState(() => structuredClone(initial)); const [invalidFields, setInvalidFields] = useState>(() => new Set()); const onValidity = useCallback((key: string, valid: boolean) => { @@ -134,6 +136,22 @@ export function TileEditor({ [client, promqlEnabled, isPromql], ), ); + useEffect(() => { + if ( + original || + !defaultDataset || + (isPromql && !metrics.data?.datasets.some((entry) => entry.name === defaultDataset)) + ) + return; + setDraft((current) => + !tileDatasets(current).some(Boolean) + ? { + ...current, + dbName: tileType(current) === 'promql' ? defaultDataset : [defaultDataset], + } + : current, + ); + }, [original, isPromql, defaultDataset, metrics.data]); const metadata = useMemo( () => metadataEnabled diff --git a/frontend/src/features/dashboards/helpers.test.ts b/frontend/src/features/dashboards/helpers.test.ts index 4a9f6283e..167f437fd 100644 --- a/frontend/src/features/dashboards/helpers.test.ts +++ b/frontend/src/features/dashboards/helpers.test.ts @@ -142,3 +142,19 @@ describe('dashboard helpers', () => { expect(dashboardRange(new URLSearchParams('start=bad&end=now'), classic.timeRange)).toBe('1h'); }); }); + +it('changes the dashboard dataset without rewriting existing tiles', () => { + const original = { ...classic, dataset: 'logs' }; + const updated = editDashboardMetadata( + original, + original.title, + original.tags, + original.description, + 'metrics', + ); + expect(updated.dataset).toBe('metrics'); + expect(updated.tiles).toEqual(original.tiles); + expect( + metadataPatch(original, original.title, original.tags, original.description, null), + ).toEqual({ dataset: null }); +}); diff --git a/frontend/src/features/dashboards/helpers.ts b/frontend/src/features/dashboards/helpers.ts index 855aead4b..fcea275d5 100644 --- a/frontend/src/features/dashboards/helpers.ts +++ b/frontend/src/features/dashboards/helpers.ts @@ -43,9 +43,11 @@ export function metadataPatch( title: string, tags: string[], description: string, + dataset: string | null | undefined = original.dataset, ): Partial { const currentDescription = typeof original.description === 'string' ? original.description : ''; return { + ...(dataset !== original.dataset ? { dataset } : {}), ...(title !== original.title ? { title } : {}), ...(JSON.stringify(tags) !== JSON.stringify(original.tags ?? []) ? { tags } : {}), ...(description !== currentDescription ? { description } : {}), @@ -56,8 +58,9 @@ export function editDashboardMetadata( title: string, tags: string[], description: string, + dataset: string | null | undefined = original.dataset, ): Dashboard { - return { ...original, ...metadataPatch(original, title, tags, description) }; + return { ...original, ...metadataPatch(original, title, tags, description, dataset) }; } /** True when someone else changed a field that `patch` also changes. */ export function patchConflicts(loaded: Dashboard, latest: Dashboard, patch: Partial) { diff --git a/frontend/src/features/dashboards/importExport.test.ts b/frontend/src/features/dashboards/importExport.test.ts index ae4901a67..a3f4f766f 100644 --- a/frontend/src/features/dashboards/importExport.test.ts +++ b/frontend/src/features/dashboards/importExport.test.ts @@ -3,6 +3,14 @@ import classic from './__fixtures__/classic.json'; import { exportDashboard, importDashboard } from './importExport'; import { ulid } from '../../lib/dashboardsContract'; describe('classic portable JSON', () => { + it('retains a default dataset through portable export and import', () => { + const exported = exportDashboard({ ...classic, dataset: 'application_logs' }); + const imported = importDashboard(JSON.stringify(exported), 'Imported'); + expect(imported.dataset).toBe('application_logs'); + expect(imported.tiles?.map((tile) => tile.dbName)).toEqual( + classic.tiles.map((tile) => tile.dbName), + ); + }); it('exports exactly the classic keys and removes only tile IDs', () => { const result = exportDashboard(classic); expect(Object.keys(result)).toEqual(['tags', 'variables', 'sections', 'tiles']); @@ -42,6 +50,7 @@ describe('classic portable JSON', () => { '{}', '{"tiles":[null]}', '{"tiles":[],"tags":"bad"}', + '{"tiles":[],"dataset":["logs"]}', '{"tiles":[],"variables":[{"type":"promql_query"}]}', ])('rejects invalid imports: %s', (json) => expect(() => importDashboard(json, 'Title')).toThrow(), diff --git a/frontend/src/features/dashboards/importExport.ts b/frontend/src/features/dashboards/importExport.ts index 537ed902b..20268adf7 100644 --- a/frontend/src/features/dashboards/importExport.ts +++ b/frontend/src/features/dashboards/importExport.ts @@ -7,6 +7,7 @@ import { sectionGroups } from './layout'; /** The classic portable export deliberately omits server metadata, title and timeRange. */ export function exportDashboard(dashboard: Dashboard) { return structuredClone({ + ...(dashboard.dataset ? { dataset: dashboard.dataset } : {}), tags: dashboard.tags ?? [], variables: dashboard.variables ?? [], sections: dashboard.sections ?? [], @@ -24,6 +25,8 @@ export function importDashboard(json: string, title: string): DashboardRequest { throw new Error('A dashboard must contain a tiles array (which may be empty).'); if (value.tags != null && !strings(value.tags)) throw new Error('Tags must be an array of strings.'); + if (value.dataset != null && typeof value.dataset !== 'string') + throw new Error('Default dataset must be a string.'); if (value.sections != null && !Array.isArray(value.sections)) throw new Error('Sections must be an array.'); if ( diff --git a/frontend/src/features/dashboards/importLocal.test.ts b/frontend/src/features/dashboards/importLocal.test.ts index dc5d1cd51..870caa6c9 100644 --- a/frontend/src/features/dashboards/importLocal.test.ts +++ b/frontend/src/features/dashboards/importLocal.test.ts @@ -30,6 +30,7 @@ describe('browser-local import', () => { expect(planLocalImport(inputs, titles, ids)).toEqual(plan); expect(localImportBody(plan.create[0], '01M4J000000000000000000041')).toMatchObject({ title: 'Title (3)', + dataset: 'app"logs', description: 'Keep description', tiles: [ { diff --git a/frontend/src/features/dashboards/importLocal.ts b/frontend/src/features/dashboards/importLocal.ts index b2d51499e..e87b1f4cb 100644 --- a/frontend/src/features/dashboards/importLocal.ts +++ b/frontend/src/features/dashboards/importLocal.ts @@ -32,6 +32,7 @@ export function localImportBody(item: ImportItem, tileId: string): DashboardRequ const { local, title } = item; return { title, + dataset: local.dataset, description: local.description, tags: [], variables: [], diff --git a/frontend/src/features/dashboards/tiles.test.ts b/frontend/src/features/dashboards/tiles.test.ts index 1aa6e7c18..3ccc44d6e 100644 --- a/frontend/src/features/dashboards/tiles.test.ts +++ b/frontend/src/features/dashboards/tiles.test.ts @@ -297,3 +297,10 @@ describe('classic tile helpers', () => { expect(sqlChart([], { tile_id: 'x' })).toMatchObject({ categorical: false, timestamps: [] }); }); }); + +it('starts new tiles with the dashboard default dataset and retains legacy empty defaults', () => { + expect(newTile([], 'application_logs').dbName).toEqual(['application_logs']); + expect(newTile([]).dbName).toEqual([]); + const builder = setQueryLanguage(newTile([], 'application_logs'), 'builder'); + expect(builder.dbName).toEqual(['application_logs']); +}); diff --git a/frontend/src/features/dashboards/tiles.ts b/frontend/src/features/dashboards/tiles.ts index 722490c23..116c6868d 100644 --- a/frontend/src/features/dashboards/tiles.ts +++ b/frontend/src/features/dashboards/tiles.ts @@ -115,7 +115,7 @@ export function chartConfig(chartType: string) { }, }; } -export function newTile(tiles: DashboardTile[]): DashboardTile { +export function newTile(tiles: DashboardTile[], dataset?: string): DashboardTile { return { tile_id: createUlid(), title: '', @@ -123,7 +123,7 @@ export function newTile(tiles: DashboardTile[]): DashboardTile { tileType: 'code', chartType: 'timeseries', chartQuery: '', - dbName: [], + dbName: dataset ? [dataset] : [], layout: appendLayout(tiles), config: chartConfig('timeseries'), }; diff --git a/frontend/src/features/sql/SqlChartPanel.tsx b/frontend/src/features/sql/SqlChartPanel.tsx index e08c4e3e4..f67061a1b 100644 --- a/frontend/src/features/sql/SqlChartPanel.tsx +++ b/frontend/src/features/sql/SqlChartPanel.tsx @@ -172,6 +172,7 @@ export function SqlChartPanel({ if (destination === 'new') result = await client.createDashboard({ title: title.trim(), + dataset, tiles: [tile], tags: [], variables: [], diff --git a/frontend/src/lib/dashboardsContract.test.ts b/frontend/src/lib/dashboardsContract.test.ts index 48317bd01..22e1d08ae 100644 --- a/frontend/src/lib/dashboardsContract.test.ts +++ b/frontend/src/lib/dashboardsContract.test.ts @@ -26,6 +26,9 @@ describe('dashboard wire guards', () => { expect(dashboard({ ...classic, tiles: null })).toBe(true); }); it('validates known metadata and required snake-case tile IDs', () => { + expect(dashboard({ ...classic, dataset: 'application_logs' })).toBe(true); + expect(dashboard({ ...classic, dataset: null })).toBe(true); + expect(dashboard({ ...classic, dataset: ['application_logs'] })).toBe(false); expect(dashboard({ ...classic, tiles: [{ tileId: classic.tiles[0].tile_id }] })).toBe(false); expect(dashboardSummary({ ...classic, tags: 'x' })).toBe(false); expect(dashboardSummary({ ...classic, isFavorite: 'true' })).toBe(false); diff --git a/frontend/src/lib/dashboardsContract.ts b/frontend/src/lib/dashboardsContract.ts index efb6cb42c..70f5795a5 100644 --- a/frontend/src/lib/dashboardsContract.ts +++ b/frontend/src/lib/dashboardsContract.ts @@ -11,7 +11,7 @@ export function dashboardSummary(value: unknown): value is DashboardSummary { object(value) && ulid(value.dashboardId) && typeof value.title === 'string' && - ['author', 'created', 'modified'].every((key) => optionalString(value[key])) && + ['author', 'created', 'modified', 'dataset'].every((key) => optionalString(value[key])) && (value.tags == null || strings(value.tags)) && (value.isFavorite == null || typeof value.isFavorite === 'boolean') ); diff --git a/frontend/src/lib/demoDashboards.ts b/frontend/src/lib/demoDashboards.ts index 81f56e4cd..5947901f1 100644 --- a/frontend/src/lib/demoDashboards.ts +++ b/frontend/src/lib/demoDashboards.ts @@ -171,6 +171,7 @@ export function createDemoDashboards(now = Date.now()): Pick ({ title, + ...(dataset == null ? {} : { dataset }), author, dashboardId, created, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 05b406e19..ab53a6427 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -372,6 +372,7 @@ export type ApiKey = { // intact and patch it; a narrower editor model must never become the PUT body. export type DashboardTile = { tile_id: string; [key: string]: unknown }; export type DashboardSummary = { + dataset?: string | null; dashboardId: string; title: string; author?: string | null; @@ -382,6 +383,7 @@ export type DashboardSummary = { [key: string]: unknown; }; export type DashboardRequest = { + dataset?: string | null; title: string; tiles?: DashboardTile[] | null; tags?: string[] | null; diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index 5e0446bb3..8b8bfde40 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -59,6 +59,13 @@ pub struct Tile { pub struct Dashboard { pub version: Option, pub title: String, + /// Default dataset for new tiles; existing tiles retain their own queries. + #[serde( + default, + deserialize_with = "deserialize_dataset", + skip_serializing_if = "Option::is_none" + )] + pub dataset: Option, pub author: Option, pub dashboard_id: Option, pub created: Option>, @@ -73,6 +80,19 @@ pub struct Dashboard { pub other_fields: Option>, } +// Dataset was previously an arbitrary flattened field. Ignore unsupported legacy +// shapes instead of dropping the entire dashboard when loading stored documents. +fn deserialize_dataset<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let value = Value::deserialize(deserializer)?; + Ok(value + .as_str() + .map(str::trim) + .filter(|dataset| !dataset.is_empty()) + .map(str::to_owned)) +} + impl MetastoreObject for Dashboard { fn get_object_path(&self) -> String { let root = self.tenant_id.as_deref().unwrap_or(""); @@ -121,6 +141,10 @@ impl Dashboard { serde_json::Value::String(self.title.clone()), ); + if let Some(dataset) = &self.dataset { + map.insert("dataset".to_string(), Value::String(dataset.clone())); + } + if let Some(author) = &self.author { map.insert( "author".to_string(), @@ -532,3 +556,51 @@ impl Dashboards { }) } } + +#[cfg(test)] +mod tests { + use super::Dashboard; + use serde_json::json; + + #[test] + fn dataset_survives_metadata_and_storage_round_trip() { + let mut dashboard: Dashboard = serde_json::from_value(json!({ + "title": "Application health", + "dataset": "application_logs", + "customSetting": { "enabled": true } + })) + .unwrap(); + dashboard.set_metadata("owner", None); + + let stored = serde_json::to_vec(&dashboard).unwrap(); + let loaded: Dashboard = serde_json::from_slice(&stored).unwrap(); + assert_eq!(loaded.dataset.as_deref(), Some("application_logs")); + assert_eq!(loaded.to_summary()["dataset"], "application_logs"); + assert_eq!( + serde_json::to_value(&loaded).unwrap()["customSetting"], + json!({ "enabled": true }) + ); + } + + #[test] + fn legacy_and_unbound_dashboards_need_no_dataset() { + for document in [ + json!({ "title": "Legacy dashboard" }), + json!({ "title": "Unbound dashboard", "dataset": null }), + json!({ "title": "Blank dataset", "dataset": " " }), + json!({ "title": "Legacy number", "dataset": 5 }), + json!({ "title": "Legacy object", "dataset": { "name": "logs" } }), + json!({ "title": "Legacy array", "dataset": ["logs"] }), + ] { + let dashboard: Dashboard = serde_json::from_value(document).unwrap(); + assert!(dashboard.dataset.is_none()); + assert!(!dashboard.to_summary().contains_key("dataset")); + assert!( + serde_json::to_value(&dashboard) + .unwrap() + .get("dataset") + .is_none() + ); + } + } +}