From ff612623a1222028262a639cba105d5470e8784a Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 6 Sep 2026 15:26:48 +0200 Subject: [PATCH] Give both summaries a modified time, and the dashboard list a footprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -- ` 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) Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk --- backend/fluksio/api/routes/flows.py | 1 + backend/fluksio/flow/dashboards.py | 50 +++++++- backend/fluksio/flow/schemas.py | 4 + backend/fluksio/flow/store.py | 28 +++++ backend/tests/flow/test_dashboards.py | 50 ++++++++ backend/tests/flow/test_store.py | 20 ++++ frontend/src/client/schemas.gen.ts | 75 ++++++++++++ frontend/src/client/types.gen.ts | 20 ++++ .../src/components/Common/DashboardMosaic.tsx | 109 ++++-------------- 9 files changed, 271 insertions(+), 86 deletions(-) diff --git a/backend/fluksio/api/routes/flows.py b/backend/fluksio/api/routes/flows.py index 29cc3ee..b0f7091 100644 --- a/backend/fluksio/api/routes/flows.py +++ b/backend/fluksio/api/routes/flows.py @@ -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)) diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index b1e1188..ced1df8 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -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", ] diff --git a/backend/fluksio/flow/schemas.py b/backend/fluksio/flow/schemas.py index a0e47af..b877953 100644 --- a/backend/fluksio/flow/schemas.py +++ b/backend/fluksio/flow/schemas.py @@ -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): diff --git a/backend/fluksio/flow/store.py b/backend/fluksio/flow/store.py index 3d3e56d..4ab35e1 100644 --- a/backend/fluksio/flow/store.py +++ b/backend/fluksio/flow/store.py @@ -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 -- ` + 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: diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index 466e4e2..52bc7de 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -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): diff --git a/backend/tests/flow/test_store.py b/backend/tests/flow/test_store.py index 5af699e..0734b03 100644 --- a/backend/tests/flow/test_store.py +++ b/backend/tests/flow/test_store.py @@ -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") diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index ec8bf58..3bf8e38 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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: { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 6bb2e4c..0776ba7 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -158,6 +158,9 @@ export type DashboardSummary = { widget_count?: number; has_draft?: boolean; version?: number; + columns?: number; + footprint?: Array; + 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)>; diff --git a/frontend/src/components/Common/DashboardMosaic.tsx b/frontend/src/components/Common/DashboardMosaic.tsx index 521eee3..a3e5cb7 100644 --- a/frontend/src/components/Common/DashboardMosaic.tsx +++ b/frontend/src/components/Common/DashboardMosaic.tsx @@ -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 - 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 ( - {preview && isPending ? ( - - ) : data ? ( - - ) : ( -
- -
- )} + {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 (
- {dashboards.map((dashboard, index) => ( - + {dashboards.map((dashboard) => ( + ))}
)