Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
"""Dashboards: what a wall panel shows, and what its buttons do.
|
||||
|
||||
A dashboard is its own document, not a set of nodes placed in a flow. Widgets
|
||||
bind to message names — the same names that wire the graph — so a dashboard
|
||||
reads across flows without being part of any of them, and a flow stays the
|
||||
logic it was.
|
||||
|
||||
Stored beside the flows in the same git repository, under a directory the flow
|
||||
listing ignores. Editing is separated from showing, exactly as it is for flows:
|
||||
the editor writes ``dashboard.draft.json`` and a wall panel reads only the
|
||||
published ``dashboard.json``, so a half-arranged page never reaches the wall.
|
||||
Publishing promotes the draft and removes it; a dashboard directory without one
|
||||
is simply a dashboard with nothing unpublished. A new dashboard starts as a
|
||||
draft alone, so a directory may just as well hold only the draft — a dashboard
|
||||
nobody has published yet, which no panel can be shown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from fluksio.flow.schemas import _validate_name
|
||||
from fluksio.flow.store import FlowStore, StaleVersion
|
||||
|
||||
#: Sibling of the shared-node library, and likewise not a flow.
|
||||
DASHBOARD_DIR = "_dashboards"
|
||||
|
||||
#: A chart cannot ask for an unbounded series; this is the ceiling.
|
||||
HISTORY_CAP = 5000
|
||||
|
||||
#: How many readings a bar may nest inside its own. The limit is contrast, not
|
||||
#: layout: the segments share one fill token, because no slot of the chart ramp
|
||||
#: clears 3:1 against the outer one, and a fourth could not be told from its
|
||||
#: neighbour. Mirrored in the client (``BarWidget.tsx``).
|
||||
BAR_SEGMENTS = 3
|
||||
|
||||
#: Resolved out here on purpose: the store has a ``list`` method, which
|
||||
#: shadows the builtin for any annotation written inside the class.
|
||||
Bindings = list[dict[str, Any]]
|
||||
|
||||
WidgetType = Literal[
|
||||
# Display
|
||||
"stat",
|
||||
"gauge",
|
||||
"chart",
|
||||
"markdown",
|
||||
"agenda",
|
||||
"notification",
|
||||
"bar",
|
||||
"icon",
|
||||
"forecast",
|
||||
"clock",
|
||||
# Input
|
||||
"button",
|
||||
"switch",
|
||||
"slider",
|
||||
"input",
|
||||
"dropdown",
|
||||
]
|
||||
|
||||
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown"}
|
||||
|
||||
#: What a widget may be pointed at, by payload type. A switch that reads a
|
||||
#: float has nothing to show and nothing safe to send, so the pairing belongs
|
||||
#: to the document rather than to the editor that happened to write it. Types
|
||||
#: missing here take anything. Mirrored in the client
|
||||
#: (``frontend/src/components/Dashboard/widgets.tsx``).
|
||||
WIDGET_DTYPES: dict[str, set[str]] = {
|
||||
"gauge": {"float", "int"},
|
||||
# A chart reading the engine's ring. One that queries binds a `series`
|
||||
# answer and a `record` request instead, checked separately below.
|
||||
"chart": {"float", "int"},
|
||||
"slider": {"float", "int"},
|
||||
"switch": {"bool"},
|
||||
"agenda": {"list"},
|
||||
"notification": {"record"},
|
||||
"bar": {"float", "int"},
|
||||
"forecast": {"list"},
|
||||
# An icon maps weather strings, bool hints and numbers alike, and a clock
|
||||
# binds nothing at all, so neither has a row to be held to.
|
||||
}
|
||||
|
||||
|
||||
class Placement(BaseModel):
|
||||
"""Where a widget sits in its section's grid, in grid units."""
|
||||
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
w: int = 3
|
||||
h: int = 2
|
||||
|
||||
|
||||
class WidgetDef(BaseModel):
|
||||
"""One tile: what it shows or does, and where it sits.
|
||||
|
||||
``config`` is per type — a chart names its series, a button names the
|
||||
message it publishes — and is validated against the type below rather than
|
||||
by a schema per class, because the whole set is small and closed.
|
||||
|
||||
A chart comes in two kinds. The default reads what the engine kept for a
|
||||
message. One with ``source: "query"`` asks instead, and its config is
|
||||
``{source, request, request_dtype: "record", message, dtype: "series",
|
||||
refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to
|
||||
``request`` exactly as a slider publishes a value, and draws the ``series``
|
||||
a flow answers with on ``message``.
|
||||
"""
|
||||
|
||||
id: str
|
||||
type: WidgetType
|
||||
title: str = ""
|
||||
#: Keyed by breakpoint (``lg``/``md``/``sm``); missing ones are derived by
|
||||
#: the client from the widest one it has.
|
||||
layout: dict[str, Placement] = Field(default_factory=dict)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def _check_id(cls, value: str) -> str:
|
||||
return _validate_name(value)
|
||||
|
||||
@property
|
||||
def _query_chart(self) -> bool:
|
||||
"""A chart that asks a flow for its series instead of reading the ring."""
|
||||
return self.type == "chart" and self.config.get("source") == "query"
|
||||
|
||||
@property
|
||||
def inner_bindings(self) -> Bindings:
|
||||
"""A bar's nested readings, in either shape a document may carry them.
|
||||
|
||||
One binding beside ``inner_dtype``, as a bar was written before it
|
||||
stacked, or an ordered list of ``{message, dtype}`` — so an older
|
||||
dashboard keeps drawing without being migrated first.
|
||||
"""
|
||||
inner = self.config.get("inner")
|
||||
if isinstance(inner, list):
|
||||
return [s for s in inner[:BAR_SEGMENTS] if isinstance(s, dict)]
|
||||
dtype = self.config.get("inner_dtype")
|
||||
# A recorded type with nothing bound is still a type to be held to.
|
||||
if inner or dtype:
|
||||
return [{"message": inner or "", "dtype": dtype}]
|
||||
return []
|
||||
|
||||
@property
|
||||
def messages(self) -> list[str]:
|
||||
"""Every message name this widget reads."""
|
||||
if self._query_chart:
|
||||
name = self.config.get("message")
|
||||
return [str(name)] if name else []
|
||||
if self.type == "chart":
|
||||
return [
|
||||
str(series.get("message"))
|
||||
for series in self.config.get("series") or []
|
||||
if series.get("message")
|
||||
]
|
||||
name = self.config.get("message")
|
||||
# Only a bar nests further readings inside the one it draws.
|
||||
nested = [s.get("message") for s in self.inner_bindings]
|
||||
return [str(value) for value in (name, *nested) if value]
|
||||
|
||||
@property
|
||||
def target(self) -> str:
|
||||
"""The message this widget publishes, if it is an input.
|
||||
|
||||
A querying chart is one too: its request is a value it puts into the
|
||||
graph, so the canvas draws it as an endpoint like any other control.
|
||||
"""
|
||||
if self._query_chart:
|
||||
return str(self.config.get("request") or "")
|
||||
return str(self.config.get("target") or "")
|
||||
|
||||
@property
|
||||
def history_points(self) -> int:
|
||||
"""How much past this widget needs kept for it.
|
||||
|
||||
Nothing, for a chart that queries: the answer carries its own past, so
|
||||
asking the engine to keep a ring as well would store it twice.
|
||||
"""
|
||||
if self.type != "chart" or self._query_chart:
|
||||
return 0
|
||||
points = int((self.config.get("history") or {}).get("points") or 0)
|
||||
return min(points, HISTORY_CAP)
|
||||
|
||||
@property
|
||||
def bound_dtypes(self) -> list[str]:
|
||||
"""The payload types this widget was bound to, as the editor recorded.
|
||||
|
||||
Empty for a document written before the editor kept them, which is why
|
||||
a missing type is never an error.
|
||||
"""
|
||||
if self.type == "chart":
|
||||
return [
|
||||
str(series.get("dtype") or "")
|
||||
for series in self.config.get("series") or []
|
||||
]
|
||||
return [
|
||||
str(self.config.get("dtype") or ""),
|
||||
*(str(s.get("dtype") or "") for s in self.inner_bindings),
|
||||
]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_binding(self) -> WidgetDef:
|
||||
"""Refuse a widget wired to a message it cannot carry."""
|
||||
if self._query_chart:
|
||||
for key, want in (("dtype", "series"), ("request_dtype", "record")):
|
||||
bound = str(self.config.get(key) or "")
|
||||
if bound and bound != want:
|
||||
raise ValueError(
|
||||
f"a querying chart's {key} must be '{want}', not '{bound}'"
|
||||
)
|
||||
return self
|
||||
|
||||
inner = self.config.get("inner")
|
||||
if isinstance(inner, list) and len(inner) > BAR_SEGMENTS:
|
||||
raise ValueError(f"a bar nests at most {BAR_SEGMENTS} readings")
|
||||
|
||||
allowed = WIDGET_DTYPES.get(self.type)
|
||||
if not allowed:
|
||||
return self
|
||||
for dtype in self.bound_dtypes:
|
||||
if dtype and dtype not in allowed:
|
||||
raise ValueError(
|
||||
f"a '{self.type}' widget cannot carry a '{dtype}' message"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class SectionDef(BaseModel):
|
||||
"""A grid of widgets under a heading."""
|
||||
|
||||
id: str
|
||||
title: str = ""
|
||||
widgets: list[WidgetDef] = Field(default_factory=list)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def _check_id(cls, value: str) -> str:
|
||||
return _validate_name(value)
|
||||
|
||||
|
||||
class PageDef(BaseModel):
|
||||
"""One tab of a dashboard."""
|
||||
|
||||
id: str
|
||||
title: str = ""
|
||||
#: A lucide icon name, or empty.
|
||||
icon: str = ""
|
||||
sections: list[SectionDef] = Field(default_factory=list)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def _check_id(cls, value: str) -> str:
|
||||
return _validate_name(value)
|
||||
|
||||
|
||||
class DashboardDef(BaseModel):
|
||||
"""A dashboard as stored, and as the API hands it over."""
|
||||
|
||||
name: str
|
||||
title: str = ""
|
||||
#: How many columns the grid is cut into, so a dashboard can be matched to
|
||||
#: the panel it will hang on.
|
||||
columns: int = Field(default=12, ge=1, le=48)
|
||||
#: The panel this dashboard is drawn for, in CSS pixels. Both the editor
|
||||
#: and the wall panel scale that surface to fit whatever room they have, so
|
||||
#: an arrangement does not depend on the window it was made in. Zero means
|
||||
#: "unset" and the client falls back to its default.
|
||||
canvas_width: int = Field(default=1920, ge=0, le=7680)
|
||||
canvas_height: int = Field(default=1080, ge=0, le=4320)
|
||||
#: A lucide icon name, drawn on the panel rail; empty falls back to two
|
||||
#: letters of the title.
|
||||
icon: str = ""
|
||||
pages: list[PageDef] = Field(default_factory=list)
|
||||
#: Bumped on every save; a save based on an older one is refused.
|
||||
version: int = 1
|
||||
#: Whether there are unpublished changes. Reported by the store on read,
|
||||
#: never stored — the draft file's existence is the only record of it.
|
||||
has_draft: bool = False
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _check_name(cls, value: str) -> str:
|
||||
return _validate_name(value)
|
||||
|
||||
@property
|
||||
def widgets(self) -> list[WidgetDef]:
|
||||
return [w for p in self.pages for s in p.sections for w in s.widgets]
|
||||
|
||||
|
||||
class DashboardSummary(BaseModel):
|
||||
"""A dashboard in a list, without its contents."""
|
||||
|
||||
name: str
|
||||
title: str = ""
|
||||
page_count: int = 0
|
||||
widget_count: int = 0
|
||||
has_draft: bool = False
|
||||
#: Of the working copy, so publishing from a list needs no second read.
|
||||
version: int = 1
|
||||
|
||||
|
||||
class DashboardsPublic(BaseModel):
|
||||
data: list[DashboardSummary]
|
||||
count: int
|
||||
|
||||
|
||||
class DashboardNotFound(KeyError):
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(name)
|
||||
self.name = name
|
||||
|
||||
|
||||
class DashboardExists(ValueError):
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__(name)
|
||||
self.name = name
|
||||
|
||||
|
||||
class DashboardStore:
|
||||
"""Dashboards in the flow store's repository, invisible to the flow listing.
|
||||
|
||||
Shares the flow store's write lock and commit, so a dashboard save and a
|
||||
flow save cannot interleave into one confused commit.
|
||||
"""
|
||||
|
||||
def __init__(self, flows: FlowStore) -> None:
|
||||
self.flows = flows
|
||||
self.root = flows.root / DASHBOARD_DIR
|
||||
# Read-modify-write of the version counter, same as the flow store.
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _file(self, name: str) -> Path:
|
||||
return self.root / name / "dashboard.json"
|
||||
|
||||
def _draft_file(self, name: str) -> Path:
|
||||
return self.root / name / "dashboard.draft.json"
|
||||
|
||||
@staticmethod
|
||||
def _dump(defn: DashboardDef) -> str:
|
||||
"""What goes on disk. ``has_draft`` is the file layout, not a field."""
|
||||
return defn.model_dump_json(indent=2, exclude={"has_draft"})
|
||||
|
||||
def list(self) -> list[DashboardSummary]:
|
||||
"""Every dashboard the editor knows, published or not."""
|
||||
names = {path.parent.name for path in self.root.glob("*/dashboard.json")}
|
||||
names |= {path.parent.name for path in self.root.glob("*/dashboard.draft.json")}
|
||||
summaries = []
|
||||
for name in sorted(names):
|
||||
try:
|
||||
defn = self.read(name, draft=True)
|
||||
except Exception:
|
||||
continue
|
||||
summaries.append(
|
||||
DashboardSummary(
|
||||
name=defn.name,
|
||||
title=defn.title,
|
||||
page_count=len(defn.pages),
|
||||
widget_count=len(defn.widgets),
|
||||
has_draft=defn.has_draft,
|
||||
version=defn.version,
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
def exists(self, name: str) -> bool:
|
||||
return self._file(name).exists() or self._draft_file(name).exists()
|
||||
|
||||
def is_published(self, name: str) -> bool:
|
||||
"""Is there a document a panel can be shown?"""
|
||||
return self._file(name).exists()
|
||||
|
||||
def has_draft(self, name: str) -> bool:
|
||||
"""Are there unpublished changes to this dashboard?"""
|
||||
return self._draft_file(name).exists()
|
||||
|
||||
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)
|
||||
if not path.exists():
|
||||
path = self._file(name)
|
||||
if not path.exists():
|
||||
raise DashboardNotFound(name)
|
||||
return DashboardDef.model_validate_json(path.read_text()).model_copy(
|
||||
update={"has_draft": self.has_draft(name)}
|
||||
)
|
||||
|
||||
def write(
|
||||
self, defn: DashboardDef, base_version: int | None = None
|
||||
) -> DashboardDef:
|
||||
"""Publish a dashboard directly, skipping the draft.
|
||||
|
||||
The API never does: it creates a draft and promotes it. This is for a
|
||||
caller that already has the finished document — a test, or a seed.
|
||||
"""
|
||||
with self._lock, self.flows._write_lock:
|
||||
path = self._file(defn.name)
|
||||
current = 0
|
||||
if path.exists():
|
||||
current = DashboardDef.model_validate_json(path.read_text()).version
|
||||
if base_version is not None and base_version != current:
|
||||
raise StaleVersion(defn.name, current)
|
||||
|
||||
saved = defn.model_copy(update={"version": current + 1, "has_draft": False})
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(self._dump(saved))
|
||||
self.flows._commit(f"Save dashboard '{defn.name}'")
|
||||
return saved
|
||||
|
||||
def write_draft(
|
||||
self, defn: DashboardDef, base_version: int | None = None
|
||||
) -> DashboardDef:
|
||||
"""Save unpublished changes, refusing to overwrite someone else's.
|
||||
|
||||
``base_version`` is the version the editor last saw — of the working
|
||||
copy, which is the draft once there is one, and 0 for a dashboard that
|
||||
does not exist yet: creating one is its first draft.
|
||||
"""
|
||||
with self._lock, self.flows._write_lock:
|
||||
current = 0
|
||||
if self.exists(defn.name):
|
||||
current = self.read(defn.name, draft=True).version
|
||||
if base_version is not None and base_version != current:
|
||||
raise StaleVersion(defn.name, current)
|
||||
|
||||
saved = defn.model_copy(update={"version": current + 1, "has_draft": True})
|
||||
path = self._draft_file(defn.name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(self._dump(saved))
|
||||
self.flows._commit(f"Update draft of dashboard '{defn.name}'")
|
||||
return saved
|
||||
|
||||
def publish(self, name: str, base_version: int | None = None) -> DashboardDef:
|
||||
"""Promote the working copy to what the panels show."""
|
||||
with self._lock, self.flows._write_lock:
|
||||
current = self.read(name, draft=True)
|
||||
if base_version is not None and base_version != current.version:
|
||||
raise StaleVersion(name, current.version)
|
||||
|
||||
draft = self._draft_file(name)
|
||||
if draft.exists():
|
||||
self._file(name).write_text(self._dump(current))
|
||||
draft.unlink()
|
||||
self.flows._commit(f"Publish dashboard '{name}'")
|
||||
return current.model_copy(update={"has_draft": False})
|
||||
|
||||
def discard_draft(self, name: str) -> DashboardDef:
|
||||
"""Throw the unpublished changes away and go back to what is shown."""
|
||||
with self._lock, self.flows._write_lock:
|
||||
draft = self._draft_file(name)
|
||||
if draft.exists():
|
||||
draft.unlink()
|
||||
self.flows._commit(f"Discard draft of dashboard '{name}'")
|
||||
return self.read(name)
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
if not self.exists(name):
|
||||
raise DashboardNotFound(name)
|
||||
path = self._file(name)
|
||||
with self.flows._write_lock:
|
||||
path.unlink(missing_ok=True)
|
||||
self._draft_file(name).unlink(missing_ok=True)
|
||||
try:
|
||||
path.parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
self.flows._commit(f"Delete dashboard '{name}'")
|
||||
|
||||
def rename(self, name: str, new_name: str) -> DashboardDef:
|
||||
defn = self.read(name, draft=True)
|
||||
if self.exists(new_name):
|
||||
raise DashboardExists(new_name)
|
||||
with self.flows._write_lock:
|
||||
renamed = defn.model_copy(update={"name": new_name})
|
||||
self._file(new_name).parent.mkdir(parents=True, exist_ok=True)
|
||||
# Whichever files the dashboard has move; one nobody published yet
|
||||
# has only the draft, and renaming it must not publish it.
|
||||
if self.is_published(name):
|
||||
published = self.read(name).model_copy(update={"name": new_name})
|
||||
self._file(new_name).write_text(self._dump(published))
|
||||
self._file(name).unlink()
|
||||
# An unpublished edit belongs to the dashboard, so it moves too.
|
||||
if self.has_draft(name):
|
||||
self._draft_file(new_name).write_text(self._dump(renamed))
|
||||
self._draft_file(name).unlink()
|
||||
try:
|
||||
self._file(name).parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
self.flows._commit(f"Rename dashboard '{name}' to '{new_name}'")
|
||||
return renamed
|
||||
|
||||
def bindings_for(self, flow: str) -> Bindings:
|
||||
"""Every widget bound to a message of ``flow``.
|
||||
|
||||
What the canvas draws as an endpoint: a control that sets one of this
|
||||
flow's messages, or a tile that shows one. Without this a dashboard is
|
||||
an invisible participant — a value changes and nothing on the canvas
|
||||
accounts for it.
|
||||
"""
|
||||
prefix = f"{flow}."
|
||||
found: Bindings = []
|
||||
for path in sorted(self.root.glob("*/dashboard.json")):
|
||||
try:
|
||||
defn = DashboardDef.model_validate_json(path.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
for widget in defn.widgets:
|
||||
# A control produces the message; a tile consumes it.
|
||||
produces = widget.target if widget.target.startswith(prefix) else ""
|
||||
consumes = [m for m in widget.messages if m.startswith(prefix)]
|
||||
if not produces and not consumes:
|
||||
continue
|
||||
found.append(
|
||||
{
|
||||
"dashboard": defn.name,
|
||||
"dashboard_title": defn.title or defn.name,
|
||||
"widget": widget.id,
|
||||
"title": widget.title or widget.id,
|
||||
"type": widget.type,
|
||||
"provides": produces,
|
||||
"requires": consumes,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
def history_requirements(self) -> dict[str, int]:
|
||||
"""How many points to keep per message, so charts have a past to draw.
|
||||
|
||||
The deepest chart bound to a message wins; a message no chart reads
|
||||
keeps the default.
|
||||
"""
|
||||
limits: dict[str, int] = {}
|
||||
for path in self.root.glob("*/dashboard.json"):
|
||||
try:
|
||||
defn = DashboardDef.model_validate_json(path.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
for widget in defn.widgets:
|
||||
points = widget.history_points
|
||||
if not points:
|
||||
continue
|
||||
for message in widget.messages:
|
||||
limits[message] = max(limits.get(message, 0), points)
|
||||
return limits
|
||||
|
||||
|
||||
def default_dashboard(name: str) -> DashboardDef:
|
||||
"""A new dashboard: one page, one section, nothing in it yet."""
|
||||
return DashboardDef(
|
||||
name=name,
|
||||
title=name.replace("_", " ").capitalize(),
|
||||
pages=[PageDef(id="main", title="Overview", sections=[SectionDef(id="main")])],
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BAR_SEGMENTS",
|
||||
"DASHBOARD_DIR",
|
||||
"HISTORY_CAP",
|
||||
"INPUT_WIDGETS",
|
||||
"WIDGET_DTYPES",
|
||||
"DashboardDef",
|
||||
"DashboardExists",
|
||||
"DashboardNotFound",
|
||||
"DashboardStore",
|
||||
"DashboardSummary",
|
||||
"DashboardsPublic",
|
||||
"PageDef",
|
||||
"Placement",
|
||||
"SectionDef",
|
||||
"WidgetDef",
|
||||
"default_dashboard",
|
||||
]
|
||||
Reference in New Issue
Block a user