Skip to content
Draft
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
326 changes: 326 additions & 0 deletions docs/dashboard-agent-api.md

Large diffs are not rendered by default.

13 changes: 8 additions & 5 deletions docs/tool-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ matching `If-None-Match` receives `304 Not Modified`.

The catalog is edition-specific:

- OSS returns externally callable OSS read tools.
- OSS returns externally callable OSS read tools and explicitly enabled dashboard write tools.
- Enterprise returns OSS read tools plus Enterprise read tools.
- Parseable Cloud uses the Enterprise server catalog and exposes the tools
enabled by that deployment.
Expand All @@ -63,10 +63,13 @@ Request:
}
```

`arguments` must satisfy the tool's `inputSchema`. The external registry
currently exposes only read-only and expensive-read tools. Mutation and
external-side-effect tools remain unavailable until Parseable supports a
server-verifiable confirmation flow bound to the session, tool, and arguments.
`arguments` must satisfy the tool's `inputSchema`. The external registry exposes read-only and expensive-read tools by default.
Dashboard mutation tools can be enabled with `P_DASHBOARD_AGENT_WRITES=true`;
normal dashboard RBAC, tenant and owner/admin policy, revisions and idempotency
still apply. This explicit standing operator policy applies only to dashboard
writes. Other mutation/external-side-effect tools remain unavailable until
Parseable supports their server-verifiable confirmation flows. See
[Agent-driven dashboards](dashboard-agent-api.md) for the full contract.

Successful and failed executions use the MCP `CallToolResult` shape:

Expand Down
81 changes: 79 additions & 2 deletions frontend/e2e/dashboards.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type Call = {
method: string;
body?: Record<string, unknown>;
params: URLSearchParams;
headers: Record<string, string>;
};
async function mockServer(
page: Page,
Expand Down Expand Up @@ -82,7 +83,7 @@ async function mockServer(
? Object.fromEntries(new URLSearchParams(request.postData()!))
: request.postDataJSON()
: undefined;
calls.push({ path, method, body, params: url.searchParams });
calls.push({ path, method, body, params: url.searchParams, headers: request.headers() });
if (path === options.denied)
return route.fulfill({ status: 403, body: 'Dashboard permission denied' });
if (path === '/api/v1/about')
Expand Down Expand Up @@ -146,7 +147,7 @@ async function mockServer(
json:
options.rows ??
[...documents.values()].map(
({ title, dataset, author, dashboardId, created, modified, tags, isFavorite }) => ({
({
title,
dataset,
author,
Expand All @@ -155,6 +156,17 @@ async function mockServer(
modified,
tags,
isFavorite,
revision,
}) => ({
title,
dataset,
author,
dashboardId,
created,
modified,
tags,
isFavorite,
revision,
}),
),
});
Expand All @@ -177,6 +189,13 @@ async function mockServer(
return route.fulfill({ json: doc });
}
if (method === 'DELETE') {
const document = documents.get(requestedId);
const observed = request.headers()['if-match'];
if (document?.revision && observed && observed !== `"${document.revision}"`)
return route.fulfill({
status: 412,
json: { error: { code: 'revision_conflict', message: 'Dashboard changed' } },
});
documents.delete(requestedId);
return route.fulfill({ status: 200, body: '' });
}
Expand Down Expand Up @@ -330,6 +349,64 @@ test('list favourites and rename preserve the full document; typed deletion rest
await expect(page.getByLabel('Search dashboards')).toBeFocused();
});

for (const location of ['list', 'detail'] as const) {
test(`conditional deletion from ${location} keeps agent edits until a fresh confirmation`, async ({
page,
}) => {
const source = { ...classic, revision: 'revision-1', tiles: [], variables: [], sections: [] };
const server = await mockServer(page, { document: source });
await page.goto(location === 'list' ? '/dashboards' : detail);
async function openDelete(title: string) {
await page
.getByRole('button', {
name: location === 'list' ? `Actions for ${title}` : 'Dashboard actions',
exact: true,
})
.click();
await page
.getByRole('menuitem', {
name: location === 'list' ? 'Delete' : 'Delete dashboard',
exact: true,
})
.click();
const dialog = page.getByRole('dialog', { name: 'Delete dashboard', exact: true });
await dialog.getByLabel('Confirmation name').fill(title);
return dialog;
}
const dialog = await openDelete(source.title);
server.change({ revision: 'revision-2', title: 'Agent saved a newer dashboard' });
await dialog.locator('[data-dialog-confirm]').click();
await expect(dialog.getByRole('alert')).toHaveText(
'This dashboard changed. Reload and review it before deleting.',
);
await expect(dialog).toBeVisible();
const deletes = () => server.calls.filter((call) => call.method === 'DELETE');
expect(deletes()).toHaveLength(1);
expect(deletes()[0].headers['if-match']).toBe('"revision-1"');
expect(server.current()).toMatchObject({
revision: 'revision-2',
title: 'Agent saved a newer dashboard',
});
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
if (location === 'list') {
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(
page.getByRole('link', { name: 'Agent saved a newer dashboard', exact: true }),
).toBeVisible();
} else {
await page.reload();
await expect(
page.getByRole('heading', { name: 'Agent saved a newer dashboard', exact: true }),
).toBeVisible();
}
const fresh = await openDelete('Agent saved a newer dashboard');
await fresh.locator('[data-dialog-confirm]').click();
await expect(fresh).not.toBeVisible();
expect(deletes()).toHaveLength(2);
expect(deletes()[1].headers['if-match']).toBe('"revision-2"');
});
}

test('demo PromQL tile add/edit/delete and variable selection', async ({ page }) => {
await demo(page, '/dashboards/01M4J000000000000000000002');
await expect(tile(page, 'Host load').getByRole('img')).toHaveAccessibleName(/4 series from/);
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/dashboards/DashboardDialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ export function DashboardDialogs({
error={dashboardError(mutation.error)}
onConfirm={() =>
void mutation.run(async () => {
await client.deleteDashboard(draft.dashboardId);
await client.deleteDashboard(draft.dashboardId, draft.revision);
if (mutation.isActive()) {
workspace.markSaved();
onDashboardDeleted();
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/features/dashboards/DashboardRefresh.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
import { createDemoDashboards } from '../../lib/demoDashboards';
import {
DashboardRefreshControls,
DashboardRefreshProvider,
savedRefreshInterval,
} from './DashboardRefresh';

afterEach(() => vi.unstubAllGlobals());
describe('saved dashboard refresh defaults', () => {
it('starts older documents and unsupported intervals with refresh off', () => {
for (const value of [undefined, null, -1, 1000, '30000', Number.NaN])
expect(savedRefreshInterval(value)).toBe(0);
for (const value of [0, 15_000, 30_000, 60_000, 300_000])
expect(savedRefreshInterval(value)).toBe(value);
});
it('reloads an explicitly saved default into the live refresh controls', async () => {
const client = createDemoDashboards();
const created = await client.createDashboard({ title: 'Refresh default', tiles: [] });
expect(created.refreshInterval).toBeUndefined();
await client.updateDashboard(created.dashboardId, { ...created, refreshInterval: 30_000 });
const reloaded = await client.getDashboard(created.dashboardId);
vi.stubGlobal('document', { hidden: false });
const markup = renderToStaticMarkup(
<DashboardRefreshProvider onRefresh={() => {}} savedInterval={reloaded.refreshInterval}>
<DashboardRefreshControls savedInterval={reloaded.refreshInterval} />
</DashboardRefreshProvider>,
);
expect(markup).toContain('<option value="30000" selected="">30s</option>');
expect(markup).not.toContain('Use as saved default');
});
});
20 changes: 18 additions & 2 deletions frontend/src/features/dashboards/DashboardRefresh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const refreshIntervals = [
{ value: 60_000, label: '1m' },
{ value: 300_000, label: '5m' },
];
export const savedRefreshInterval = (value: unknown): number =>
refreshIntervals.some((interval) => interval.value === value) ? Number(value) : 0;

type Tracker = {
track: <T>(
Expand Down Expand Up @@ -47,12 +49,15 @@ const Refresh = createContext<
/** One completion-based timer for the whole view, including variable queries. */
export function DashboardRefreshProvider({
onRefresh,
savedInterval = 0,
children,
}: {
onRefresh: () => void;
savedInterval?: number;
children: ReactNode;
}) {
const [interval, setRefreshInterval] = useState(0);
const [interval, setRefreshInterval] = useState(() => savedRefreshInterval(savedInterval));
useEffect(() => setRefreshInterval(savedRefreshInterval(savedInterval)), [savedInterval]);
const [hidden, setHidden] = useState(() => document.hidden);
const [activity, setActivity] = useState(0);
const [lastUpdated, setLastUpdated] = useState<number>();
Expand Down Expand Up @@ -161,7 +166,15 @@ export function useDashboardQuery<T>(
return useAsync(tracked);
}

export function DashboardRefreshControls({ onManualRefresh }: { onManualRefresh?: () => void }) {
export function DashboardRefreshControls({
onManualRefresh,
savedInterval = 0,
onSaveDefault,
}: {
onManualRefresh?: () => void;
savedInterval?: number;
onSaveDefault?: (interval: number) => void;
}) {
const state = useContext(Refresh);
const [announcement, setAnnouncement] = useState('');
const previouslyFailed = useRef(false);
Expand All @@ -185,6 +198,9 @@ export function DashboardRefreshControls({ onManualRefresh }: { onManualRefresh?
</option>
))}
</Select>
{onSaveDefault && state.interval !== savedRefreshInterval(savedInterval) && (
<Button onClick={() => onSaveDefault(state.interval)}>Use as saved default</Button>
)}
<Button
className="dashboard-refresh-button"
aria-disabled={state.busy || state.hidden}
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/features/dashboards/DashboardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function DashboardFields({ original }: { original: Dashboard }) {
);
}
return (
<DashboardRefreshProvider onRefresh={url.refresh}>
<DashboardRefreshProvider onRefresh={url.refresh} savedInterval={draft.refreshInterval}>
<div
ref={page}
className={`page dashboards-page${presentation.presenting ? ' dashboard-presentation' : ''}`}
Expand Down Expand Up @@ -215,6 +215,16 @@ function DashboardFields({ original }: { original: Dashboard }) {
}}
/>
<DashboardRefreshControls
savedInterval={draft.refreshInterval}
onSaveDefault={
editing
? (refreshInterval) =>
workspace.changeDraft(
(current) => ({ ...current, refreshInterval }),
'Refresh default updated. Save to keep this change.',
)
: undefined
}
onManualRefresh={() =>
workspace.announce(
`Refreshing ${(draft.tiles ?? []).length} ${(draft.tiles ?? []).length === 1 ? 'tile' : 'tiles'}.`,
Expand Down
22 changes: 18 additions & 4 deletions frontend/src/features/dashboards/DashboardsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import { useCollection } from '../../hooks/useCollection';
import { useMutation } from '../../hooks/useMutation';
import type { Dashboard, DashboardSummary } from '../../lib/types';
import { useLeaveGuard } from '../../hooks/useLeaveGuard';
import { dashboardError, duplicateDashboard, metadataPatch, patchConflicts } from './helpers';
import {
dashboardError,
duplicateDashboard,
metadataPatch,
patchConflicts,
saveDashboardDocument,
} from './helpers';
import { patchedDocument } from './draft';
import { useDashboardAccess } from './owner';
import { DashboardsList } from './DashboardsList';
Expand Down Expand Up @@ -55,14 +61,22 @@ export function DashboardsPage() {
}
/** Applies a metadata patch to the latest stored copy, so newer tiles and settings survive. */
async function save(loaded: Dashboard, patch: Partial<Dashboard>, overwrite = false) {
const latest = await client.getDashboard(loaded.dashboardId, readSignal());
const signal = readSignal();
// An overwrite is conditional on the copy shown in the conflict dialog.
const latest = overwrite ? loaded : await client.getDashboard(loaded.dashboardId, signal);
if (!mutation.isActive()) return;
if (!overwrite && patchConflicts(loaded, latest, patch)) {
setForm(undefined);
setConflict({ latest, patch });
return;
}
await client.updateDashboard(latest.dashboardId, patchedDocument(latest, patch));
const result = await saveDashboardDocument(client, patchedDocument(latest, patch), signal);
if (!mutation.isActive()) return;
if (result.conflict) {
setForm(undefined);
setConflict({ latest: result.conflict, patch });
return;
}
if (mutation.isActive()) {
markSaved();
completed('Dashboard updated.');
Expand Down Expand Up @@ -239,7 +253,7 @@ export function DashboardsPage() {
onConfirm={() =>
void mutation.run(async () => {
if (!deleting) return;
await client.deleteDashboard(deleting.dashboardId);
await client.deleteDashboard(deleting.dashboardId, deleting.revision);
if (mutation.isActive()) {
setDeleted(deleting.dashboardId);
setDeleting(undefined);
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/features/dashboards/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,21 @@ describe('dashboard helpers', () => {
getDashboard.mockRejectedValue(new Error('Denied'));
await expect(checkDashboardConflict(client, classic, signal)).rejects.toThrow('Denied');
});
it('detects revision changes even when modified timestamps are unchanged', async () => {
const loaded = { ...classic, revision: 'r1' };
const latest = { ...classic, revision: 'r2' };
const getDashboard = vi.fn().mockResolvedValue(latest);
const client = { getDashboard } as unknown as ParseableClient;
expect(await checkDashboardConflict(client, loaded, new AbortController().signal)).toEqual(
latest,
);
getDashboard.mockResolvedValue({ ...loaded, modified: 'Different display timestamp' });
expect(
await checkDashboardConflict(client, loaded, new AbortController().signal),
).toBeUndefined();
});
it('duplicates all extras with fresh tile ULIDs and no old metadata', () => {
const result = duplicateDashboard(classic, [classic.title]);
const result = duplicateDashboard({ ...classic, revision: 'r1' }, [classic.title]);
expect(result.title).toBe('Service health (Copy)');
expect(result.isFavorite).toBe(false);
expect(result.tiles.every((tile) => ulid(tile.tile_id))).toBe(true);
Expand All @@ -78,6 +91,7 @@ describe('dashboard helpers', () => {
expect(result.timeRange).toEqual(classic.timeRange);
expect(result.future).toEqual(classic.future);
expect(result.dashboardId).toBeUndefined();
expect(result).not.toHaveProperty('revision');
expect(result).not.toHaveProperty('tenantId');
expect(result.dashboardType).toBe('Report');
});
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/features/dashboards/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ParseableClient,
} from '../../lib/types';
import { createUlid } from '../../lib/ids';
import { ApiError } from '../../lib/apiError';

export const dashboardError = (error?: string) =>
error?.replace(/^Cannot perform this operation: /, '');
Expand All @@ -27,8 +28,22 @@ export function dashboardDate(value: string | null | undefined) {
return Number.isFinite(date) ? new Date(date).toISOString() : undefined;
}
function hasConflict(loaded: Dashboard, latest: Dashboard): boolean {
if (loaded.revision && latest.revision) return loaded.revision !== latest.revision;
return loaded.modified !== latest.modified;
}
/** A rejected conditional write is shown with the freshly observed server copy. */
export async function saveDashboardDocument(
client: ParseableClient,
document: Dashboard,
signal: AbortSignal,
): Promise<{ saved: Dashboard; conflict?: never } | { saved?: never; conflict: Dashboard }> {
try {
return { saved: await client.updateDashboard(document.dashboardId, document) };
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 412) throw error;
return { conflict: await client.getDashboard(document.dashboardId, signal) };
}
}
export async function checkDashboardConflict(
client: ParseableClient,
loaded: Dashboard,
Expand Down Expand Up @@ -80,7 +95,8 @@ export function duplicateDashboard(
titles: Iterable<string>,
): DashboardRequest & { tiles: DashboardTile[] } {
const body = structuredClone(source);
for (const key of ['dashboardId', 'author', 'created', 'modified', 'tenantId']) delete body[key];
for (const key of ['dashboardId', 'author', 'created', 'modified', 'tenantId', 'revision'])
delete body[key];
return {
...body,
title: availableTitle(`${source.title} (Copy)`, new Set(titles)),
Expand Down
Loading
Loading