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:
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user