Make a dashboard its widgets: drop the pages and sections nobody drew

This commit is contained in:
2026-08-25 12:30:22 +02:00
parent 2840cc8e2b
commit 7ff29ca939
17 changed files with 273 additions and 497 deletions
+6 -2
View File
@@ -124,7 +124,9 @@ async function captureRuns(page, dir) {
// watched on, showing finished ones. This is the seam the feature exists for.
const picked = await page
.getByTestId("run-link")
.evaluateAll((links) => links.slice(0, 2).map((a) => a.getAttribute("href")))
.evaluateAll((links) =>
links.slice(0, 2).map((a) => a.getAttribute("href")),
)
const ids = picked
.map((href) => (href || "").split("/").pop())
.filter(Boolean)
@@ -132,7 +134,9 @@ async function captureRuns(page, dir) {
const apiUrl = APP_URL.replace("//app.", "//api.")
const results = await page.evaluate(async (base) => {
const answer = await fetch(`${base}/api/v1/dashboards/`, {
headers: { Authorization: `Bearer ${localStorage.getItem("access_token")}` },
headers: {
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
},
})
if (!answer.ok) return null
const body = await answer.json()
+6 -121
View File
@@ -428,12 +428,12 @@ export const DashboardDef_InputSchema = {
title: 'Icon',
default: ''
},
pages: {
widgets: {
items: {
'$ref': '#/components/schemas/PageDef-Input'
'$ref': '#/components/schemas/WidgetDef'
},
type: 'array',
title: 'Pages'
title: 'Widgets'
},
settings: {
additionalProperties: {
@@ -496,12 +496,12 @@ export const DashboardDef_OutputSchema = {
title: 'Icon',
default: ''
},
pages: {
widgets: {
items: {
'$ref': '#/components/schemas/PageDef-Output'
'$ref': '#/components/schemas/WidgetDef'
},
type: 'array',
title: 'Pages'
title: 'Widgets'
},
settings: {
additionalProperties: {
@@ -538,11 +538,6 @@ export const DashboardSummarySchema = {
title: 'Title',
default: ''
},
page_count: {
type: 'integer',
title: 'Page Count',
default: 0
},
widget_count: {
type: 'integer',
title: 'Widget Count',
@@ -2048,66 +2043,6 @@ export const OAuthClientRegisterSchema = {
description: 'RFC 7591 dynamic client registration request.'
} as const;
export const PageDef_InputSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
icon: {
type: 'string',
title: 'Icon',
default: ''
},
sections: {
items: {
'$ref': '#/components/schemas/SectionDef-Input'
},
type: 'array',
title: 'Sections'
}
},
type: 'object',
required: ['id'],
title: 'PageDef',
description: 'One tab of a dashboard.'
} as const;
export const PageDef_OutputSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
icon: {
type: 'string',
title: 'Icon',
default: ''
},
sections: {
items: {
'$ref': '#/components/schemas/SectionDef-Output'
},
type: 'array',
title: 'Sections'
}
},
type: 'object',
required: ['id'],
title: 'PageDef',
description: 'One tab of a dashboard.'
} as const;
export const PairRequestSchema = {
properties: {
code: {
@@ -2676,56 +2611,6 @@ export const SecretValueSchema = {
title: 'SecretValue'
} as const;
export const SectionDef_InputSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
widgets: {
items: {
'$ref': '#/components/schemas/WidgetDef'
},
type: 'array',
title: 'Widgets'
}
},
type: 'object',
required: ['id'],
title: 'SectionDef',
description: 'A grid of widgets under a heading.'
} as const;
export const SectionDef_OutputSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
widgets: {
items: {
'$ref': '#/components/schemas/WidgetDef'
},
type: 'array',
title: 'Widgets'
}
},
type: 'object',
required: ['id'],
title: 'SectionDef',
description: 'A grid of widgets under a heading.'
} as const;
export const SeriesAnswerSchema = {
properties: {
metric: {
+2 -41
View File
@@ -116,7 +116,7 @@ export type DashboardDef_Input = {
canvas_width?: number;
canvas_height?: number;
icon?: string;
pages?: Array<PageDef_Input>;
widgets?: Array<WidgetDef>;
settings?: {
[key: string]: SettingDef;
};
@@ -134,7 +134,7 @@ export type DashboardDef_Output = {
canvas_width?: number;
canvas_height?: number;
icon?: string;
pages?: Array<PageDef_Output>;
widgets?: Array<WidgetDef>;
settings?: {
[key: string]: SettingDef;
};
@@ -153,7 +153,6 @@ export type DashboardsPublic = {
export type DashboardSummary = {
name: string;
title?: string;
page_count?: number;
widget_count?: number;
has_draft?: boolean;
version?: number;
@@ -750,26 +749,6 @@ export type OAuthClientRegister = {
token_endpoint_auth_method?: (string | null);
};
/**
* One tab of a dashboard.
*/
export type PageDef_Input = {
id: string;
title?: string;
icon?: string;
sections?: Array<SectionDef_Input>;
};
/**
* One tab of a dashboard.
*/
export type PageDef_Output = {
id: string;
title?: string;
icon?: string;
sections?: Array<SectionDef_Output>;
};
export type PairRequest = {
code: string;
};
@@ -947,24 +926,6 @@ export type SecretValue = {
value: string;
};
/**
* A grid of widgets under a heading.
*/
export type SectionDef_Input = {
id: string;
title?: string;
widgets?: Array<WidgetDef>;
};
/**
* A grid of widgets under a heading.
*/
export type SectionDef_Output = {
id: string;
title?: string;
widgets?: Array<WidgetDef>;
};
export type SeriesAnswer = {
metric: string;
lines?: Array<MetricSeries>;
@@ -68,32 +68,26 @@ type Block = {
/**
* The first page's widgets as one grid.
*
* Sections are separate grids on the real thing, each starting at its own row
* zero, so each is pushed down past the one before it to keep them apart here.
*/
function blocksOf(dashboard: DashboardDef_Output): {
blocks: Block[]
rows: number
} {
const blocks: Block[] = []
let offset = 0
for (const section of (dashboard.pages ?? [])[0]?.sections ?? []) {
let bottom = 0
for (const widget of section.widgets ?? []) {
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
blocks.push({
id: widget.id,
type: widget.type,
x: Math.max(0, x),
y: Math.max(0, y) + offset,
w: Math.max(1, w),
h: Math.max(1, h),
})
bottom = Math.max(bottom, Math.max(0, y) + Math.max(1, h))
}
offset += bottom
let rows = 0
for (const widget of dashboard.widgets ?? []) {
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
blocks.push({
id: widget.id,
type: widget.type,
x: Math.max(0, x),
y: Math.max(0, y),
w: Math.max(1, w),
h: Math.max(1, h),
})
rows = Math.max(rows, Math.max(0, y) + Math.max(1, h))
}
return { blocks, rows: Math.max(1, offset) }
return { blocks, rows: Math.max(1, rows) }
}
/**
@@ -59,14 +59,11 @@ import {
columnsOf,
type Dashboard,
DashboardView,
flatWidgets,
GRID_GAP,
isPlaced,
pagesOf,
placement,
rowHeightOf,
rowsOf,
sectionsOf,
widgetsOf,
} from "./DashboardView"
import { PanelRail } from "./PanelRail"
@@ -147,13 +144,7 @@ const INTERACTIVE =
"button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle, .widget-grip"
function nextId(dashboard: DashboardDef_Output, type: string): string {
const taken = new Set(
pagesOf(dashboard).flatMap((page) =>
sectionsOf(page).flatMap((section) =>
widgetsOf(section).map((widget) => widget.id),
),
),
)
const taken = new Set(widgetsOf(dashboard).map((widget) => widget.id))
let candidate = type
for (let i = 2; taken.has(candidate); i++) candidate = `${type}${i}`
return candidate
@@ -323,34 +314,10 @@ export function DashboardEditor({
// a save in flight is still "no unpublished changes" until it lands.
const hasDraft = Boolean(dashboard.has_draft)
const columns = columnsOf(draft)
const page = pagesOf(draft)[0]
// Every widget the panel shows, not just the first section's — a document
// written with several sections is one arrangement here, as it is there.
const widgets = page ? flatWidgets(page) : []
const widgets = widgetsOf(draft)
const updateWidgets = (next: WidgetDef[]) => {
if (!page) return
commit({
...draft,
// Only this page is rewritten: a document carrying pages the editor
// does not show round-trips them untouched.
pages: pagesOf(draft).map((candidate) =>
candidate.id !== page.id
? candidate
: {
...candidate,
// One canvas, one grid: what several sections were read as is
// written back as one, which is also what keeps their row
// offsets from being applied a second time on the next read.
sections: [
{
...(sectionsOf(candidate)[0] ?? { id: "main" }),
widgets: next,
},
],
},
),
})
commit({ ...draft, widgets: next })
}
const addWidget = (type: WidgetKind) => {
@@ -468,91 +435,91 @@ export function DashboardEditor({
/>
)
const body = !page ? (
<p className="text-sm text-muted-foreground">
This dashboard has no pages yet.
</p>
) : edit && widgets.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
Nothing on this page yet. Add a widget from the bar below.
</p>
) : stacked ? (
// One column at the viewport's width. Edit mode still picks a widget and
// opens its settings; only the arrangement is missing.
<div
data-look={root["data-look"]}
data-touch={root["data-touch"]}
data-palette={root["data-palette"]}
className={cn(
"h-full overflow-y-auto bg-background text-foreground",
root.className,
)}
style={root.style}
>
<DashboardView
const body =
edit && widgets.length === 0 ? (
<p
className="text-sm text-muted-foreground"
data-testid="dashboard-empty"
>
Nothing on this dashboard yet. Add a widget from the bar below.
</p>
) : stacked ? (
// One column at the viewport's width. Edit mode still picks a widget and
// opens its settings; only the arrangement is missing.
<div
data-look={root["data-look"]}
data-touch={root["data-touch"]}
data-palette={root["data-palette"]}
className={cn(
"h-full overflow-y-auto bg-background text-foreground",
root.className,
)}
style={root.style}
>
<DashboardView
dashboard={draft}
stacked
renderWidget={edit ? pickable : undefined}
/>
</div>
) : (
<CanvasSurface
dashboard={draft}
stacked
renderWidget={edit ? pickable : undefined}
/>
</div>
) : (
<CanvasSurface
dashboard={draft}
dots={edit}
rail={
railDashboards ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
// Editing one dashboard of a panel and editing its neighbour are
// the same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null
}
>
{(scale) =>
!edit ? (
<DashboardView dashboard={draft} rail={hasRail} />
) : (
<GridLayout
width={area.width}
layout={layout}
onLayoutChange={applyLayout}
positionStrategy={scaledStrategy(scale)}
gridConfig={{
cols: columns,
rowHeight: rowHeightOf(draft, hasRail),
margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0],
// The canvas is the constraint: nothing may be dragged off the
// panel it is being drawn for. Shared with the warning above, so
// the constraint and what it is warned about cannot disagree.
maxRows: rows,
}}
// Only the header moves a widget, so a slider under the cursor still
// slides and a switch still flips while the dashboard is being edited.
// The header is the handle, and a chart now draws its range
// picker there — so that one control is exempt, or picking a
// window would drag the tile instead.
dragConfig={{
handle: ".widget-grip",
cancel: "[data-testid=range-picker]",
}}
resizeConfig={{ handles: ["e", "s", "se"] }}
>
{widgets.map((widget) => (
<div key={widget.id}>{pickable(widget, true)}</div>
))}
</GridLayout>
)
}
</CanvasSurface>
)
dots={edit}
rail={
railDashboards ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
// Editing one dashboard of a panel and editing its neighbour are
// the same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null
}
>
{(scale) =>
!edit ? (
<DashboardView dashboard={draft} rail={hasRail} />
) : (
<GridLayout
width={area.width}
layout={layout}
onLayoutChange={applyLayout}
positionStrategy={scaledStrategy(scale)}
gridConfig={{
cols: columns,
rowHeight: rowHeightOf(draft, hasRail),
margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0],
// The canvas is the constraint: nothing may be dragged off the
// panel it is being drawn for. Shared with the warning above, so
// the constraint and what it is warned about cannot disagree.
maxRows: rows,
}}
// Only the header moves a widget, so a slider under the cursor still
// slides and a switch still flips while the dashboard is being edited.
// The header is the handle, and a chart now draws its range
// picker there — so that one control is exempt, or picking a
// window would drag the tile instead.
dragConfig={{
handle: ".widget-grip",
cancel: "[data-testid=range-picker]",
}}
resizeConfig={{ handles: ["e", "s", "se"] }}
>
{widgets.map((widget) => (
<div key={widget.id}>{pickable(widget, true)}</div>
))}
</GridLayout>
)
}
</CanvasSurface>
)
return (
<LookProvider dashboard={draft}>
@@ -1,12 +1,6 @@
import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import type {
DashboardDef_Output,
PageDef_Output,
Placement,
SectionDef_Output,
WidgetDef,
} from "@/client"
import type { DashboardDef_Output, Placement, WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
import "./dashboard.css"
@@ -68,9 +62,8 @@ export const RAIL_INSET = 72
* The generated client marks every list optional, because the server fills
* them in. These three keep that from spreading through the components.
*/
export const pagesOf = (dashboard: DashboardDef_Output) => dashboard.pages ?? []
export const sectionsOf = (page: PageDef_Output) => page.sections ?? []
export const widgetsOf = (section: SectionDef_Output) => section.widgets ?? []
export const widgetsOf = (dashboard: DashboardDef_Output) =>
dashboard.widgets ?? []
export const columnsOf = (dashboard: Dashboard) =>
dashboard.columns || DEFAULT_COLUMNS
@@ -293,52 +286,6 @@ export const isPlaced = (widgets: WidgetDef[]) =>
return x > 0 || y > 0
})
/**
* The page's widgets as one arrangement.
*
* Sections used to be drawn as separate grids stacked down the page, each
* starting at its own row zero, so a document written with several of them is
* read as one grid by pushing each below the one before it. That is what lets
* the editor and the panel show the same thing: the editor arranges one grid,
* and it now arranges all of them.
*
* An unarranged document is left alone — every widget still sits at 0,0 there,
* so there is no arrangement to keep apart and the grid auto-flows instead.
*
* ponytail: a section's own heading stops being drawn with it. No UI ever
* wrote one, so only a seeded document has any — restoring them means giving
* a group a box on the canvas, which is a widget, not a section.
*/
export function flatWidgets(page: PageDef_Output): WidgetDef[] {
const sections = sectionsOf(page)
const all = sections.flatMap(widgetsOf)
if (sections.length < 2 || !isPlaced(all)) return all
const flat: WidgetDef[] = []
let offset = 0
for (const section of sections) {
let bottom = 0
for (const widget of widgetsOf(section)) {
const placed = placement(widget)
const y = Math.max(0, placed.y ?? 0)
bottom = Math.max(bottom, y + Math.max(1, placed.h ?? 2))
flat.push(
offset === 0
? widget
: {
...widget,
layout: {
...(widget.layout ?? {}),
lg: { ...placed, y: y + offset },
},
},
)
}
offset += bottom
}
return flat
}
/**
* One page, drawn as the single grid the panel shows.
*
@@ -360,26 +307,14 @@ export function DashboardView({
/** Whether the panel carries a rail, which takes a column of the canvas. */
rail?: boolean
}) {
// ponytail: the first page only. A panel carries several whole dashboards
// — that is what the rail is for — so nothing writes a second page.
const page = pagesOf(dashboard)[0]
if (!page) {
return (
<p className="text-sm text-muted-foreground">
This dashboard has no pages yet.
</p>
)
}
const all = flatWidgets(page)
const all = widgetsOf(dashboard)
if (all.length === 0) {
return (
<p
className="text-sm text-muted-foreground"
data-testid="dashboard-empty"
>
Nothing on this page yet. Edit it to add a widget.
Nothing on this dashboard yet. Edit it to add a widget.
</p>
)
}
@@ -8,7 +8,7 @@ import {
runQueryOptions,
shortId,
} from "@/components/Runs/queries"
import { type Dashboard, flatWidgets, pagesOf } from "./DashboardView"
import { type Dashboard, widgetsOf } from "./DashboardView"
import {
type ContextLine,
type DataContext,
@@ -22,10 +22,9 @@ import {
* asking the server for its whole series would be a query nobody draws.
*/
function chartedNames(dashboard: Dashboard | undefined): string[] {
const page = pagesOf(dashboard ?? ({} as Dashboard))[0]
if (!page) return []
if (!dashboard) return []
const names = new Set<string>()
for (const widget of flatWidgets(page)) {
for (const widget of widgetsOf(dashboard)) {
if (widget.type !== "chart") continue
const config = (widget.config ?? {}) as {
series?: { message?: string }[]
@@ -217,8 +217,6 @@ function Dashboards() {
draft={dashboard.has_draft}
detail={`${dashboard.widget_count} widget${
dashboard.widget_count === 1 ? "" : "s"
} · ${dashboard.page_count} page${
dashboard.page_count === 1 ? "" : "s"
}`}
selecting={selection.selecting}
selected={selection.selected.includes(dashboard.name)}
+2 -2
View File
@@ -48,7 +48,7 @@ test.beforeAll(async ({ browser }) => {
dashboard.columns = 12
dashboard.canvas_width = 1920
dashboard.canvas_height = 1080
dashboard.pages[0].sections[0].widgets = WIDGETS
dashboard.widgets = WIDGETS
await api(page, `/dashboards/${dashboardName}`, {
method: "PUT",
data: dashboard,
@@ -65,7 +65,7 @@ async function placements(page: Page) {
const doc = await (
await api(page, `/dashboards/${dashboardName}?draft=true`)
).json()
const widgets = doc.pages[0].sections[0].widgets as {
const widgets = doc.widgets as {
id: string
layout: { lg: { x: number; y: number } }
}[]
+1 -1
View File
@@ -55,7 +55,7 @@ test.beforeAll(async ({ browser }) => {
const dashboard = await (
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
).json()
dashboard.pages[0].sections[0].widgets = [
dashboard.widgets = [
{
id: "lever",
type: "slider",
+1 -1
View File
@@ -174,7 +174,7 @@ test.beforeAll(async ({ browser }) => {
const dashboard = await (
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
).json()
dashboard.pages[0].sections[0].widgets = [
dashboard.widgets = [
{
id: "top",
type: "stat",
+1 -1
View File
@@ -51,7 +51,7 @@ test.beforeAll(async ({ browser }) => {
await api(page, `/dashboards/${name}`, { method: "POST" })
const doc = await (await api(page, `/dashboards/${name}?draft=true`)).json()
doc.settings = { look: { value: "glass" } }
doc.pages[0].sections[0].widgets = [
doc.widgets = [
{
id: "stat",
type: "stat",
+2 -2
View File
@@ -89,7 +89,7 @@ test.beforeAll(async ({ browser }) => {
// Two rows taller than the default panel: the tiles below already fill it,
// and a widget past the last row is clipped rather than drawn.
dashboard.canvas_height = 1400
dashboard.pages[0].sections[0].widgets = [
dashboard.widgets = [
{
id: "load",
type: "bar",
@@ -202,7 +202,7 @@ test.beforeAll(async ({ browser }) => {
const stack = await (
await api(page, `/dashboards/${stackName}`, { method: "POST" })
).json()
stack.pages[0].sections[0].widgets = [
stack.widgets = [
{
id: "split",
type: "bar",