Give both summaries a modified time, and the dashboard list a footprint

Home's "recently modified" order was a proxy — drafts first, then the
version counter, then the name. Both summaries now carry `updated_at`,
read as the mtime of the working copy: every write in the store commits
immediately, so a file's mtime is its commit time, and `git log -1 --
<path>` costs ~990ms across this instance's 15 documents (it walks the
history back to the last commit touching each one, so it is slowest for
the stalest) against ~1.8ms for the stats. No cache needed.

`DashboardSummary` also carries `footprint`: each widget as its type plus
the placement a panel resolves, which is the whole of what the mosaic
draws. That drops the document fetch per tile and with it the cap of
eight, past which tiles showed a name and nothing else. Verified against
this instance's 8 dashboards: the blocks are identical to what the old
client-side derivation produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk
This commit is contained in:
2026-09-06 15:26:48 +02:00
co-authored by Claude Opus 5
parent 8cb843eb25
commit ff612623a1
9 changed files with 271 additions and 86 deletions
+1
View File
@@ -291,6 +291,7 @@ def read_flows(controller: FlowControllerDep) -> Any:
paused=controller.is_paused(name),
quarantined=controller.is_quarantined(name),
version=definition.version,
updated_at=controller.store.updated_at(name),
)
)
return FlowsPublic(data=summaries, count=len(summaries))
+49 -1
View File
@@ -18,6 +18,7 @@ nobody has published yet, which no panel can be shown.
from __future__ import annotations
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
@@ -25,7 +26,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator
from fluksio.flow.messages import DType, qualify
from fluksio.flow.schemas import FlowDef, _validate_name
from fluksio.flow.store import FlowStore, StaleVersion
from fluksio.flow.store import FlowStore, StaleVersion, _updated_at
#: Sibling of the shared-node library, and likewise not a flow.
DASHBOARD_DIR = "_dashboards"
@@ -177,6 +178,18 @@ class Placement(BaseModel):
h: int = 2
class WidgetFootprint(Placement):
"""A widget reduced to the shape it draws: what it is, and where it sits.
The whole of what sketching a dashboard's outline needs — a mosaic tile
shades a block by kind and puts it in the grid, and reads nothing else. So
no id (the drawing has no use for one), no title, no config, and one
placement rather than the layout's breakpoint per screen size.
"""
type: WidgetType
class WidgetDef(BaseModel):
"""One tile: what it shows or does, and where it sits.
@@ -517,6 +530,25 @@ class DashboardDef(BaseModel):
"""Every message a bound setting reads. Empty for a static dashboard."""
return [s.message for s in self.settings.values() if s.message]
@property
def footprint(self) -> list[WidgetFootprint]:
"""Every widget's shape, at the width the dashboard is arranged for.
``lg`` is that arrangement; the narrower breakpoints are derived from
it, and a widget nobody has placed falls back to the grid defaults —
the same order a panel resolves a layout in.
"""
shapes = []
for widget in self.widgets:
at = (
widget.layout.get("lg")
or widget.layout.get("md")
or widget.layout.get("sm")
or Placement()
)
shapes.append(WidgetFootprint(type=widget.type, **at.model_dump()))
return shapes
class DashboardSummary(BaseModel):
"""A dashboard in a list, without its contents."""
@@ -530,6 +562,14 @@ class DashboardSummary(BaseModel):
has_draft: bool = False
#: Of the working copy, so publishing from a list needs no second read.
version: int = 1
#: The grid `footprint` is placed in — see ``DashboardDef.columns``.
columns: int = 12
#: Every widget's shape, so a list can draw the dashboard's outline
#: without a document read per tile.
footprint: list[WidgetFootprint] = Field(default_factory=list)
#: When the working copy was last written, so a list can be ordered by
#: what was worked on rather than by how often it has been saved.
updated_at: datetime | None = None
class DashboardsPublic(BaseModel):
@@ -591,6 +631,9 @@ class DashboardStore:
widget_count=len(defn.widgets),
has_draft=defn.has_draft,
version=defn.version,
columns=defn.columns,
footprint=defn.footprint,
updated_at=self.updated_at(name),
)
)
return summaries
@@ -606,6 +649,10 @@ class DashboardStore:
"""Are there unpublished changes to this dashboard?"""
return self._draft_file(name).exists()
def updated_at(self, name: str) -> datetime | None:
"""When this dashboard was last written, draft or published."""
return _updated_at([self._file(name), self._draft_file(name)])
def read(self, name: str, draft: bool = False) -> DashboardDef:
"""The published dashboard, or with ``draft`` the working copy."""
path = self._draft_file(name) if draft else self._file(name)
@@ -915,5 +962,6 @@ __all__ = [
"Placement",
"SettingDef",
"WidgetDef",
"WidgetFootprint",
"default_dashboard",
]
+4
View File
@@ -7,6 +7,7 @@ A flow is structure plus code: this module is the structure. Node logic for
from __future__ import annotations
import re
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
@@ -366,6 +367,9 @@ class FlowSummary(BaseModel):
quarantined: bool = False
#: Of the working copy, so publishing from a list needs no second read.
version: int = 1
#: When the working copy was last written, so a list can be ordered by
#: what was worked on rather than by how often it has been saved.
updated_at: datetime | None = None
class FlowsPublic(BaseModel):
+28
View File
@@ -19,6 +19,8 @@ import logging
import shutil
import subprocess
import threading
from collections.abc import Iterable
from datetime import UTC, datetime
from pathlib import Path
from fluksio.flow.schemas import FlowDef
@@ -84,6 +86,20 @@ class StaleVersion(ValueError):
return f"Flow '{self.name}' has changed since you loaded it"
def _updated_at(paths: Iterable[Path]) -> datetime | None:
"""When the newest of these files was written, or ``None`` if none exist.
A file's mtime, not the commit that recorded it: every write here commits
immediately, so the two are the same instant, and `git log -1 -- <path>`
would walk the history back to the last commit touching that path — 130ms
for a document nobody has edited in a week, against a `stat` for all of
them. Slowest for exactly the stalest documents is the wrong shape for a
list endpoint.
"""
stamps = [path.stat().st_mtime for path in paths if path.exists()]
return datetime.fromtimestamp(max(stamps), UTC) if stamps else None
def _same_content(left: FlowDef, right: FlowDef) -> bool:
"""Equal but for the version counter, which the server owns."""
return left.model_copy(update={"version": 0}) == right.model_copy(
@@ -386,6 +402,18 @@ class FlowStore:
drafts = self._draft_nodes_dir(name)
return drafts.exists() and any(drafts.glob("*.py"))
def updated_at(self, name: str) -> datetime | None:
"""When this flow was last written — its document or any node's source.
Node code is saved without touching the flow document, so a flow whose
last change was to a node body would otherwise look untouched.
"""
return _updated_at(
[self._flow_file(name), self._draft_file(name)]
+ list((self._flow_dir(name) / "nodes").glob("*.py"))
+ list(self._draft_nodes_dir(name).glob("*.py"))
)
def read_flow(self, name: str, draft: bool = False) -> FlowDef:
"""The published flow, or with ``draft`` the working copy."""
if draft:
+50
View File
@@ -1,11 +1,14 @@
"""Dashboards: documents beside the flows, and the values their widgets move."""
from datetime import UTC, datetime
import pytest
from fluksio.flow.dashboards import (
DashboardDef,
DashboardNotFound,
DashboardStore,
Placement,
SettingDef,
WidgetDef,
default_dashboard,
@@ -389,6 +392,53 @@ def test_a_querying_chart_keeps_no_ring():
assert widget.history_points == 0
def test_a_summary_carries_the_footprint_of_the_document(store: DashboardStore):
"""The mosaic draws from the list, so the shapes have to survive it."""
saved = store.write(
DashboardDef(
name="house",
columns=8,
widgets=[
WidgetDef(
id="temp",
type="chart",
layout={"lg": Placement(x=2, y=1, w=6, h=4)},
),
# Only a narrower breakpoint, and none at all: both resolve the
# way a panel resolves them.
WidgetDef(id="power", type="gauge", layout={"md": Placement(y=5, w=2)}),
WidgetDef(id="lamp", type="switch"),
],
)
)
(summary,) = store.list()
assert summary.columns == saved.columns
assert [(f.type, f.x, f.y, f.w, f.h) for f in summary.footprint] == [
("chart", 2, 1, 6, 4),
("gauge", 0, 5, 2, 2),
("switch", 0, 0, 3, 2),
]
def test_a_summary_carries_when_the_working_copy_was_written(store: DashboardStore):
published = store.write(default_dashboard("house"))
document = store.root / "house" / "dashboard.json"
assert store.list()[0].updated_at == datetime.fromtimestamp(
document.stat().st_mtime, UTC
)
# An unpublished edit is the working copy, so it is what the time is of.
store.write_draft(published.model_copy(update={"title": "Kitchen"}), 1)
draft = store.root / "house" / "dashboard.draft.json"
assert store.list()[0].updated_at == datetime.fromtimestamp(
draft.stat().st_mtime, UTC
)
def test_a_dashboard_is_cut_into_a_sane_number_of_columns():
for columns in (0, 49):
with pytest.raises(ValueError):
+20
View File
@@ -1,4 +1,6 @@
import os
import subprocess
from datetime import UTC, datetime
from pathlib import Path
import pytest
@@ -56,6 +58,24 @@ def test_saving_unchanged_content_does_nothing(store: FlowStore):
assert commit_count(store) == commits
def test_a_flow_is_modified_when_only_its_node_code_is(store: FlowStore):
"""Node code is saved without touching the flow document."""
store.write_flow(a_flow())
# Backdated so the answer can only have come from the node file.
os.utime(store.root / "heating" / "flow.json", (0, 0))
store.write_node_source(
"heating", "sensor", "def process():\n return {'a': 1}\n"
)
updated = store.updated_at("heating")
assert updated is not None and updated > datetime.fromtimestamp(0, UTC)
def test_a_flow_that_is_not_there_has_no_modified_time(store: FlowStore):
assert store.updated_at("nope") is None
def test_missing_flow_is_reported(store: FlowStore):
with pytest.raises(FlowNotFound):
store.read_flow("nope")
+75
View File
@@ -575,6 +575,30 @@ export const DashboardSummarySchema = {
type: 'integer',
title: 'Version',
default: 1
},
columns: {
type: 'integer',
title: 'Columns',
default: 12
},
footprint: {
items: {
'$ref': '#/components/schemas/WidgetFootprint'
},
type: 'array',
title: 'Footprint'
},
updated_at: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'Updated At'
}
},
type: 'object',
@@ -1303,6 +1327,18 @@ export const FlowSummarySchema = {
type: 'integer',
title: 'Version',
default: 1
},
updated_at: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'Updated At'
}
},
type: 'object',
@@ -3834,6 +3870,45 @@ The first two are a \`\`list\`\` message, the third a \`\`str\`\`, which is what
\`\`COLOR_DTYPES\`\` records and the check below holds a binding to.`
} as const;
export const WidgetFootprintSchema = {
properties: {
x: {
type: 'integer',
title: 'X',
default: 0
},
y: {
type: 'integer',
title: 'Y',
default: 0
},
w: {
type: 'integer',
title: 'W',
default: 3
},
h: {
type: 'integer',
title: 'H',
default: 2
},
type: {
type: 'string',
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'switch', 'slider', 'input', 'dropdown', 'color'],
title: 'Type'
}
},
type: 'object',
required: ['type'],
title: 'WidgetFootprint',
description: `A widget reduced to the shape it draws: what it is, and where it sits.
The whole of what sketching a dashboard's outline needs — a mosaic tile
shades a block by kind and puts it in the grid, and reads nothing else. So
no id (the drawing has no use for one), no title, no config, and one
placement rather than the layout's breakpoint per screen size.`
} as const;
export const WorkerInfoSchema = {
properties: {
name: {
+20
View File
@@ -158,6 +158,9 @@ export type DashboardSummary = {
widget_count?: number;
has_draft?: boolean;
version?: number;
columns?: number;
footprint?: Array<WidgetFootprint>;
updated_at?: (string | null);
};
export type DeadLetter = {
@@ -408,6 +411,7 @@ export type FlowSummary = {
paused?: boolean;
quarantined?: boolean;
version?: number;
updated_at?: (string | null);
};
export type fluksio__api__routes__dashboards__PublishRequest = {
@@ -1314,6 +1318,22 @@ export type WidgetDef = {
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
/**
* A widget reduced to the shape it draws: what it is, and where it sits.
*
* The whole of what sketching a dashboard's outline needs — a mosaic tile
* shades a block by kind and puts it in the grid, and reads nothing else. So
* no id (the drawing has no use for one), no title, no config, and one
* placement rather than the layout's breakpoint per screen size.
*/
export type WidgetFootprint = {
x?: number;
y?: number;
w?: number;
h?: number;
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
};
export type WorkerInfo = {
name: string;
labels?: Array<(string)>;
@@ -1,31 +1,13 @@
import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { LayoutDashboard } from "lucide-react"
import type {
DashboardDef_Output,
DashboardSummary,
Placement,
WidgetDef,
} from "@/client"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import type { DashboardSummary, WidgetFootprint } from "@/client"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
/** Columns a dashboard is cut into when its document does not say. */
const DEFAULT_COLUMNS = 12
/**
* How many tiles are worth a request of their own.
*
* ponytail: the list endpoint carries no placements, so a footprint means
* reading that dashboard's document — cheap for the handful an instance
* has, and shared with the editor's own cache. The ceiling is an instance
* with dozens: the tiles past this show their name and nothing else, and the
* fix would be a stored footprint on `DashboardSummary`.
*/
const PREVIEWS = 8
/** Widgets that draw a shape, and widgets that are controls. The rest read out. */
const GRAPHIC = new Set(["chart", "forecast", "bar", "gauge"])
const INPUT = new Set([
@@ -44,20 +26,7 @@ const shade = (type: string) =>
? "bg-primary/45"
: "bg-primary/20"
/**
* Where a widget sits, at the width a panel is arranged for.
*
* The same three-line fallback as `Dashboard/DashboardView`, written out again
* rather than imported: that module pulls the whole dashboard chunk, and this
* draws a schematic on a screen that shows no dashboards.
*/
const placement = (widget: WidgetDef): Placement => {
const layout = (widget.layout ?? {}) as Record<string, Placement>
return layout.lg ?? layout.md ?? layout.sm ?? {}
}
type Block = {
id: string
type: string
x: number
y: number
@@ -65,18 +34,17 @@ type Block = {
h: number
}
/** The dashboard's widgets as one grid. */
function blocksOf(dashboard: DashboardDef_Output): {
/** The dashboard's widget shapes as one grid. */
function blocksOf(footprint: WidgetFootprint[]): {
blocks: Block[]
rows: number
} {
const blocks: Block[] = []
let rows = 0
for (const widget of dashboard.widgets ?? []) {
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
for (const shape of footprint) {
const { x = 0, y = 0, w = 3, h = 2 } = shape
blocks.push({
id: widget.id,
type: widget.type,
type: shape.type,
x: Math.max(0, x),
y: Math.max(0, y),
w: Math.max(1, w),
@@ -94,10 +62,10 @@ function blocksOf(dashboard: DashboardDef_Output): {
* A footprint rather than a live render. Nothing here subscribes to a message
* or reads a value — recognising "the one with the big chart on the left" is
* the whole job, and it has to cost nothing on a screen that is not the
* dashboard.
* dashboard. The summary carries the shapes, so it costs no request either.
*/
function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
const { blocks, rows } = blocksOf(dashboard)
function Footprint({ dashboard }: { dashboard: DashboardSummary }) {
const { blocks, rows } = blocksOf(dashboard.footprint ?? [])
const columns = dashboard.columns || DEFAULT_COLUMNS
// Before the editor could place things, every widget was written at 0,0;
// honouring that would pile the whole page onto one cell.
@@ -123,11 +91,11 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
}, minmax(0, 1fr))`,
}}
>
{blocks.map((block) => {
{blocks.map((block, index) => {
const width = Math.min(columns, block.w)
return (
<span
key={block.id}
key={index}
className={cn("rounded-[2px]", shade(block.type))}
style={
placed
@@ -144,30 +112,15 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
)
}
/**
* One dashboard in the mosaic.
*
* Its own query, so the tiles fill in as their documents arrive instead of the
* whole panel waiting for the slowest of them.
*/
/** One dashboard in the mosaic. */
function Tile({
dashboard,
preview,
className,
}: {
dashboard: DashboardSummary
/** Read the document for a footprint, or settle for the name alone. */
preview: boolean
/** What the layout needs of it — a width, in the scrolling strip. */
className?: string
}) {
// The working copy, which is what the list itself is a summary of, so the
// preview shows what an editor would open rather than the last publish.
const { data, isPending } = useQuery({
...dashboardQueryOptions(dashboard.name, true),
enabled: preview,
})
return (
<Link
to="/dashboards/$name"
@@ -178,15 +131,7 @@ function Tile({
className,
)}
>
{preview && isPending ? (
<Skeleton className="aspect-video rounded-sm" />
) : data ? (
<Footprint dashboard={data} />
) : (
<div className="flex aspect-video items-center justify-center rounded-sm bg-muted">
<LayoutDashboard className="size-5 text-muted-foreground" />
</div>
)}
<Footprint dashboard={dashboard} />
<span className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{dashboard.title || dashboard.name}
@@ -201,22 +146,21 @@ function Tile({
)
}
/** When the working copy was last written, or the epoch for a summary without one. */
const modifiedAt = (summary: { updated_at?: string | null }) =>
summary.updated_at ? Date.parse(summary.updated_at) : 0
/**
* Which of two documents was worked on more recently.
*
* ponytail: neither `FlowSummary` nor `DashboardSummary` carries a modified
* time, so this reads the two things that come close — an unpublished edit is
* the one someone has open, and a higher version counter has been saved more
* often. An `updated_at` on both summaries is what would make it exact.
* Both summaries carry the modified time of the working copy, so this is the
* order it says it is. The name only settles a tie, which is two documents
* written in the same instant — what a seed does.
*/
export function byRecency<
T extends { name: string; has_draft?: boolean; version?: number },
T extends { name: string; updated_at?: string | null },
>(a: T, b: T): number {
return (
Number(b.has_draft ?? false) - Number(a.has_draft ?? false) ||
(b.version ?? 0) - (a.version ?? 0) ||
a.name.localeCompare(b.name)
)
return modifiedAt(b) - modifiedAt(a) || a.name.localeCompare(b.name)
}
/**
@@ -273,13 +217,8 @@ export function DashboardMosaic({
return (
<div className={container}>
{dashboards.map((dashboard, index) => (
<Tile
key={dashboard.name}
dashboard={dashboard}
preview={index < PREVIEWS}
className={tile}
/>
{dashboards.map((dashboard) => (
<Tile key={dashboard.name} dashboard={dashboard} className={tile} />
))}
</div>
)