diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index ad9f065..e85f725 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -96,6 +96,42 @@ WIDGET_DTYPES: dict[str, set[str]] = { } +#: What a dashboard-wide setting may be bound to, by payload type. The channel +#: is general — a setting is a value plus an optional binding — but the wired +#: ones are a closed set, and a name missing here is simply a setting this +#: build does not act on. Mirrored in the client +#: (``frontend/src/components/Dashboard/settings.tsx``). +SETTING_DTYPES: dict[str, str] = { + # "system" | "light" | "dark". A panel in a room has no way to set the + # device preference the app otherwise inherits. + "theme": "str", + # Read-only: the input widgets stop publishing. + "locked": "bool", +} + + +class SettingDef(BaseModel): + """One dashboard-wide setting: a value, and optionally where it comes from. + + Unbound — no ``message`` — the setting is simply ``value``, which is what + makes a panel that is always dark cost no flow at all. Bound, a flow drives + it live and ``value`` is the fallback: what the dashboard uses until + something arrives, and whenever the message is silent. + + A schedule is not a third case. A node publishing to the bound message on a + cron *is* the schedule, which is the whole reason this is a channel rather + than a switching rule per setting. + """ + + value: Any = None + #: The message that drives it, or empty for a setting that is just a value. + message: str = "" + #: The payload type the editor recorded when it bound that message, so the + #: pairing can be judged from the document alone — the rule widget bindings + #: are held to. + dtype: str = "" + + class Placement(BaseModel): """Where a widget sits in its section's grid, in grid units.""" @@ -311,6 +347,11 @@ class DashboardDef(BaseModel): #: letters of the title. icon: str = "" pages: list[PageDef] = Field(default_factory=list) + #: Settings the whole dashboard carries, by name — see ``SettingDef``. The + #: one channel a dashboard consumes as a dashboard rather than as a set of + #: tiles, so a screen on a wall can be told things nobody standing at it + #: could set. + settings: dict[str, SettingDef] = Field(default_factory=dict) #: 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, @@ -322,10 +363,35 @@ class DashboardDef(BaseModel): def _check_name(cls, value: str) -> str: return _validate_name(value) + @model_validator(mode="after") + def _check_settings(self) -> DashboardDef: + """Refuse a setting driven by a message it cannot carry. + + Judged from the document alone, exactly as a widget's binding is: the + picker records the payload type beside the name, so neither the editor + nor a wall panel has to fetch the catalogue to know the wiring is + wrong. A name this build does not know is left alone rather than + refused — an older installation reading a newer document simply does + not act on it. + """ + for name, setting in self.settings.items(): + want = SETTING_DTYPES.get(name) + if want and setting.dtype and setting.dtype != want: + raise ValueError( + f"the '{name}' setting needs a '{want}' message, " + f"not a '{setting.dtype}'" + ) + return self + @property def widgets(self) -> list[WidgetDef]: return [w for p in self.pages for s in p.sections for w in s.widgets] + @property + def setting_messages(self) -> list[str]: + """Every message a bound setting reads. Empty for a static dashboard.""" + return [s.message for s in self.settings.values() if s.message] + class DashboardSummary(BaseModel): """A dashboard in a list, without its contents.""" @@ -544,6 +610,25 @@ class DashboardStore: defn = DashboardDef.model_validate_json(path.read_text()) except Exception: continue + # A bound setting is a consumer too — the dashboard itself reading + # a message rather than any tile on it — so the canvas accounts for + # it the same way. ``widget`` is what the endpoint id is built + # from, and no widget id can collide with it: a dot is not a legal + # name character. + for name, setting in defn.settings.items(): + if not setting.message.startswith(prefix): + continue + found.append( + { + "dashboard": defn.name, + "dashboard_title": defn.title or defn.name, + "widget": f"settings.{name}", + "title": f"{defn.title or defn.name} {name}", + "type": "setting", + "provides": "", + "requires": [setting.message], + } + ) for widget in defn.widgets: # A control produces the message; a tile consumes it. produces = widget.target if widget.target.startswith(prefix) else "" @@ -599,6 +684,7 @@ __all__ = [ "DASHBOARD_DIR", "HISTORY_CAP", "INPUT_WIDGETS", + "SETTING_DTYPES", "WIDGET_DTYPES", "DashboardDef", "DashboardExists", @@ -609,6 +695,7 @@ __all__ = [ "PageDef", "Placement", "SectionDef", + "SettingDef", "WidgetDef", "default_dashboard", ] diff --git a/backend/fluksio/flow/panels.py b/backend/fluksio/flow/panels.py index fc721ed..482f0f8 100644 --- a/backend/fluksio/flow/panels.py +++ b/backend/fluksio/flow/panels.py @@ -85,11 +85,15 @@ def find(panel_id: str) -> PanelDef | None: def messages_for(panel_id: str, store: DashboardStore) -> set[str]: - """Every message the widgets of this panel's dashboards read or write. + """Every message this panel's dashboards read or write. What a screen is entitled to see, as its own dashboards define it. Read from the published documents, since that is what a panel draws, and empty for a panel that is gone — which is the same answer as "nothing". + + A dashboard's own bound settings count, not only its widgets': the theme a + panel is driven to is a message no tile on it draws, and a wall panel + refused its own theme message is the one surface the setting exists for. """ panel = find(panel_id) if panel is None: @@ -100,6 +104,7 @@ def messages_for(panel_id: str, store: DashboardStore) -> set[str]: defn = store.read(dashboard) except DashboardNotFound: continue + names.update(defn.setting_messages) for widget in defn.widgets: names.update(widget.messages) if widget.target: diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_panels.py index e7cbb64..cf61ebe 100644 --- a/backend/tests/api/routes/test_panels.py +++ b/backend/tests/api/routes/test_panels.py @@ -313,6 +313,86 @@ def test_a_panel_speaks_only_its_own_widgets_messages( ) +def test_a_panel_may_read_its_dashboards_own_settings( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """A bound setting is no widget's binding, and a panel still needs it. + + The allowlist is a walk of what the dashboards name, so the walk has to + reach the dashboard itself: a theme driven over a message is the one thing + a screen on a wall cannot be told any other way, and a panel refused that + message would leave the feature silently doing nothing on the only surface + it exists for. + """ + from fluksio.api.routes.flows import panel_scope + + _dashboard_with( + client, superuser_token_headers, "panel_themed", [PANEL_WIDGETS[-1]] + ) + saved = client.get( + f"{DASHBOARDS}/panel_themed?draft=true", headers=superuser_token_headers + ).json() + saved["settings"] = { + "theme": {"value": "dark", "message": "demo.panel_theme", "dtype": "str"}, + # Bound to nothing: a static setting entitles a panel to nothing. + "locked": {"value": False}, + } + written = client.put( + f"{DASHBOARDS}/panel_themed", headers=superuser_token_headers, json=saved + ) + assert written.status_code == 200, written.text + published = client.post( + f"{DASHBOARDS}/panel_themed/publish", + headers=superuser_token_headers, + json={"version": written.json()["version"]}, + ) + assert published.status_code == 200, published.text + + _panels( + client, + superuser_token_headers, + {"panels": [{"id": "hall", "dashboards": ["panel_themed"]}]}, + ) + panel_headers = _pair(client, superuser_token_headers, "hall") + messages = f"{settings.API_V1_STR}/messages" + + # Not 403: the gate lets it through, and the engine then answers for a + # message no flow in this suite declares. + assert ( + client.get( + f"{messages}/demo.panel_theme/history", headers=panel_headers + ).status_code + == 200 + ) + # And the socket is bounded by the same walk, so the value actually lands. + token = panel_headers["Authorization"][7:] + assert panel_scope(token, client.app) == { + "demo.panel_theme", + "demo.temperature", + } + + # A setting still buys nothing beyond itself. + assert ( + client.get( + f"{messages}/demo.unrelated/history", headers=panel_headers + ).status_code + == 403 + ) + + +def test_a_dashboard_setting_bound_to_the_wrong_type_is_refused( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """The same answer the server gives a mis-wired widget.""" + saved = _dashboard(client, superuser_token_headers, "panel_mistyped") + saved["settings"] = {"theme": {"value": "dark", "message": "a.b", "dtype": "float"}} + + refused = client.put( + f"{DASHBOARDS}/panel_mistyped", headers=superuser_token_headers, json=saved + ) + assert refused.status_code == 422, refused.text + + def test_unpairing_a_screen_leaves_its_panel_standing( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index 21f7513..9434a14 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -8,6 +8,7 @@ from fluksio.flow.dashboards import ( DashboardStore, PageDef, SectionDef, + SettingDef, WidgetDef, default_dashboard, ) @@ -353,3 +354,62 @@ def test_a_dashboard_is_cut_into_a_sane_number_of_columns(): for columns in (0, 49): with pytest.raises(ValueError): DashboardDef(name="house", columns=columns) + + +def test_a_setting_refuses_a_message_it_cannot_carry(): + """The same rule a widget binding is held to, from the document alone.""" + DashboardDef( + name="house", + settings={"theme": SettingDef(value="dark", message="a.b", dtype="str")}, + ) + DashboardDef( + name="house", + settings={"locked": SettingDef(value=False, message="a.b", dtype="bool")}, + ) + + with pytest.raises(ValueError): + DashboardDef( + name="house", + settings={"theme": SettingDef(value="dark", message="a.b", dtype="float")}, + ) + with pytest.raises(ValueError): + DashboardDef( + name="house", + settings={"locked": SettingDef(value=False, message="a.b", dtype="str")}, + ) + + +def test_an_unbound_setting_is_just_its_value(): + """The static case, which is what makes a dark panel cost no flow. + + No message means nothing to type-check and nothing for a panel to be + entitled to — and a name this build does not know is left alone rather + than refused, so an older installation reads a newer document. + """ + defn = DashboardDef( + name="house", + settings={ + "theme": SettingDef(value="dark"), + "someday": SettingDef(value=7, message="a.b", dtype="int"), + }, + ) + + assert defn.settings["theme"].value == "dark" + assert defn.setting_messages == ["a.b"] + + +def test_a_bound_setting_is_drawn_on_the_canvas(store: DashboardStore): + """A dashboard consuming a message is an endpoint like a tile is.""" + store.write( + DashboardDef( + name="house", + settings={"theme": SettingDef(value="system", message="home.theme")}, + ) + ) + + (binding,) = store.bindings_for("home") + assert binding["widget"] == "settings.theme" + assert binding["type"] == "setting" + assert binding["requires"] == ["home.theme"] + assert not binding["provides"] + assert store.bindings_for("other") == [] diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 4462d44..6e8aaf0 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -86,6 +86,44 @@ it, so swapping the store is a change to one flow and nothing else. The answer also states what it was computed for, so an answer to a different question is ignored rather than two charts overwriting each other's picture. +## Dashboard settings + +Most of what a dashboard carries is a widget: a tile bound to a message. Two +things are not, because they belong to the whole surface rather than to any +tile on it — and a screen bolted to a wall has nobody standing at it to set +them. + +| Setting | Is | Driven by | +|---|---|---| +| **Theme** | `System`, `Light` or `Dark` | a `str` message | +| **Lock** | read-only on or off | a `bool` message | + +Both work the same way, and both halves are optional: + +- **Just a value.** Set Theme to `Dark` and that dashboard is dark wherever it + is shown, whatever the device or the browser prefers. This costs no flow at + all, and it is what most wall panels want. +- **Driven by a message.** Pick one in **Driven by** and a flow takes the + setting over, exactly as it drives a tile. The value you set stays the + fallback: what the dashboard uses before the first message arrives, and + whenever the message is silent. + +A bound setting is type-checked like a widget binding — a Theme pointed at a +`float` is refused by the editor and by the server — and the panel's credential +is extended to it, so a paired screen may read its own theme message and +nothing further. + +There is no schedule field, on purpose. **A schedule is a node publishing to +the bound message**: an `inject` with a cron expression, feeding a `change` +node that maps the hour onto `"dark"` or `"light"`, is the whole of "dark after +sunset" — and the same channel then serves anything else you want to drive, +including locking a panel down remotely. + +Lock is a read-only *surface*, not a permission. The controls stay visible, +stop publishing and read as disabled, and the panel says **Read-only** in the +corner. What a paired screen is allowed to reach is still decided by its own +credential, below. + ## Showing one - `/view/{name}` — a browser tab pointed at one dashboard. Needs an ordinary @@ -110,9 +148,12 @@ What the screen holds is not a login. It reaches that panel's published dashboards and the messages its own widgets read or publish, and nothing else — a message no tile on it draws is refused in both directions. -It cannot be made strictly read-only, and that is honest rather than an -oversight: a querying chart publishes its request, and a control on a panel is -the reason you put one there. +The *credential* cannot be made strictly read-only, and that is honest rather +than an oversight: a querying chart publishes its request, and a control on a +panel is the reason you put one there. The dashboard's own **Lock** setting +above stops its controls publishing and can be driven by a flow, which is how +you quieten a screen remotely — but that is the surface behaving, not the +credential being narrowed. Deleting the panel revokes the credential and the assignment together, which is how you retire a device and what it showed. Unpairing revokes only the diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 361c7d9..17feaef 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -435,6 +435,13 @@ export const DashboardDef_InputSchema = { type: 'array', title: 'Pages' }, + settings: { + additionalProperties: { + '$ref': '#/components/schemas/SettingDef' + }, + type: 'object', + title: 'Settings' + }, version: { type: 'integer', title: 'Version', @@ -496,6 +503,13 @@ export const DashboardDef_OutputSchema = { type: 'array', title: 'Pages' }, + settings: { + additionalProperties: { + '$ref': '#/components/schemas/SettingDef' + }, + type: 'object', + title: 'Settings' + }, version: { type: 'integer', title: 'Version', @@ -2640,6 +2654,36 @@ export const SeriesPointSchema = { title: 'SeriesPoint' } as const; +export const SettingDefSchema = { + properties: { + value: { + title: 'Value' + }, + message: { + type: 'string', + title: 'Message', + default: '' + }, + dtype: { + type: 'string', + title: 'Dtype', + default: '' + } + }, + type: 'object', + title: 'SettingDef', + description: `One dashboard-wide setting: a value, and optionally where it comes from. + +Unbound — no \`\`message\`\` — the setting is simply \`\`value\`\`, which is what +makes a panel that is always dark cost no flow at all. Bound, a flow drives +it live and \`\`value\`\` is the fallback: what the dashboard uses until +something arrives, and whenever the message is silent. + +A schedule is not a third case. A node publishing to the bound message on a +cron *is* the schedule, which is the whole reason this is a channel rather +than a switching rule per setting.` +} as const; + export const ShareRequestSchema = { properties: { lib_name: { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 9479809..44f7efa 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -117,6 +117,9 @@ export type DashboardDef_Input = { canvas_height?: number; icon?: string; pages?: Array; + settings?: { + [key: string]: SettingDef; + }; version?: number; has_draft?: boolean; }; @@ -132,6 +135,9 @@ export type DashboardDef_Output = { canvas_height?: number; icon?: string; pages?: Array; + settings?: { + [key: string]: SettingDef; + }; version?: number; has_draft?: boolean; }; @@ -919,6 +925,24 @@ export type SeriesPoint = { avg_lag_ms: number; }; +/** + * One dashboard-wide setting: a value, and optionally where it comes from. + * + * Unbound — no ``message`` — the setting is simply ``value``, which is what + * makes a panel that is always dark cost no flow at all. Bound, a flow drives + * it live and ``value`` is the fallback: what the dashboard uses until + * something arrives, and whenever the message is silent. + * + * A schedule is not a third case. A node publishing to the bound message on a + * cron *is* the schedule, which is the whole reason this is a channel rather + * than a switching rule per setting. + */ +export type SettingDef = { + value?: unknown; + message?: string; + dtype?: string; +}; + export type ShareRequest = { lib_name: string; }; diff --git a/frontend/src/components/Dashboard/ColorWidget.tsx b/frontend/src/components/Dashboard/ColorWidget.tsx index b61c0cf..c025e5b 100644 --- a/frontend/src/components/Dashboard/ColorWidget.tsx +++ b/frontend/src/components/Dashboard/ColorWidget.tsx @@ -166,11 +166,13 @@ const handleAt = (hue: number) => ({ function Level({ label, value, + disabled, onChange, onCommit, }: { label: string value: number + disabled?: boolean onChange: (value: number) => void onCommit: () => void }) { @@ -185,6 +187,7 @@ function Level({ min={0} max={100} value={value} + disabled={disabled} className="h-11 w-full accent-[var(--primary)] md:h-8" onChange={(event) => onChange(Number(event.target.value))} // Only the release publishes, as the slider widget does: a drag would @@ -211,7 +214,7 @@ function Level({ * control that can announce one, and neither of them on a keyboard. */ export function ColorWidget({ widget, dashboard }: WidgetProps) { - const { target, value, send, pulse } = usePublish(widget, dashboard) + const { target, value, send, pulse, locked } = usePublish(widget, dashboard) // While dragging, the wheel follows the finger rather than the engine. const [draft, setDraft] = useState(null) if (!target) @@ -230,6 +233,7 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) { /** The hue under the pointer: where it is relative to the wheel's centre. */ const aim = (event: React.PointerEvent) => { + if (locked) return const box = event.currentTarget.getBoundingClientRect() const x = event.clientX - (box.left + box.width / 2) const y = event.clientY - (box.top + box.height / 2) @@ -252,7 +256,11 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) { circle, so it says what it is and answers the same keys. */}
{ + if (locked) return const step = event.key === "ArrowRight" || event.key === "ArrowUp" ? HUE_STEP @@ -302,12 +311,14 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) { setDraft([hue, next, brightness])} onCommit={commit} /> setDraft([hue, saturation, next])} onCommit={commit} /> diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 646ae31..92f67b5 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -78,6 +78,7 @@ import { usePublishDashboard, useSaveDashboard, } from "./queries" +import { useDashboardTheme } from "./settings" import { WIDGET_LABELS, WIDGET_SIZES, @@ -211,6 +212,10 @@ export function DashboardEditor({ // A phone reads the dashboard rather than arranges it, so the grid library // never mounts there. See DESIGN-GUIDELINES.md → Responsive. const stacked = useIsMobile() + // The dashboard's own theme, on the surface only: the shell around the + // canvas stays whatever the person editing chose for the app. Non-stacked, + // `CanvasSurface` states it on the canvas box itself. + const theme = useDashboardTheme(draft) const navigate = useNavigate() const queryClient = useQueryClient() const save = useSaveDashboard(dashboard.name) @@ -444,7 +449,12 @@ export function DashboardEditor({ ) : stacked ? ( // One column at the viewport's width. Edit mode still picks a widget and // opens its settings; only the arrangement is missing. -
+
* Scaling rather than reflowing is the point. A side panel opening, or a * narrower screen, changes only the scale — the arrangement being designed, * and the dot grid under it, stay the layout the panel will actually show. + * + * This box is also exactly what the dashboard's `theme` setting applies to: it + * is the panel, so a dashboard forced light or dark paints its own ground here + * and leaves the shell around it alone. Which is why it states a background at + * all — without one it would borrow whatever it was dropped into. */ export function CanvasSurface({ dashboard, @@ -124,6 +130,7 @@ export function CanvasSurface({ const { width, height } = canvasOf(dashboard) const scale = Math.min(box.width / width, box.height / height) + const theme = useDashboardTheme(dashboard) return (
@@ -131,7 +138,11 @@ export function CanvasSurface({ then move it. */} {scale > 0 ? (
- {widgets.map((widget) => ( -
- {renderWidget ? ( - renderWidget(widget) - ) : ( - - - - )} -
- ))} -
+ +
+ {widgets.map((widget) => ( +
+ {renderWidget ? ( + renderWidget(widget) + ) : ( + + + + )} +
+ ))} +
+
) } diff --git a/frontend/src/components/Dashboard/PanelSurface.tsx b/frontend/src/components/Dashboard/PanelSurface.tsx index 9f7efda..93c960b 100644 --- a/frontend/src/components/Dashboard/PanelSurface.tsx +++ b/frontend/src/components/Dashboard/PanelSurface.tsx @@ -1,8 +1,11 @@ +import { Lock } from "lucide-react" + import type { Dashboard } from "@/components/Dashboard/DashboardView" import { CanvasSurface, DashboardView, } from "@/components/Dashboard/DashboardView" +import { useDashboardLocked } from "@/components/Dashboard/settings" /** * One dashboard filling whatever screen it landed on. @@ -10,6 +13,10 @@ import { * The whole of what a wall panel draws, shared by the single-dashboard route * (`/view/{name}`) and the paired-panel one (`/panel/{id}`) so a device shows * the same thing either way — the second merely has a rail beside it. + * + * The dashboard's theme is stated by the route rather than here: on these two + * the screen *is* the dashboard, so it has to cover the letterbox and the rail + * as well as the canvas. */ export function PanelSurface({ dashboard, @@ -20,13 +27,48 @@ export function PanelSurface({ * its size, which reads as nothing at all. Stack it instead. */ stacked?: boolean }) { - if (stacked) return - - // The panel's own surface, scaled to fit. No dots: nothing is being - // arranged here. return ( - - {() => } - +
+ {stacked ? ( + + ) : ( + // The panel's own surface, scaled to fit. No dots: nothing is being + // arranged here. + + {() => } + + )} + +
+ ) +} + +/** + * What a locked dashboard looks like, beyond controls that read as disabled. + * + * Without it a read-only panel is a panel whose buttons do nothing, which + * reads as broken rather than as locked. Frosted chrome over content, like + * every other floating surface, and it says the state in words — a glyph on + * its own is not a label. + * + * Live, because `locked` may be driven by a flow: the notice appears and goes + * with the lock rather than with the page load. + */ +function LockNotice({ dashboard }: { dashboard: Dashboard }) { + // Read straight from the document rather than through `useLocked`: that + // provider sits inside the grid, and this notice is beside it. + const locked = useDashboardLocked(dashboard) + if (!locked) return null + return ( +
+ + Read-only +
) } diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 6085f6d..0652ca0 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query" import { Ban, ChevronDown, Plus, X } from "lucide-react" import { useState } from "react" -import type { MessageInfo, WidgetDef } from "@/client" +import type { MessageInfo, SettingDef, WidgetDef } from "@/client" import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker" import { PANEL_SECTION, @@ -33,6 +33,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget" import { MAX_SERIES, refreshFor } from "./ChartWidget" @@ -46,6 +47,13 @@ import { } from "./DashboardView" import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons" import { messageCatalogQueryOptions } from "./queries" +import { + SETTING_DTYPES, + type SettingName, + settingIssue, + settingOf, + THEME_CHOICES, +} from "./settings" import { acceptsDtype, INPUT_WIDGETS, @@ -78,13 +86,17 @@ function MessagePicker({ value, label, testId, + placeholder = "Pick a message", filter, onPick, }: { - kind: WidgetKind + /** Omitted where the slot is not a widget's at all — a dashboard setting + * binds by payload type alone, and hands in `filter` instead. */ + kind?: WidgetKind value: string label: string testId?: string + placeholder?: string /** What this slot takes, when the widget's own type does not decide it — * a querying chart asks with one shape and draws another. */ filter?: (message: MessageInfo) => boolean @@ -92,7 +104,11 @@ function MessagePicker({ }) { const { data } = useQuery(messageCatalogQueryOptions()) const catalog = data?.data ?? [] - const choices = filter ? catalog.filter(filter) : choicesFor(kind, catalog) + const choices = filter + ? catalog.filter(filter) + : kind + ? choicesFor(kind, catalog) + : catalog return (
@@ -107,7 +123,7 @@ function MessagePicker({ } > - + {choices.map((message) => ( @@ -944,6 +960,73 @@ export function WidgetPanel({ const sizeKey = (size: { width: number; height: number }) => `${size.width}x${size.height}` +/** + * The optional half of a setting: which message, if any, drives it. + * + * Deliberately drawn as an addition rather than a requirement — the placeholder + * says what leaving it alone means, and the value control above it is the whole + * setting until something is picked here. Binding is how a flow takes the + * setting over; a node publishing on a cron is what a schedule is in this + * system, so there is no scheduling UI to build. + * + * The picker records the payload type beside the name, which is what lets the + * pairing be judged from the document alone — the same rule a widget's binding + * is held to, and the same one the server enforces. + */ +function SettingBinding({ + name, + setting, + onChange, +}: { + name: SettingName + setting: SettingDef + onChange: (setting: SettingDef) => void +}) { + const want = SETTING_DTYPES[name] + const issue = settingIssue(name, setting) + return ( +
+
+
+ message.dtype === want} + onPick={(message, dtype) => + onChange({ ...setting, message, dtype }) + } + /> +
+ {setting.message ? ( + + ) : null} +
+ {issue ? ( +

+ {issue} +

+ ) : null} +
+ ) +} + +/** How a setting's own value reads in the "nothing is driving it" line. */ +function valueLabel(name: SettingName, value: unknown): string { + if (name === "locked") return value === true ? "read-only" : "editable" + const chosen = THEME_CHOICES.find(([option]) => option === value) + return (chosen?.[1] ?? "System").toLowerCase() +} + /** * The dashboard's own settings, in the panel its widgets use. * @@ -968,6 +1051,12 @@ export function DashboardPanel({ }) { const [confirmOpen, setConfirmOpen] = useState(false) const canvas = canvasOf(dashboard) + const theme = settingOf(dashboard, "theme") + const locked = settingOf(dashboard, "locked") + + /** Settings are a map, so one of them changing rewrites the whole of it. */ + const setSetting = (name: SettingName, setting: SettingDef) => + onChange({ settings: { ...(dashboard.settings ?? {}), [name]: setting } }) return ( <> @@ -1095,6 +1184,55 @@ export function DashboardPanel({

+
+ Theme + setSetting("theme", { ...theme, value })} + /> + setSetting("theme", setting)} + /> +

+ What this dashboard wears wherever it is shown — a screen on a + wall has nobody to set the device preference System otherwise + follows. Bind a message and a flow drives it instead: a node + publishing on a cron is what a schedule looks like here, and the + choice above stays the fallback. +

+
+ +
+ Lock +
+ Read-only + + setSetting("locked", { ...locked, value }) + } + /> +
+ setSetting("locked", setting)} + /> +

+ Locked, the controls on this dashboard are shown but stop + publishing, and the surface says so. It is a read-only surface + rather than a permission: what a paired screen may reach is still + decided by its own credential. +

+
+
Contents

diff --git a/frontend/src/components/Dashboard/publish.tsx b/frontend/src/components/Dashboard/publish.tsx index 0717531..3e06791 100644 --- a/frontend/src/components/Dashboard/publish.tsx +++ b/frontend/src/components/Dashboard/publish.tsx @@ -8,6 +8,7 @@ import { handleError } from "@/utils" // chunked per entry — so the sheet is pulled in wherever the pulse is drawn. import "./dashboard.css" import { usePublishMessage } from "./queries" +import { useLocked } from "./settings" /** * How long a control shows what it sent before falling back to the engine. @@ -35,6 +36,17 @@ function confirms(live: unknown, sent: unknown): boolean { * is refused, or on `HOLD_MS`; success is silent, because the echo is the * confirmation. * + * A locked dashboard is gated here and only here: every control publishes + * through this hook, so one check covers all of them and a control added later + * inherits it. `locked` is handed back so each control can also *read* as + * disabled — a dashboard that silently swallows a press looks broken rather + * than locked. + * + * ponytail: this is a read-only surface, not an authorisation boundary. The + * server still takes a publish from a panel credential whose dashboard says + * locked, because the credential's own allowlist is what bounds it. Making it + * a real lock means carrying the flag into `_panel_may`. + * * Its own module rather than `widgets.tsx`, which every widget file is * imported *by*: a control drawn in a file of its own can only reach this * without closing that circle if it does not sit there. @@ -42,6 +54,7 @@ function confirms(live: unknown, sent: unknown): boolean { export function usePublish(widget: WidgetDef, dashboard: string) { const cfg = (widget.config ?? {}) as Record const target = cfg.target == null ? "" : String(cfg.target) + const locked = useLocked() const publish = usePublishMessage() const live = useLiveValue(target || undefined) const { showErrorToast } = useCustomToast() @@ -62,8 +75,10 @@ export function usePublish(widget: WidgetDef, dashboard: string) { target, /** What the control draws: what it sent, until the engine answers. */ value: held ? held.value : live?.value, + /** Whether this dashboard is read-only; controls draw themselves disabled. */ + locked, send: (value: unknown) => { - if (!target) return + if (!target || locked) return setHeld({ value }) publish.mutate( { diff --git a/frontend/src/components/Dashboard/settings.tsx b/frontend/src/components/Dashboard/settings.tsx new file mode 100644 index 0000000..23b1911 --- /dev/null +++ b/frontend/src/components/Dashboard/settings.tsx @@ -0,0 +1,126 @@ +import { createContext, useContext } from "react" + +import type { DashboardDef_Output, SettingDef } from "@/client" +import { useLiveValue } from "@/components/Flow/liveStore" + +/** + * The dashboard's own settings channel. + * + * A widget is a receiver a dashboard *contains*; this is the dashboard itself + * as one. Each setting is a value plus an optional binding: + * + * - **Unbound** — no message — the setting is simply its value. That is what + * gives a wall panel which is always dark without a flow behind it. + * - **Bound** — a flow drives it live, and the stored value is the fallback: + * what the dashboard uses until something arrives, and whenever the message + * is silent. + * + * A schedule is not a third case. A node publishing to the bound message on a + * cron *is* the schedule here, which is the whole reason this is a channel + * rather than a switching rule bolted onto each setting. + * + * A bound setting is read the way a widget reads a value — the same live store + * and the same socket — so a panel's own settings messages are part of what + * its credential is entitled to (`flow/panels.py`, `messages_for`). + */ + +/** + * What a setting may be driven by, by payload type. The same table is enforced + * on the server (`app/flow/dashboards.py`); a name missing from it is a + * setting this build does not act on rather than an error. + */ +export const SETTING_DTYPES: Record = { + theme: "str", + locked: "bool", +} + +/** The settings this build actually wires up. */ +export type SettingName = "theme" | "locked" + +/** What `theme` may be set to. `system` follows whatever the device says. */ +export const THEME_CHOICES = [ + ["system", "System"], + ["light", "Light"], + ["dark", "Dark"], +] as const + +/** The generated client marks the map optional, because the server fills it in. */ +export function settingOf( + dashboard: DashboardDef_Output | undefined, + name: SettingName, +): SettingDef { + return dashboard?.settings?.[name] ?? {} +} + +/** + * What is wrong with a setting's binding, if anything. + * + * Judged from the document alone — the picker records the payload type beside + * the name — so this is the same rule `widgetIssue` holds a tile to, and the + * editor cannot author a document the server would refuse. + */ +export function settingIssue(name: string, setting: SettingDef): string | null { + const want = SETTING_DTYPES[name] + if (!want || !setting.message || !setting.dtype) return null + if (setting.dtype === want) return null + return `${setting.message} is a ${setting.dtype}; ${name} is driven by a ${want}.` +} + +/** What the setting is worth now: live if it is bound, stored otherwise. */ +function useSetting( + dashboard: DashboardDef_Output | undefined, + name: SettingName, +): unknown { + const setting = settingOf(dashboard, name) + // `undefined` while nothing has arrived, which is exactly when the stored + // value is meant to stand in. + const live = useLiveValue(setting.message || undefined) + return live?.value ?? setting.value +} + +/** + * The class that themes a dashboard's own surface, or `""` to follow the app. + * + * A class rather than the root, because inside the app shell this must not + * flip the chrome around it. `.light` and `.dark` both redefine the tokens on + * whatever carries them (`index.css`), so either direction works on a subtree. + */ +export function useDashboardTheme( + dashboard: DashboardDef_Output | undefined, +): "" | "light" | "dark" { + const value = useSetting(dashboard, "theme") + return value === "dark" || value === "light" ? value : "" +} + +/** Whether this dashboard is read-only, live if the setting is bound. */ +export function useDashboardLocked( + dashboard: DashboardDef_Output | undefined, +): boolean { + return useSetting(dashboard, "locked") === true +} + +const LockedContext = createContext(false) + +/** + * Marks everything drawn under it read-only. + * + * A context rather than a prop threaded through sixteen renderers: what a + * control needs to know is one bit, and the one place it is acted on is + * `usePublish`. Edit mode deliberately never mounts this — arranging a + * dashboard is not the same as using it. + */ +export function LockedProvider({ + dashboard, + children, +}: { + dashboard: DashboardDef_Output | undefined + children: React.ReactNode +}) { + const locked = useDashboardLocked(dashboard) + return ( + {children} + ) +} + +/** Whether the dashboard around this control is read-only. */ +export const useLocked = () => useContext(LockedContext) diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index f141b4a..1999cb3 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -569,7 +569,7 @@ function NotificationWidget({ widget }: WidgetProps) { function ButtonWidget({ widget, dashboard }: WidgetProps) { const cfg = config(widget) - const { target, send, pending, pulse } = usePublish(widget, dashboard) + const { target, send, pending, pulse, locked } = usePublish(widget, dashboard) if (!target) return // Nothing to hold: a button carries no reading, so the pulse and a refusal // are the whole of its feedback. @@ -579,7 +579,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {

@@ -693,7 +695,7 @@ function SliderTicks({ function SliderWidget({ widget, dashboard }: WidgetProps) { const cfg = config(widget) - const { target, value, send, pulse } = usePublish(widget, dashboard) + const { target, value, send, pulse, locked } = usePublish(widget, dashboard) const min = num(cfg.min, 0) const max = num(cfg.max, 100) const step = num(cfg.step, 1) @@ -721,6 +723,7 @@ function SliderWidget({ widget, dashboard }: WidgetProps) { step={step} value={current} aria-label={widget.title || target} + disabled={locked} className="h-11 w-full accent-[var(--primary)] md:h-8" onChange={(event) => setDragging(Number(event.target.value))} // Only the release publishes: dragging would otherwise send a value @@ -742,7 +745,7 @@ function SliderWidget({ widget, dashboard }: WidgetProps) { function InputWidget({ widget, dashboard }: WidgetProps) { const cfg = config(widget) - const { target, value, send, pulse } = usePublish(widget, dashboard) + const { target, value, send, pulse, locked } = usePublish(widget, dashboard) const [draft, setDraft] = useState(null) if (!target) return @@ -760,6 +763,7 @@ function InputWidget({ widget, dashboard }: WidgetProps) { value={draft ?? text(value)} type={asNumber ? "number" : "text"} aria-label={widget.title || target} + disabled={locked} onChange={(event) => setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => { @@ -778,7 +782,7 @@ function InputWidget({ widget, dashboard }: WidgetProps) { */ function DropdownWidget({ widget, dashboard }: WidgetProps) { const cfg = config(widget) - const { target, value, send, pulse } = usePublish(widget, dashboard) + const { target, value, send, pulse, locked } = usePublish(widget, dashboard) const options = (cfg.options ?? []) as { label?: string; value?: unknown }[] if (!target) return @@ -820,9 +824,10 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) { key={text(option.value)} type="button" aria-pressed={index === chosen} + disabled={locked} onClick={() => send(option.value)} className={cn( - "relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors md:h-8", + "relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors disabled:opacity-50 md:h-8", index === chosen ? "text-accent-foreground" : "text-muted-foreground hover:bg-accent/50", @@ -844,7 +849,11 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) { value={text(value)} onValueChange={(selected) => send(asOriginal(selected, options))} > - + diff --git a/frontend/src/index.css b/frontend/src/index.css index 94cbca4..9c9a2ec 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -98,6 +98,11 @@ * segment is therefore drawn inside a gutter of the fill rather than ever * bordering the track. */ +/* `.light` is the symmetric half of `.dark` below: both are plain classes, so + either themes a subtree as well as the whole document — which is what lets a + dashboard forced to one theme sit inside a shell on the other. `:root` is + listed second so the parity grep keeps its anchor line. */ +.light, :root { --background: #ffffff; --foreground: #333232; diff --git a/frontend/src/routes/panel.$id.tsx b/frontend/src/routes/panel.$id.tsx index 2ae83c7..42c38d8 100644 --- a/frontend/src/routes/panel.$id.tsx +++ b/frontend/src/routes/panel.$id.tsx @@ -8,6 +8,7 @@ import { dashboardQueryOptions, panelQueryOptions, } from "@/components/Dashboard/queries" +import { useDashboardTheme } from "@/components/Dashboard/settings" import { useFlowSocket } from "@/components/Flow/useFlowSocket" import { isLoggedIn } from "@/hooks/useAuth" import { useIsMobile } from "@/hooks/useMobile" @@ -55,6 +56,11 @@ function PanelRoute() { }) const stacked = useIsMobile() const rail = dashboards.length > 1 + // The screen is the dashboard here, so its theme covers the whole of it — + // the rail and the letterbox around a scaled canvas included. This is the + // surface the setting exists for: a panel in a room has no other way to be + // told which theme to wear. + const theme = useDashboardTheme(dashboard as Dashboard | undefined) if (panel && dashboards.length === 0) { return ( @@ -69,7 +75,8 @@ function PanelRoute() { return (
return (