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),
|
paused=controller.is_paused(name),
|
||||||
quarantined=controller.is_quarantined(name),
|
quarantined=controller.is_quarantined(name),
|
||||||
version=definition.version,
|
version=definition.version,
|
||||||
|
updated_at=controller.store.updated_at(name),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return FlowsPublic(data=summaries, count=len(summaries))
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
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.messages import DType, qualify
|
||||||
from fluksio.flow.schemas import FlowDef, _validate_name
|
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.
|
#: Sibling of the shared-node library, and likewise not a flow.
|
||||||
DASHBOARD_DIR = "_dashboards"
|
DASHBOARD_DIR = "_dashboards"
|
||||||
@@ -177,6 +178,18 @@ class Placement(BaseModel):
|
|||||||
h: int = 2
|
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):
|
class WidgetDef(BaseModel):
|
||||||
"""One tile: what it shows or does, and where it sits.
|
"""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."""
|
"""Every message a bound setting reads. Empty for a static dashboard."""
|
||||||
return [s.message for s in self.settings.values() if s.message]
|
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):
|
class DashboardSummary(BaseModel):
|
||||||
"""A dashboard in a list, without its contents."""
|
"""A dashboard in a list, without its contents."""
|
||||||
@@ -530,6 +562,14 @@ class DashboardSummary(BaseModel):
|
|||||||
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.
|
||||||
version: int = 1
|
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):
|
class DashboardsPublic(BaseModel):
|
||||||
@@ -591,6 +631,9 @@ class DashboardStore:
|
|||||||
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,
|
||||||
|
columns=defn.columns,
|
||||||
|
footprint=defn.footprint,
|
||||||
|
updated_at=self.updated_at(name),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return summaries
|
return summaries
|
||||||
@@ -606,6 +649,10 @@ class DashboardStore:
|
|||||||
"""Are there unpublished changes to this dashboard?"""
|
"""Are there unpublished changes to this dashboard?"""
|
||||||
return self._draft_file(name).exists()
|
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:
|
def read(self, name: str, draft: bool = False) -> DashboardDef:
|
||||||
"""The published dashboard, or with ``draft`` the working copy."""
|
"""The published dashboard, or with ``draft`` the working copy."""
|
||||||
path = self._draft_file(name) if draft else self._file(name)
|
path = self._draft_file(name) if draft else self._file(name)
|
||||||
@@ -915,5 +962,6 @@ __all__ = [
|
|||||||
"Placement",
|
"Placement",
|
||||||
"SettingDef",
|
"SettingDef",
|
||||||
"WidgetDef",
|
"WidgetDef",
|
||||||
|
"WidgetFootprint",
|
||||||
"default_dashboard",
|
"default_dashboard",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ A flow is structure plus code: this module is the structure. Node logic for
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
@@ -366,6 +367,9 @@ class FlowSummary(BaseModel):
|
|||||||
quarantined: bool = False
|
quarantined: 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.
|
||||||
version: int = 1
|
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):
|
class FlowsPublic(BaseModel):
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import logging
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fluksio.flow.schemas import FlowDef
|
from fluksio.flow.schemas import FlowDef
|
||||||
@@ -84,6 +86,20 @@ class StaleVersion(ValueError):
|
|||||||
return f"Flow '{self.name}' has changed since you loaded it"
|
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:
|
def _same_content(left: FlowDef, right: FlowDef) -> bool:
|
||||||
"""Equal but for the version counter, which the server owns."""
|
"""Equal but for the version counter, which the server owns."""
|
||||||
return left.model_copy(update={"version": 0}) == right.model_copy(
|
return left.model_copy(update={"version": 0}) == right.model_copy(
|
||||||
@@ -386,6 +402,18 @@ class FlowStore:
|
|||||||
drafts = self._draft_nodes_dir(name)
|
drafts = self._draft_nodes_dir(name)
|
||||||
return drafts.exists() and any(drafts.glob("*.py"))
|
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:
|
def read_flow(self, name: str, draft: bool = False) -> FlowDef:
|
||||||
"""The published flow, or with ``draft`` the working copy."""
|
"""The published flow, or with ``draft`` the working copy."""
|
||||||
if draft:
|
if draft:
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""Dashboards: documents beside the flows, and the values their widgets move."""
|
"""Dashboards: documents beside the flows, and the values their widgets move."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from fluksio.flow.dashboards import (
|
from fluksio.flow.dashboards import (
|
||||||
DashboardDef,
|
DashboardDef,
|
||||||
DashboardNotFound,
|
DashboardNotFound,
|
||||||
DashboardStore,
|
DashboardStore,
|
||||||
|
Placement,
|
||||||
SettingDef,
|
SettingDef,
|
||||||
WidgetDef,
|
WidgetDef,
|
||||||
default_dashboard,
|
default_dashboard,
|
||||||
@@ -389,6 +392,53 @@ def test_a_querying_chart_keeps_no_ring():
|
|||||||
assert widget.history_points == 0
|
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():
|
def test_a_dashboard_is_cut_into_a_sane_number_of_columns():
|
||||||
for columns in (0, 49):
|
for columns in (0, 49):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -56,6 +58,24 @@ def test_saving_unchanged_content_does_nothing(store: FlowStore):
|
|||||||
assert commit_count(store) == commits
|
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):
|
def test_missing_flow_is_reported(store: FlowStore):
|
||||||
with pytest.raises(FlowNotFound):
|
with pytest.raises(FlowNotFound):
|
||||||
store.read_flow("nope")
|
store.read_flow("nope")
|
||||||
|
|||||||
@@ -575,6 +575,30 @@ export const DashboardSummarySchema = {
|
|||||||
type: 'integer',
|
type: 'integer',
|
||||||
title: 'Version',
|
title: 'Version',
|
||||||
default: 1
|
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',
|
type: 'object',
|
||||||
@@ -1303,6 +1327,18 @@ export const FlowSummarySchema = {
|
|||||||
type: 'integer',
|
type: 'integer',
|
||||||
title: 'Version',
|
title: 'Version',
|
||||||
default: 1
|
default: 1
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Updated At'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
type: 'object',
|
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.`
|
\`\`COLOR_DTYPES\`\` records and the check below holds a binding to.`
|
||||||
} as const;
|
} 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 = {
|
export const WorkerInfoSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ export type DashboardSummary = {
|
|||||||
widget_count?: number;
|
widget_count?: number;
|
||||||
has_draft?: boolean;
|
has_draft?: boolean;
|
||||||
version?: number;
|
version?: number;
|
||||||
|
columns?: number;
|
||||||
|
footprint?: Array<WidgetFootprint>;
|
||||||
|
updated_at?: (string | null);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeadLetter = {
|
export type DeadLetter = {
|
||||||
@@ -408,6 +411,7 @@ export type FlowSummary = {
|
|||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
quarantined?: boolean;
|
quarantined?: boolean;
|
||||||
version?: number;
|
version?: number;
|
||||||
|
updated_at?: (string | null);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type fluksio__api__routes__dashboards__PublishRequest = {
|
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';
|
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 = {
|
export type WorkerInfo = {
|
||||||
name: string;
|
name: string;
|
||||||
labels?: Array<(string)>;
|
labels?: Array<(string)>;
|
||||||
|
|||||||
@@ -1,31 +1,13 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
import { LayoutDashboard } from "lucide-react"
|
import { LayoutDashboard } from "lucide-react"
|
||||||
|
|
||||||
import type {
|
import type { DashboardSummary, WidgetFootprint } from "@/client"
|
||||||
DashboardDef_Output,
|
|
||||||
DashboardSummary,
|
|
||||||
Placement,
|
|
||||||
WidgetDef,
|
|
||||||
} from "@/client"
|
|
||||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
/** Columns a dashboard is cut into when its document does not say. */
|
/** Columns a dashboard is cut into when its document does not say. */
|
||||||
const DEFAULT_COLUMNS = 12
|
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. */
|
/** Widgets that draw a shape, and widgets that are controls. The rest read out. */
|
||||||
const GRAPHIC = new Set(["chart", "forecast", "bar", "gauge"])
|
const GRAPHIC = new Set(["chart", "forecast", "bar", "gauge"])
|
||||||
const INPUT = new Set([
|
const INPUT = new Set([
|
||||||
@@ -44,20 +26,7 @@ const shade = (type: string) =>
|
|||||||
? "bg-primary/45"
|
? "bg-primary/45"
|
||||||
: "bg-primary/20"
|
: "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 = {
|
type Block = {
|
||||||
id: string
|
|
||||||
type: string
|
type: string
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
@@ -65,18 +34,17 @@ type Block = {
|
|||||||
h: number
|
h: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The dashboard's widgets as one grid. */
|
/** The dashboard's widget shapes as one grid. */
|
||||||
function blocksOf(dashboard: DashboardDef_Output): {
|
function blocksOf(footprint: WidgetFootprint[]): {
|
||||||
blocks: Block[]
|
blocks: Block[]
|
||||||
rows: number
|
rows: number
|
||||||
} {
|
} {
|
||||||
const blocks: Block[] = []
|
const blocks: Block[] = []
|
||||||
let rows = 0
|
let rows = 0
|
||||||
for (const widget of dashboard.widgets ?? []) {
|
for (const shape of footprint) {
|
||||||
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
|
const { x = 0, y = 0, w = 3, h = 2 } = shape
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id: widget.id,
|
type: shape.type,
|
||||||
type: widget.type,
|
|
||||||
x: Math.max(0, x),
|
x: Math.max(0, x),
|
||||||
y: Math.max(0, y),
|
y: Math.max(0, y),
|
||||||
w: Math.max(1, w),
|
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
|
* 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
|
* 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
|
* 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 }) {
|
function Footprint({ dashboard }: { dashboard: DashboardSummary }) {
|
||||||
const { blocks, rows } = blocksOf(dashboard)
|
const { blocks, rows } = blocksOf(dashboard.footprint ?? [])
|
||||||
const columns = dashboard.columns || DEFAULT_COLUMNS
|
const columns = dashboard.columns || DEFAULT_COLUMNS
|
||||||
// Before the editor could place things, every widget was written at 0,0;
|
// Before the editor could place things, every widget was written at 0,0;
|
||||||
// honouring that would pile the whole page onto one cell.
|
// honouring that would pile the whole page onto one cell.
|
||||||
@@ -123,11 +91,11 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
|
|||||||
}, minmax(0, 1fr))`,
|
}, minmax(0, 1fr))`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{blocks.map((block) => {
|
{blocks.map((block, index) => {
|
||||||
const width = Math.min(columns, block.w)
|
const width = Math.min(columns, block.w)
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={block.id}
|
key={index}
|
||||||
className={cn("rounded-[2px]", shade(block.type))}
|
className={cn("rounded-[2px]", shade(block.type))}
|
||||||
style={
|
style={
|
||||||
placed
|
placed
|
||||||
@@ -144,30 +112,15 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** One dashboard in the mosaic. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
function Tile({
|
function Tile({
|
||||||
dashboard,
|
dashboard,
|
||||||
preview,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
dashboard: DashboardSummary
|
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. */
|
/** What the layout needs of it — a width, in the scrolling strip. */
|
||||||
className?: string
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to="/dashboards/$name"
|
to="/dashboards/$name"
|
||||||
@@ -178,15 +131,7 @@ function Tile({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{preview && isPending ? (
|
<Footprint dashboard={dashboard} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate text-sm font-medium">
|
<span className="truncate text-sm font-medium">
|
||||||
{dashboard.title || dashboard.name}
|
{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.
|
* Which of two documents was worked on more recently.
|
||||||
*
|
*
|
||||||
* ponytail: neither `FlowSummary` nor `DashboardSummary` carries a modified
|
* Both summaries carry the modified time of the working copy, so this is the
|
||||||
* time, so this reads the two things that come close — an unpublished edit is
|
* order it says it is. The name only settles a tie, which is two documents
|
||||||
* the one someone has open, and a higher version counter has been saved more
|
* written in the same instant — what a seed does.
|
||||||
* often. An `updated_at` on both summaries is what would make it exact.
|
|
||||||
*/
|
*/
|
||||||
export function byRecency<
|
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 {
|
>(a: T, b: T): number {
|
||||||
return (
|
return modifiedAt(b) - modifiedAt(a) || a.name.localeCompare(b.name)
|
||||||
Number(b.has_draft ?? false) - Number(a.has_draft ?? false) ||
|
|
||||||
(b.version ?? 0) - (a.version ?? 0) ||
|
|
||||||
a.name.localeCompare(b.name)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -273,13 +217,8 @@ export function DashboardMosaic({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={container}>
|
<div className={container}>
|
||||||
{dashboards.map((dashboard, index) => (
|
{dashboards.map((dashboard) => (
|
||||||
<Tile
|
<Tile key={dashboard.name} dashboard={dashboard} className={tile} />
|
||||||
key={dashboard.name}
|
|
||||||
dashboard={dashboard}
|
|
||||||
preview={index < PREVIEWS}
|
|
||||||
className={tile}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user