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
+68 -39
View File
@@ -374,32 +374,57 @@ class WidgetDef(BaseModel):
return self return self
class SectionDef(BaseModel): def _placement(widget: dict[str, Any]) -> dict[str, Any]:
"""A grid of widgets under a heading.""" """Where a stored widget sits, by the widest breakpoint it names."""
layout = widget.get("layout") or {}
id: str for key in ("lg", "md", "sm"):
title: str = "" box = layout.get(key)
widgets: list[WidgetDef] = Field(default_factory=list) if isinstance(box, dict):
return dict(box)
@field_validator("id") return {}
@classmethod
def _check_id(cls, value: str) -> str:
return _validate_name(value)
class PageDef(BaseModel): def _flatten_pages(pages: list[Any]) -> list[dict[str, Any]]:
"""One tab of a dashboard.""" """The widgets of a document written as pages and sections.
id: str Only the first page: no UI ever wrote a second one, and a panel carries
title: str = "" several whole dashboards instead. Its sections are stacked into one grid
#: A lucide icon name, or empty. the way the viewer always drew them, so a document that placed its widgets
icon: str = "" keeps the arrangement it had rather than piling everything at row zero.
sections: list[SectionDef] = Field(default_factory=list) """
if not pages or not isinstance(pages[0], dict):
return []
sections = [s for s in (pages[0].get("sections") or []) if isinstance(s, dict)]
lists = [
[w for w in (s.get("widgets") or []) if isinstance(w, dict)] for s in sections
]
flat = [w for widgets in lists for w in widgets]
placed = any(
(_placement(w).get("x") or 0) > 0 or (_placement(w).get("y") or 0) > 0
for w in flat
)
if len(sections) < 2 or not placed:
return flat
@field_validator("id") stacked: list[dict[str, Any]] = []
@classmethod offset = 0
def _check_id(cls, value: str) -> str: for widgets in lists:
return _validate_name(value) bottom = 0
for widget in widgets:
box = _placement(widget)
y = max(0, int(box.get("y") or 0))
bottom = max(bottom, y + max(1, int(box.get("h") or 2)))
if offset:
widget = {
**widget,
"layout": {
**(widget.get("layout") or {}),
"lg": {**box, "y": y + offset},
},
}
stacked.append(widget)
offset += bottom
return stacked
class DashboardDef(BaseModel): class DashboardDef(BaseModel):
@@ -419,7 +444,11 @@ class DashboardDef(BaseModel):
#: A lucide icon name, drawn on the panel rail; empty falls back to two #: A lucide icon name, drawn on the panel rail; empty falls back to two
#: letters of the title. #: letters of the title.
icon: str = "" icon: str = ""
pages: list[PageDef] = Field(default_factory=list) #: One grid. Pages and sections were in the schema and never in the UI —
#: only the first page was ever read and its sections were drawn as one —
#: so a dashboard is its widgets, and several dashboards on one device is
#: what a panel is for.
widgets: list[WidgetDef] = Field(default_factory=list)
#: Settings the whole dashboard carries, by name — see ``SettingDef``. The #: Settings the whole dashboard carries, by name — see ``SettingDef``. The
#: one channel a dashboard consumes as a dashboard rather than as a set of #: one channel a dashboard consumes as a dashboard rather than as a set of
#: tiles, so a screen on a wall can be told things nobody standing at it #: tiles, so a screen on a wall can be told things nobody standing at it
@@ -431,6 +460,21 @@ class DashboardDef(BaseModel):
#: never stored — the draft file's existence is the only record of it. #: never stored — the draft file's existence is the only record of it.
has_draft: bool = False has_draft: bool = False
@model_validator(mode="before")
@classmethod
def _flatten(cls, data: Any) -> Any:
"""Read a document written as pages and sections as one grid.
Stored dashboards live in each installation's git repository, so the
old shape is normalised on the way in rather than migrated: an
untouched document keeps working, and the next save writes it flat.
"""
if isinstance(data, dict) and "widgets" not in data and "pages" in data:
pages = data.get("pages") or []
data = {k: v for k, v in data.items() if k != "pages"}
data["widgets"] = _flatten_pages(pages)
return data
@field_validator("name") @field_validator("name")
@classmethod @classmethod
def _check_name(cls, value: str) -> str: def _check_name(cls, value: str) -> str:
@@ -456,10 +500,6 @@ class DashboardDef(BaseModel):
) )
return self return self
@property
def widgets(self) -> list[WidgetDef]:
return [w for p in self.pages for s in p.sections for w in s.widgets]
@property @property
def setting_messages(self) -> list[str]: def setting_messages(self) -> list[str]:
"""Every message a bound setting reads. Empty for a static dashboard.""" """Every message a bound setting reads. Empty for a static dashboard."""
@@ -471,7 +511,6 @@ class DashboardSummary(BaseModel):
name: str name: str
title: str = "" title: str = ""
page_count: int = 0
widget_count: int = 0 widget_count: int = 0
has_draft: bool = False has_draft: bool = False
#: Of the working copy, so publishing from a list needs no second read. #: Of the working copy, so publishing from a list needs no second read.
@@ -533,7 +572,6 @@ class DashboardStore:
DashboardSummary( DashboardSummary(
name=defn.name, name=defn.name,
title=defn.title, title=defn.title,
page_count=len(defn.pages),
widget_count=len(defn.widgets), widget_count=len(defn.widgets),
has_draft=defn.has_draft, has_draft=defn.has_draft,
version=defn.version, version=defn.version,
@@ -747,7 +785,6 @@ def default_dashboard(name: str) -> DashboardDef:
return DashboardDef( return DashboardDef(
name=name, name=name,
title=name.replace("_", " ").capitalize(), title=name.replace("_", " ").capitalize(),
pages=[PageDef(id="main", title="Overview", sections=[SectionDef(id="main")])],
) )
@@ -840,13 +877,7 @@ def results_dashboard(flow: FlowDef) -> DashboardDef:
return DashboardDef( return DashboardDef(
name=results_name(flow.name), name=results_name(flow.name),
title=f"{flow.title or flow.name} results", title=f"{flow.title or flow.name} results",
pages=[ widgets=widgets,
PageDef(
id="main",
title="Results",
sections=[SectionDef(id="main", widgets=widgets)],
)
],
) )
@@ -865,9 +896,7 @@ __all__ = [
"DashboardStore", "DashboardStore",
"DashboardSummary", "DashboardSummary",
"DashboardsPublic", "DashboardsPublic",
"PageDef",
"Placement", "Placement",
"SectionDef",
"SettingDef", "SettingDef",
"WidgetDef", "WidgetDef",
"default_dashboard", "default_dashboard",
+2 -13
View File
@@ -243,7 +243,7 @@ def _dashboard_with(
) -> None: ) -> None:
"""A published dashboard carrying these widgets.""" """A published dashboard carrying these widgets."""
saved = _dashboard(client, headers, name) saved = _dashboard(client, headers, name)
saved["pages"] = [{"id": "main", "sections": [{"id": "main", "widgets": widgets}]}] saved["widgets"] = widgets
written = client.put(f"{DASHBOARDS}/{name}", headers=headers, json=saved) written = client.put(f"{DASHBOARDS}/{name}", headers=headers, json=saved)
assert written.status_code == 200, written.text assert written.status_code == 200, written.text
published = client.post( published = client.post(
@@ -706,23 +706,12 @@ def test_a_panels_socket_carries_only_what_it_draws(
) )
saved = _dashboard(client, superuser_token_headers, "panel_socket") saved = _dashboard(client, superuser_token_headers, "panel_socket")
saved["pages"] = [ saved["widgets"] = [
{
"id": "main",
"title": "Overview",
"sections": [
{
"id": "main",
"widgets": [
{ {
"id": "w1", "id": "w1",
"type": "stat", "type": "stat",
"config": {"message": "house.kitchen.temperature"}, "config": {"message": "house.kitchen.temperature"},
} }
],
}
],
}
] ]
written = client.put( written = client.put(
f"{DASHBOARDS}/panel_socket", headers=superuser_token_headers, json=saved f"{DASHBOARDS}/panel_socket", headers=superuser_token_headers, json=saved
+49 -22
View File
@@ -6,8 +6,6 @@ from fluksio.flow.dashboards import (
DashboardDef, DashboardDef,
DashboardNotFound, DashboardNotFound,
DashboardStore, DashboardStore,
PageDef,
SectionDef,
SettingDef, SettingDef,
WidgetDef, WidgetDef,
default_dashboard, default_dashboard,
@@ -40,7 +38,7 @@ def test_a_dashboard_survives_a_round_trip(store: DashboardStore):
read = store.read("house") read = store.read("house")
assert read.name == "house" assert read.name == "house"
assert [p.id for p in read.pages] == ["main"] assert read.widgets == []
assert read.version == saved.version assert read.version == saved.version
@@ -138,21 +136,11 @@ def test_the_deepest_chart_decides_how_much_past_is_kept(store: DashboardStore):
store.write( store.write(
DashboardDef( DashboardDef(
name="house", name="house",
pages=[
PageDef(
id="main",
sections=[
SectionDef(id="a", widgets=[chart("heating.temp", 400)]),
SectionDef(
id="b",
widgets=[ widgets=[
chart("heating.temp", 400),
chart("heating.temp", 900), chart("heating.temp", 900),
chart("solar.watts", 100), chart("solar.watts", 100),
], ],
),
],
)
],
) )
) )
@@ -163,14 +151,7 @@ def test_a_chart_cannot_ask_for_an_unbounded_series(store: DashboardStore):
store.write( store.write(
DashboardDef( DashboardDef(
name="house", name="house",
pages=[ widgets=[chart("heating.temp", 10**9)],
PageDef(
id="main",
sections=[
SectionDef(id="a", widgets=[chart("heating.temp", 10**9)])
],
)
],
) )
) )
@@ -514,3 +495,49 @@ def test_a_chart_of_runs_must_say_which_runs_and_which_metric():
runs_chart(metric="study.loss") runs_chart(metric="study.loss")
with pytest.raises(ValueError, match="between 1 and 5"): with pytest.raises(ValueError, match="between 1 and 5"):
runs_chart(metric="study.loss", flow="study", latest=9) runs_chart(metric="study.loss", flow="study", latest=9)
def test_a_document_written_as_pages_is_read_as_one_grid():
"""Stored dashboards live in each installation's repository.
So the old shape is normalised on the way in rather than migrated, and a
placed second section keeps its arrangement instead of piling onto the
first — which is how the viewer always drew it.
"""
old = {
"name": "house",
"pages": [
{
"id": "main",
"sections": [
{
"id": "a",
"widgets": [
{
"id": "top",
"type": "stat",
"layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}},
}
],
},
{
"id": "b",
"widgets": [
{
"id": "under",
"type": "stat",
"layout": {"lg": {"x": 0, "y": 1, "w": 3, "h": 2}},
}
],
},
],
}
],
}
read = DashboardDef.model_validate(old)
assert [w.id for w in read.widgets] == ["top", "under"]
# The first section is two rows deep, so the second one starts under it.
assert read.widgets[1].layout["lg"].y == 3
assert "pages" not in read.model_dump()
-12
View File
@@ -8,8 +8,6 @@ control or another flow, it pulses a node that did nothing at all.
from fluksio.flow.dashboards import ( from fluksio.flow.dashboards import (
DashboardDef, DashboardDef,
DashboardStore, DashboardStore,
PageDef,
SectionDef,
WidgetDef, WidgetDef,
) )
from fluksio.flow.events import EventBus from fluksio.flow.events import EventBus
@@ -104,12 +102,6 @@ def test_the_widgets_wired_into_a_flow_are_reported(tmp_path):
DashboardDef( DashboardDef(
name="panel", name="panel",
title="Panel", title="Panel",
pages=[
PageDef(
id="main",
sections=[
SectionDef(
id="main",
widgets=[ widgets=[
WidgetDef( WidgetDef(
id="setpoint", id="setpoint",
@@ -131,10 +123,6 @@ def test_the_widgets_wired_into_a_flow_are_reported(tmp_path):
), ),
], ],
) )
],
)
],
)
) )
bindings = store.bindings_for("house") bindings = store.bindings_for("house")
+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. // watched on, showing finished ones. This is the seam the feature exists for.
const picked = await page const picked = await page
.getByTestId("run-link") .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 const ids = picked
.map((href) => (href || "").split("/").pop()) .map((href) => (href || "").split("/").pop())
.filter(Boolean) .filter(Boolean)
@@ -132,7 +134,9 @@ async function captureRuns(page, dir) {
const apiUrl = APP_URL.replace("//app.", "//api.") const apiUrl = APP_URL.replace("//app.", "//api.")
const results = await page.evaluate(async (base) => { const results = await page.evaluate(async (base) => {
const answer = await fetch(`${base}/api/v1/dashboards/`, { 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 if (!answer.ok) return null
const body = await answer.json() const body = await answer.json()
+6 -121
View File
@@ -428,12 +428,12 @@ export const DashboardDef_InputSchema = {
title: 'Icon', title: 'Icon',
default: '' default: ''
}, },
pages: { widgets: {
items: { items: {
'$ref': '#/components/schemas/PageDef-Input' '$ref': '#/components/schemas/WidgetDef'
}, },
type: 'array', type: 'array',
title: 'Pages' title: 'Widgets'
}, },
settings: { settings: {
additionalProperties: { additionalProperties: {
@@ -496,12 +496,12 @@ export const DashboardDef_OutputSchema = {
title: 'Icon', title: 'Icon',
default: '' default: ''
}, },
pages: { widgets: {
items: { items: {
'$ref': '#/components/schemas/PageDef-Output' '$ref': '#/components/schemas/WidgetDef'
}, },
type: 'array', type: 'array',
title: 'Pages' title: 'Widgets'
}, },
settings: { settings: {
additionalProperties: { additionalProperties: {
@@ -538,11 +538,6 @@ export const DashboardSummarySchema = {
title: 'Title', title: 'Title',
default: '' default: ''
}, },
page_count: {
type: 'integer',
title: 'Page Count',
default: 0
},
widget_count: { widget_count: {
type: 'integer', type: 'integer',
title: 'Widget Count', title: 'Widget Count',
@@ -2048,66 +2043,6 @@ export const OAuthClientRegisterSchema = {
description: 'RFC 7591 dynamic client registration request.' description: 'RFC 7591 dynamic client registration request.'
} as const; } 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 = { export const PairRequestSchema = {
properties: { properties: {
code: { code: {
@@ -2676,56 +2611,6 @@ export const SecretValueSchema = {
title: 'SecretValue' title: 'SecretValue'
} as const; } 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 = { export const SeriesAnswerSchema = {
properties: { properties: {
metric: { metric: {
+2 -41
View File
@@ -116,7 +116,7 @@ export type DashboardDef_Input = {
canvas_width?: number; canvas_width?: number;
canvas_height?: number; canvas_height?: number;
icon?: string; icon?: string;
pages?: Array<PageDef_Input>; widgets?: Array<WidgetDef>;
settings?: { settings?: {
[key: string]: SettingDef; [key: string]: SettingDef;
}; };
@@ -134,7 +134,7 @@ export type DashboardDef_Output = {
canvas_width?: number; canvas_width?: number;
canvas_height?: number; canvas_height?: number;
icon?: string; icon?: string;
pages?: Array<PageDef_Output>; widgets?: Array<WidgetDef>;
settings?: { settings?: {
[key: string]: SettingDef; [key: string]: SettingDef;
}; };
@@ -153,7 +153,6 @@ export type DashboardsPublic = {
export type DashboardSummary = { export type DashboardSummary = {
name: string; name: string;
title?: string; title?: string;
page_count?: number;
widget_count?: number; widget_count?: number;
has_draft?: boolean; has_draft?: boolean;
version?: number; version?: number;
@@ -750,26 +749,6 @@ export type OAuthClientRegister = {
token_endpoint_auth_method?: (string | null); 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 = { export type PairRequest = {
code: string; code: string;
}; };
@@ -947,24 +926,6 @@ export type SecretValue = {
value: string; 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 = { export type SeriesAnswer = {
metric: string; metric: string;
lines?: Array<MetricSeries>; lines?: Array<MetricSeries>;
@@ -68,32 +68,26 @@ type Block = {
/** /**
* The first page's widgets as one grid. * 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): { function blocksOf(dashboard: DashboardDef_Output): {
blocks: Block[] blocks: Block[]
rows: number rows: number
} { } {
const blocks: Block[] = [] const blocks: Block[] = []
let offset = 0 let rows = 0
for (const section of (dashboard.pages ?? [])[0]?.sections ?? []) { for (const widget of dashboard.widgets ?? []) {
let bottom = 0
for (const widget of section.widgets ?? []) {
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget) const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
blocks.push({ blocks.push({
id: widget.id, id: widget.id,
type: widget.type, type: widget.type,
x: Math.max(0, x), x: Math.max(0, x),
y: Math.max(0, y) + offset, y: Math.max(0, y),
w: Math.max(1, w), w: Math.max(1, w),
h: Math.max(1, h), h: Math.max(1, h),
}) })
bottom = Math.max(bottom, Math.max(0, y) + Math.max(1, h)) rows = Math.max(rows, Math.max(0, y) + Math.max(1, h))
} }
offset += bottom return { blocks, rows: Math.max(1, rows) }
}
return { blocks, rows: Math.max(1, offset) }
} }
/** /**
@@ -59,14 +59,11 @@ import {
columnsOf, columnsOf,
type Dashboard, type Dashboard,
DashboardView, DashboardView,
flatWidgets,
GRID_GAP, GRID_GAP,
isPlaced, isPlaced,
pagesOf,
placement, placement,
rowHeightOf, rowHeightOf,
rowsOf, rowsOf,
sectionsOf,
widgetsOf, widgetsOf,
} from "./DashboardView" } from "./DashboardView"
import { PanelRail } from "./PanelRail" 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" "button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle, .widget-grip"
function nextId(dashboard: DashboardDef_Output, type: string): string { function nextId(dashboard: DashboardDef_Output, type: string): string {
const taken = new Set( const taken = new Set(widgetsOf(dashboard).map((widget) => widget.id))
pagesOf(dashboard).flatMap((page) =>
sectionsOf(page).flatMap((section) =>
widgetsOf(section).map((widget) => widget.id),
),
),
)
let candidate = type let candidate = type
for (let i = 2; taken.has(candidate); i++) candidate = `${type}${i}` for (let i = 2; taken.has(candidate); i++) candidate = `${type}${i}`
return candidate return candidate
@@ -323,34 +314,10 @@ export function DashboardEditor({
// a save in flight is still "no unpublished changes" until it lands. // a save in flight is still "no unpublished changes" until it lands.
const hasDraft = Boolean(dashboard.has_draft) const hasDraft = Boolean(dashboard.has_draft)
const columns = columnsOf(draft) const columns = columnsOf(draft)
const page = pagesOf(draft)[0] const widgets = widgetsOf(draft)
// 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 updateWidgets = (next: WidgetDef[]) => { const updateWidgets = (next: WidgetDef[]) => {
if (!page) return commit({ ...draft, widgets: next })
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,
},
],
},
),
})
} }
const addWidget = (type: WidgetKind) => { const addWidget = (type: WidgetKind) => {
@@ -468,13 +435,13 @@ export function DashboardEditor({
/> />
) )
const body = !page ? ( const body =
<p className="text-sm text-muted-foreground"> edit && widgets.length === 0 ? (
This dashboard has no pages yet. <p
</p> className="text-sm text-muted-foreground"
) : edit && widgets.length === 0 ? ( data-testid="dashboard-empty"
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty"> >
Nothing on this page yet. Add a widget from the bar below. Nothing on this dashboard yet. Add a widget from the bar below.
</p> </p>
) : stacked ? ( ) : stacked ? (
// One column at the viewport's width. Edit mode still picks a widget and // One column at the viewport's width. Edit mode still picks a widget and
@@ -1,12 +1,6 @@
import { motion } from "motion/react" import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import type { import type { DashboardDef_Output, Placement, WidgetDef } from "@/client"
DashboardDef_Output,
PageDef_Output,
Placement,
SectionDef_Output,
WidgetDef,
} from "@/client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import "./dashboard.css" import "./dashboard.css"
@@ -68,9 +62,8 @@ export const RAIL_INSET = 72
* The generated client marks every list optional, because the server fills * The generated client marks every list optional, because the server fills
* them in. These three keep that from spreading through the components. * them in. These three keep that from spreading through the components.
*/ */
export const pagesOf = (dashboard: DashboardDef_Output) => dashboard.pages ?? [] export const widgetsOf = (dashboard: DashboardDef_Output) =>
export const sectionsOf = (page: PageDef_Output) => page.sections ?? [] dashboard.widgets ?? []
export const widgetsOf = (section: SectionDef_Output) => section.widgets ?? []
export const columnsOf = (dashboard: Dashboard) => export const columnsOf = (dashboard: Dashboard) =>
dashboard.columns || DEFAULT_COLUMNS dashboard.columns || DEFAULT_COLUMNS
@@ -293,52 +286,6 @@ export const isPlaced = (widgets: WidgetDef[]) =>
return x > 0 || y > 0 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. * 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. */ /** Whether the panel carries a rail, which takes a column of the canvas. */
rail?: boolean rail?: boolean
}) { }) {
// ponytail: the first page only. A panel carries several whole dashboards const all = widgetsOf(dashboard)
// — 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)
if (all.length === 0) { if (all.length === 0) {
return ( return (
<p <p
className="text-sm text-muted-foreground" className="text-sm text-muted-foreground"
data-testid="dashboard-empty" 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> </p>
) )
} }
@@ -8,7 +8,7 @@ import {
runQueryOptions, runQueryOptions,
shortId, shortId,
} from "@/components/Runs/queries" } from "@/components/Runs/queries"
import { type Dashboard, flatWidgets, pagesOf } from "./DashboardView" import { type Dashboard, widgetsOf } from "./DashboardView"
import { import {
type ContextLine, type ContextLine,
type DataContext, type DataContext,
@@ -22,10 +22,9 @@ import {
* asking the server for its whole series would be a query nobody draws. * asking the server for its whole series would be a query nobody draws.
*/ */
function chartedNames(dashboard: Dashboard | undefined): string[] { function chartedNames(dashboard: Dashboard | undefined): string[] {
const page = pagesOf(dashboard ?? ({} as Dashboard))[0] if (!dashboard) return []
if (!page) return []
const names = new Set<string>() const names = new Set<string>()
for (const widget of flatWidgets(page)) { for (const widget of widgetsOf(dashboard)) {
if (widget.type !== "chart") continue if (widget.type !== "chart") continue
const config = (widget.config ?? {}) as { const config = (widget.config ?? {}) as {
series?: { message?: string }[] series?: { message?: string }[]
@@ -217,8 +217,6 @@ function Dashboards() {
draft={dashboard.has_draft} draft={dashboard.has_draft}
detail={`${dashboard.widget_count} widget${ detail={`${dashboard.widget_count} widget${
dashboard.widget_count === 1 ? "" : "s" dashboard.widget_count === 1 ? "" : "s"
} · ${dashboard.page_count} page${
dashboard.page_count === 1 ? "" : "s"
}`} }`}
selecting={selection.selecting} selecting={selection.selecting}
selected={selection.selected.includes(dashboard.name)} selected={selection.selected.includes(dashboard.name)}
+2 -2
View File
@@ -48,7 +48,7 @@ test.beforeAll(async ({ browser }) => {
dashboard.columns = 12 dashboard.columns = 12
dashboard.canvas_width = 1920 dashboard.canvas_width = 1920
dashboard.canvas_height = 1080 dashboard.canvas_height = 1080
dashboard.pages[0].sections[0].widgets = WIDGETS dashboard.widgets = WIDGETS
await api(page, `/dashboards/${dashboardName}`, { await api(page, `/dashboards/${dashboardName}`, {
method: "PUT", method: "PUT",
data: dashboard, data: dashboard,
@@ -65,7 +65,7 @@ async function placements(page: Page) {
const doc = await ( const doc = await (
await api(page, `/dashboards/${dashboardName}?draft=true`) await api(page, `/dashboards/${dashboardName}?draft=true`)
).json() ).json()
const widgets = doc.pages[0].sections[0].widgets as { const widgets = doc.widgets as {
id: string id: string
layout: { lg: { x: number; y: number } } layout: { lg: { x: number; y: number } }
}[] }[]
+1 -1
View File
@@ -55,7 +55,7 @@ test.beforeAll(async ({ browser }) => {
const dashboard = await ( const dashboard = await (
await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
).json() ).json()
dashboard.pages[0].sections[0].widgets = [ dashboard.widgets = [
{ {
id: "lever", id: "lever",
type: "slider", type: "slider",
+1 -1
View File
@@ -174,7 +174,7 @@ test.beforeAll(async ({ browser }) => {
const dashboard = await ( const dashboard = await (
await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
).json() ).json()
dashboard.pages[0].sections[0].widgets = [ dashboard.widgets = [
{ {
id: "top", id: "top",
type: "stat", type: "stat",
+1 -1
View File
@@ -51,7 +51,7 @@ test.beforeAll(async ({ browser }) => {
await api(page, `/dashboards/${name}`, { method: "POST" }) await api(page, `/dashboards/${name}`, { method: "POST" })
const doc = await (await api(page, `/dashboards/${name}?draft=true`)).json() const doc = await (await api(page, `/dashboards/${name}?draft=true`)).json()
doc.settings = { look: { value: "glass" } } doc.settings = { look: { value: "glass" } }
doc.pages[0].sections[0].widgets = [ doc.widgets = [
{ {
id: "stat", id: "stat",
type: "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, // 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. // and a widget past the last row is clipped rather than drawn.
dashboard.canvas_height = 1400 dashboard.canvas_height = 1400
dashboard.pages[0].sections[0].widgets = [ dashboard.widgets = [
{ {
id: "load", id: "load",
type: "bar", type: "bar",
@@ -202,7 +202,7 @@ test.beforeAll(async ({ browser }) => {
const stack = await ( const stack = await (
await api(page, `/dashboards/${stackName}`, { method: "POST" }) await api(page, `/dashboards/${stackName}`, { method: "POST" })
).json() ).json()
stack.pages[0].sections[0].widgets = [ stack.widgets = [
{ {
id: "split", id: "split",
type: "bar", type: "bar",