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: