Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/docs/api-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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-<name>` 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.

Expand Down
90 changes: 89 additions & 1 deletion frontend/e2e/dashboards.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
1 change: 1 addition & 0 deletions frontend/e2e/sql-chart-races.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/features/dashboards/DashboardDialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export function DashboardDialogs({
{dialog?.kind === 'tile' && (
<TileEditor
original={dialog.tile}
defaultDataset={draft.dataset ?? undefined}
tiles={draft.tiles ?? []}
sections={draft.sections}
variables={url.variables}
Expand Down Expand Up @@ -112,9 +113,9 @@ export function DashboardDialogs({
draftOnly
onDirtyChange={workspace.setEditorDirty}
onClose={closeEditor}
onSubmit={(title, tags, description) => {
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);
Expand Down
39 changes: 36 additions & 3 deletions frontend/src/features/dashboards/DashboardForm.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -17,16 +19,26 @@ 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(
typeof original?.description === 'string' ? original.description : '',
);
const reason = useId();
const dirty =
dataset !== (original?.dataset ?? '') ||
title !== (original?.title ?? '') ||
tags !== (original?.tags ?? []).join(', ') ||
description !== (typeof original?.description === 'string' ? original.description : '');
Expand Down Expand Up @@ -55,6 +67,7 @@ export function DashboardForm({
? (original?.tags ?? [])
: tagsFromText(tags),
description === original?.description ? description : description.trim(),
dataset || (original?.dataset == null ? original?.dataset : null),
);
}}
>
Expand All @@ -66,6 +79,26 @@ export function DashboardForm({
required
autoFocus
/>
<Select
label="Default dataset"
disabled={pending}
value={dataset}
onChange={(event) => setDataset(event.target.value)}
hint="New tiles start with this dataset. Existing tile queries keep their datasets."
>
<option value="">No default dataset</option>
{dataset && !datasetNames.includes(dataset) && <option value={dataset}>{dataset}</option>}
{datasetNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</Select>
<InlineError
error={
datasets.error ? 'Could not load datasets. The saved selection is retained.' : undefined
}
/>
<Input
label="Tags"
disabled={pending}
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/features/dashboards/DashboardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ function DashboardFields({ original }: { original: Dashboard }) {
</nav>
<PageHeader
title={draft.title}
description={text(draft.description) || undefined}
description={
[text(draft.description), draft.dataset ? `Dataset: ${draft.dataset}` : '']
.filter(Boolean)
.join(' · ') || undefined
}
actions={
<>
{writable && (
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/dashboards/DashboardsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export function DashboardsList({
</Link>
</div>
<div className="dashboards-tags">
{row.dataset && <Badge>Dataset: {row.dataset}</Badge>}
{[...new Set(row.tags ?? [])].map((value) => (
<Badge key={value}>{value}</Badge>
))}
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/features/dashboards/DashboardsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
22 changes: 20 additions & 2 deletions frontend/src/features/dashboards/TileEditor.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,6 +32,7 @@ import { resolveDataset, type VariableValues } from './variables';

export function TileEditor({
original,
defaultDataset,
sections,
tiles,
variables,
Expand All @@ -46,6 +47,7 @@ export function TileEditor({
onDirty,
}: {
original?: DashboardTile;
defaultDataset?: string;
sections?: unknown;
tiles: DashboardTile[];
variables: DashboardVariable[];
Expand All @@ -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<Set<string>>(() => new Set());
const onValidity = useCallback((key: string, valid: boolean) => {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading