Add the dashboard settings channel, wired for theme and lock
A dashboard could only ever receive as a set of tiles. This adds the dashboard itself as a receiver: `settings` maps a name to a value plus an optional binding. Unbound, the setting is simply its value — a wall panel that is always dark costs no flow. Bound, a flow drives it live and the value is the fallback. Two settings are wired: `theme` (system/light/dark) and `locked` (read-only). There is no schedule field on purpose — a node publishing to the bound message on a cron is what a schedule is here, which is the point of a channel. - `messages_for()` now walks a dashboard's bound settings as well as its widgets' bindings. Without this a paired screen is refused its own theme message, on the one surface the setting exists for; it bounds the socket too. - `locked` is gated in `usePublish`, so every control inherits it, and each control also draws itself disabled — a dead button reads as broken otherwise. The panel surface says Read-only in the corner. - The theme is a class on the dashboard's own surface, never the root: inside the app shell it must not flip the chrome. `.light` gains the tokens `.dark` already had (mirrored in the index repo) so both directions work on a subtree. - Settings bindings are type-checked from the document alone, the rule widget bindings follow, and mirrored on the server. - A bound setting is drawn on the flow canvas as a dashboard-level endpoint. - The demo's house flow now publishes `home.panel_theme`, which the demo dashboard's theme binds to: the panel goes dark after sunset, at no tile cost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
@@ -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):
|
class Placement(BaseModel):
|
||||||
"""Where a widget sits in its section's grid, in grid units."""
|
"""Where a widget sits in its section's grid, in grid units."""
|
||||||
|
|
||||||
@@ -311,6 +347,11 @@ class DashboardDef(BaseModel):
|
|||||||
#: letters of the title.
|
#: letters of the title.
|
||||||
icon: str = ""
|
icon: str = ""
|
||||||
pages: list[PageDef] = Field(default_factory=list)
|
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.
|
#: Bumped on every save; a save based on an older one is refused.
|
||||||
version: int = 1
|
version: int = 1
|
||||||
#: Whether there are unpublished changes. Reported by the store on read,
|
#: 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:
|
def _check_name(cls, value: str) -> str:
|
||||||
return _validate_name(value)
|
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
|
@property
|
||||||
def widgets(self) -> list[WidgetDef]:
|
def widgets(self) -> list[WidgetDef]:
|
||||||
return [w for p in self.pages for s in p.sections for w in s.widgets]
|
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):
|
class DashboardSummary(BaseModel):
|
||||||
"""A dashboard in a list, without its contents."""
|
"""A dashboard in a list, without its contents."""
|
||||||
@@ -544,6 +610,25 @@ class DashboardStore:
|
|||||||
defn = DashboardDef.model_validate_json(path.read_text())
|
defn = DashboardDef.model_validate_json(path.read_text())
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
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:
|
for widget in defn.widgets:
|
||||||
# A control produces the message; a tile consumes it.
|
# A control produces the message; a tile consumes it.
|
||||||
produces = widget.target if widget.target.startswith(prefix) else ""
|
produces = widget.target if widget.target.startswith(prefix) else ""
|
||||||
@@ -599,6 +684,7 @@ __all__ = [
|
|||||||
"DASHBOARD_DIR",
|
"DASHBOARD_DIR",
|
||||||
"HISTORY_CAP",
|
"HISTORY_CAP",
|
||||||
"INPUT_WIDGETS",
|
"INPUT_WIDGETS",
|
||||||
|
"SETTING_DTYPES",
|
||||||
"WIDGET_DTYPES",
|
"WIDGET_DTYPES",
|
||||||
"DashboardDef",
|
"DashboardDef",
|
||||||
"DashboardExists",
|
"DashboardExists",
|
||||||
@@ -609,6 +695,7 @@ __all__ = [
|
|||||||
"PageDef",
|
"PageDef",
|
||||||
"Placement",
|
"Placement",
|
||||||
"SectionDef",
|
"SectionDef",
|
||||||
|
"SettingDef",
|
||||||
"WidgetDef",
|
"WidgetDef",
|
||||||
"default_dashboard",
|
"default_dashboard",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -85,11 +85,15 @@ def find(panel_id: str) -> PanelDef | None:
|
|||||||
|
|
||||||
|
|
||||||
def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
|
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
|
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
|
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".
|
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)
|
panel = find(panel_id)
|
||||||
if panel is None:
|
if panel is None:
|
||||||
@@ -100,6 +104,7 @@ def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
|
|||||||
defn = store.read(dashboard)
|
defn = store.read(dashboard)
|
||||||
except DashboardNotFound:
|
except DashboardNotFound:
|
||||||
continue
|
continue
|
||||||
|
names.update(defn.setting_messages)
|
||||||
for widget in defn.widgets:
|
for widget in defn.widgets:
|
||||||
names.update(widget.messages)
|
names.update(widget.messages)
|
||||||
if widget.target:
|
if widget.target:
|
||||||
|
|||||||
@@ -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(
|
def test_unpairing_a_screen_leaves_its_panel_standing(
|
||||||
client: TestClient, superuser_token_headers: dict[str, str]
|
client: TestClient, superuser_token_headers: dict[str, str]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fluksio.flow.dashboards import (
|
|||||||
DashboardStore,
|
DashboardStore,
|
||||||
PageDef,
|
PageDef,
|
||||||
SectionDef,
|
SectionDef,
|
||||||
|
SettingDef,
|
||||||
WidgetDef,
|
WidgetDef,
|
||||||
default_dashboard,
|
default_dashboard,
|
||||||
)
|
)
|
||||||
@@ -353,3 +354,62 @@ 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):
|
||||||
DashboardDef(name="house", columns=columns)
|
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") == []
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## Showing one
|
||||||
|
|
||||||
- `/view/{name}` — a browser tab pointed at one dashboard. Needs an ordinary
|
- `/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 —
|
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.
|
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
|
The *credential* cannot be made strictly read-only, and that is honest rather
|
||||||
oversight: a querying chart publishes its request, and a control on a panel is
|
than an oversight: a querying chart publishes its request, and a control on a
|
||||||
the reason you put one there.
|
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
|
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
|
how you retire a device and what it showed. Unpairing revokes only the
|
||||||
|
|||||||
@@ -435,6 +435,13 @@ export const DashboardDef_InputSchema = {
|
|||||||
type: 'array',
|
type: 'array',
|
||||||
title: 'Pages'
|
title: 'Pages'
|
||||||
},
|
},
|
||||||
|
settings: {
|
||||||
|
additionalProperties: {
|
||||||
|
'$ref': '#/components/schemas/SettingDef'
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
title: 'Settings'
|
||||||
|
},
|
||||||
version: {
|
version: {
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
title: 'Version',
|
title: 'Version',
|
||||||
@@ -496,6 +503,13 @@ export const DashboardDef_OutputSchema = {
|
|||||||
type: 'array',
|
type: 'array',
|
||||||
title: 'Pages'
|
title: 'Pages'
|
||||||
},
|
},
|
||||||
|
settings: {
|
||||||
|
additionalProperties: {
|
||||||
|
'$ref': '#/components/schemas/SettingDef'
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
title: 'Settings'
|
||||||
|
},
|
||||||
version: {
|
version: {
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
title: 'Version',
|
title: 'Version',
|
||||||
@@ -2640,6 +2654,36 @@ export const SeriesPointSchema = {
|
|||||||
title: 'SeriesPoint'
|
title: 'SeriesPoint'
|
||||||
} as const;
|
} 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 = {
|
export const ShareRequestSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
lib_name: {
|
lib_name: {
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ export type DashboardDef_Input = {
|
|||||||
canvas_height?: number;
|
canvas_height?: number;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
pages?: Array<PageDef_Input>;
|
pages?: Array<PageDef_Input>;
|
||||||
|
settings?: {
|
||||||
|
[key: string]: SettingDef;
|
||||||
|
};
|
||||||
version?: number;
|
version?: number;
|
||||||
has_draft?: boolean;
|
has_draft?: boolean;
|
||||||
};
|
};
|
||||||
@@ -132,6 +135,9 @@ export type DashboardDef_Output = {
|
|||||||
canvas_height?: number;
|
canvas_height?: number;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
pages?: Array<PageDef_Output>;
|
pages?: Array<PageDef_Output>;
|
||||||
|
settings?: {
|
||||||
|
[key: string]: SettingDef;
|
||||||
|
};
|
||||||
version?: number;
|
version?: number;
|
||||||
has_draft?: boolean;
|
has_draft?: boolean;
|
||||||
};
|
};
|
||||||
@@ -919,6 +925,24 @@ export type SeriesPoint = {
|
|||||||
avg_lag_ms: number;
|
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 = {
|
export type ShareRequest = {
|
||||||
lib_name: string;
|
lib_name: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -166,11 +166,13 @@ const handleAt = (hue: number) => ({
|
|||||||
function Level({
|
function Level({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
onCommit,
|
onCommit,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
value: number
|
value: number
|
||||||
|
disabled?: boolean
|
||||||
onChange: (value: number) => void
|
onChange: (value: number) => void
|
||||||
onCommit: () => void
|
onCommit: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -185,6 +187,7 @@ function Level({
|
|||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
className="h-11 w-full accent-[var(--primary)] md:h-8"
|
className="h-11 w-full accent-[var(--primary)] md:h-8"
|
||||||
onChange={(event) => onChange(Number(event.target.value))}
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
// Only the release publishes, as the slider widget does: a drag would
|
// 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.
|
* control that can announce one, and neither of them on a keyboard.
|
||||||
*/
|
*/
|
||||||
export function ColorWidget({ widget, dashboard }: WidgetProps) {
|
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.
|
// While dragging, the wheel follows the finger rather than the engine.
|
||||||
const [draft, setDraft] = useState<Triple | null>(null)
|
const [draft, setDraft] = useState<Triple | null>(null)
|
||||||
if (!target)
|
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. */
|
/** The hue under the pointer: where it is relative to the wheel's centre. */
|
||||||
const aim = (event: React.PointerEvent<HTMLDivElement>) => {
|
const aim = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (locked) return
|
||||||
const box = event.currentTarget.getBoundingClientRect()
|
const box = event.currentTarget.getBoundingClientRect()
|
||||||
const x = event.clientX - (box.left + box.width / 2)
|
const x = event.clientX - (box.left + box.width / 2)
|
||||||
const y = event.clientY - (box.top + box.height / 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. */}
|
circle, so it says what it is and answers the same keys. */}
|
||||||
<div
|
<div
|
||||||
role="slider"
|
role="slider"
|
||||||
tabIndex={0}
|
// Not a native control, so the state it is in is said rather
|
||||||
|
// than inherited — and the ring keeps its colours, which are the
|
||||||
|
// reading, while the handle stops answering.
|
||||||
|
tabIndex={locked ? -1 : 0}
|
||||||
|
aria-disabled={locked || undefined}
|
||||||
aria-label={`${name} hue`}
|
aria-label={`${name} hue`}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={359}
|
aria-valuemax={359}
|
||||||
@@ -271,6 +279,7 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
}}
|
}}
|
||||||
onPointerUp={commit}
|
onPointerUp={commit}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
if (locked) return
|
||||||
const step =
|
const step =
|
||||||
event.key === "ArrowRight" || event.key === "ArrowUp"
|
event.key === "ArrowRight" || event.key === "ArrowUp"
|
||||||
? HUE_STEP
|
? HUE_STEP
|
||||||
@@ -302,12 +311,14 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
<Level
|
<Level
|
||||||
label="Saturation"
|
label="Saturation"
|
||||||
value={saturation}
|
value={saturation}
|
||||||
|
disabled={locked}
|
||||||
onChange={(next) => setDraft([hue, next, brightness])}
|
onChange={(next) => setDraft([hue, next, brightness])}
|
||||||
onCommit={commit}
|
onCommit={commit}
|
||||||
/>
|
/>
|
||||||
<Level
|
<Level
|
||||||
label="Brightness"
|
label="Brightness"
|
||||||
value={brightness}
|
value={brightness}
|
||||||
|
disabled={locked}
|
||||||
onChange={(next) => setDraft([hue, saturation, next])}
|
onChange={(next) => setDraft([hue, saturation, next])}
|
||||||
onCommit={commit}
|
onCommit={commit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import {
|
|||||||
usePublishDashboard,
|
usePublishDashboard,
|
||||||
useSaveDashboard,
|
useSaveDashboard,
|
||||||
} from "./queries"
|
} from "./queries"
|
||||||
|
import { useDashboardTheme } from "./settings"
|
||||||
import {
|
import {
|
||||||
WIDGET_LABELS,
|
WIDGET_LABELS,
|
||||||
WIDGET_SIZES,
|
WIDGET_SIZES,
|
||||||
@@ -211,6 +212,10 @@ export function DashboardEditor({
|
|||||||
// A phone reads the dashboard rather than arranges it, so the grid library
|
// A phone reads the dashboard rather than arranges it, so the grid library
|
||||||
// never mounts there. See DESIGN-GUIDELINES.md → Responsive.
|
// never mounts there. See DESIGN-GUIDELINES.md → Responsive.
|
||||||
const stacked = useIsMobile()
|
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 navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const save = useSaveDashboard(dashboard.name)
|
const save = useSaveDashboard(dashboard.name)
|
||||||
@@ -444,7 +449,12 @@ export function DashboardEditor({
|
|||||||
) : stacked ? (
|
) : stacked ? (
|
||||||
// One column at the viewport's width. Edit mode still picks a widget and
|
// One column at the viewport's width. Edit mode still picks a widget and
|
||||||
// opens its settings; only the arrangement is missing.
|
// opens its settings; only the arrangement is missing.
|
||||||
<div className="h-full overflow-y-auto">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full overflow-y-auto bg-background text-foreground",
|
||||||
|
theme,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<DashboardView
|
<DashboardView
|
||||||
dashboard={draft}
|
dashboard={draft}
|
||||||
stacked
|
stacked
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
} from "@/client"
|
} from "@/client"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import "./dashboard.css"
|
import "./dashboard.css"
|
||||||
|
import { LockedProvider, useDashboardTheme } from "./settings"
|
||||||
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
|
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
|
||||||
|
|
||||||
export type Dashboard = DashboardDef_Output
|
export type Dashboard = DashboardDef_Output
|
||||||
@@ -95,6 +96,11 @@ export const rowsOf = (dashboard: Dashboard) =>
|
|||||||
* Scaling rather than reflowing is the point. A side panel opening, or a
|
* Scaling rather than reflowing is the point. A side panel opening, or a
|
||||||
* narrower screen, changes only the scale — the arrangement being designed,
|
* narrower screen, changes only the scale — the arrangement being designed,
|
||||||
* and the dot grid under it, stay the layout the panel will actually show.
|
* 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({
|
export function CanvasSurface({
|
||||||
dashboard,
|
dashboard,
|
||||||
@@ -124,6 +130,7 @@ export function CanvasSurface({
|
|||||||
|
|
||||||
const { width, height } = canvasOf(dashboard)
|
const { width, height } = canvasOf(dashboard)
|
||||||
const scale = Math.min(box.width / width, box.height / height)
|
const scale = Math.min(box.width / width, box.height / height)
|
||||||
|
const theme = useDashboardTheme(dashboard)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative size-full overflow-hidden">
|
<div ref={ref} className="relative size-full overflow-hidden">
|
||||||
@@ -131,7 +138,11 @@ export function CanvasSurface({
|
|||||||
then move it. */}
|
then move it. */}
|
||||||
{scale > 0 ? (
|
{scale > 0 ? (
|
||||||
<div
|
<div
|
||||||
className={cn("absolute overflow-hidden", dots && "dot-canvas")}
|
className={cn(
|
||||||
|
"absolute overflow-hidden bg-background text-foreground",
|
||||||
|
theme,
|
||||||
|
dots && "dot-canvas",
|
||||||
|
)}
|
||||||
data-testid="canvas-surface"
|
data-testid="canvas-surface"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
@@ -292,6 +303,7 @@ export function DashboardView({
|
|||||||
const columns = columnsOf(dashboard)
|
const columns = columnsOf(dashboard)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<LockedProvider dashboard={dashboard}>
|
||||||
<div
|
<div
|
||||||
className={cn("widget-grid", stacked && "widget-stacked")}
|
className={cn("widget-grid", stacked && "widget-stacked")}
|
||||||
data-placed={isPlaced(widgets) || undefined}
|
data-placed={isPlaced(widgets) || undefined}
|
||||||
@@ -320,5 +332,6 @@ export function DashboardView({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</LockedProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { Lock } from "lucide-react"
|
||||||
|
|
||||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||||
import {
|
import {
|
||||||
CanvasSurface,
|
CanvasSurface,
|
||||||
DashboardView,
|
DashboardView,
|
||||||
} from "@/components/Dashboard/DashboardView"
|
} from "@/components/Dashboard/DashboardView"
|
||||||
|
import { useDashboardLocked } from "@/components/Dashboard/settings"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One dashboard filling whatever screen it landed on.
|
* 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
|
* 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
|
* (`/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 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({
|
export function PanelSurface({
|
||||||
dashboard,
|
dashboard,
|
||||||
@@ -20,13 +27,48 @@ export function PanelSurface({
|
|||||||
* its size, which reads as nothing at all. Stack it instead. */
|
* its size, which reads as nothing at all. Stack it instead. */
|
||||||
stacked?: boolean
|
stacked?: boolean
|
||||||
}) {
|
}) {
|
||||||
if (stacked) return <DashboardView dashboard={dashboard} stacked />
|
return (
|
||||||
|
<div className="relative size-full">
|
||||||
|
{stacked ? (
|
||||||
|
<DashboardView dashboard={dashboard} stacked />
|
||||||
|
) : (
|
||||||
// The panel's own surface, scaled to fit. No dots: nothing is being
|
// The panel's own surface, scaled to fit. No dots: nothing is being
|
||||||
// arranged here.
|
// arranged here.
|
||||||
return (
|
|
||||||
<CanvasSurface dashboard={dashboard}>
|
<CanvasSurface dashboard={dashboard}>
|
||||||
{() => <DashboardView dashboard={dashboard} />}
|
{() => <DashboardView dashboard={dashboard} />}
|
||||||
</CanvasSurface>
|
</CanvasSurface>
|
||||||
|
)}
|
||||||
|
<LockNotice dashboard={dashboard} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div
|
||||||
|
// Announced rather than merely drawn: `locked` may be driven by a flow,
|
||||||
|
// so the state can change under someone already looking at the page.
|
||||||
|
aria-live="polite"
|
||||||
|
data-testid="dashboard-locked"
|
||||||
|
className="pointer-events-none absolute bottom-0 right-0 flex items-center gap-1.5 rounded-full border border-border bg-card/80 px-3 py-1.5 text-sm text-muted-foreground shadow-e2 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<Lock className="size-4" aria-hidden />
|
||||||
|
Read-only
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"
|
|||||||
import { Ban, ChevronDown, Plus, X } from "lucide-react"
|
import { Ban, ChevronDown, Plus, X } from "lucide-react"
|
||||||
import { useState } from "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 { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
|
||||||
import {
|
import {
|
||||||
PANEL_SECTION,
|
PANEL_SECTION,
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
|
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
|
||||||
import { MAX_SERIES, refreshFor } from "./ChartWidget"
|
import { MAX_SERIES, refreshFor } from "./ChartWidget"
|
||||||
@@ -46,6 +47,13 @@ import {
|
|||||||
} from "./DashboardView"
|
} from "./DashboardView"
|
||||||
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
|
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
|
||||||
import { messageCatalogQueryOptions } from "./queries"
|
import { messageCatalogQueryOptions } from "./queries"
|
||||||
|
import {
|
||||||
|
SETTING_DTYPES,
|
||||||
|
type SettingName,
|
||||||
|
settingIssue,
|
||||||
|
settingOf,
|
||||||
|
THEME_CHOICES,
|
||||||
|
} from "./settings"
|
||||||
import {
|
import {
|
||||||
acceptsDtype,
|
acceptsDtype,
|
||||||
INPUT_WIDGETS,
|
INPUT_WIDGETS,
|
||||||
@@ -78,13 +86,17 @@ function MessagePicker({
|
|||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
testId,
|
testId,
|
||||||
|
placeholder = "Pick a message",
|
||||||
filter,
|
filter,
|
||||||
onPick,
|
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
|
value: string
|
||||||
label: string
|
label: string
|
||||||
testId?: string
|
testId?: string
|
||||||
|
placeholder?: string
|
||||||
/** What this slot takes, when the widget's own type does not decide it —
|
/** What this slot takes, when the widget's own type does not decide it —
|
||||||
* a querying chart asks with one shape and draws another. */
|
* a querying chart asks with one shape and draws another. */
|
||||||
filter?: (message: MessageInfo) => boolean
|
filter?: (message: MessageInfo) => boolean
|
||||||
@@ -92,7 +104,11 @@ function MessagePicker({
|
|||||||
}) {
|
}) {
|
||||||
const { data } = useQuery(messageCatalogQueryOptions())
|
const { data } = useQuery(messageCatalogQueryOptions())
|
||||||
const catalog = data?.data ?? []
|
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 (
|
return (
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
@@ -107,7 +123,7 @@ function MessagePicker({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger data-testid={testId}>
|
<SelectTrigger data-testid={testId}>
|
||||||
<SelectValue placeholder="Pick a message" />
|
<SelectValue placeholder={placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{choices.map((message) => (
|
{choices.map((message) => (
|
||||||
@@ -944,6 +960,73 @@ export function WidgetPanel({
|
|||||||
const sizeKey = (size: { width: number; height: number }) =>
|
const sizeKey = (size: { width: number; height: number }) =>
|
||||||
`${size.width}x${size.height}`
|
`${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 (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<MessagePicker
|
||||||
|
value={str(setting.message)}
|
||||||
|
label="Driven by"
|
||||||
|
testId={`dashboard-${name}-message`}
|
||||||
|
placeholder={`Nothing — always ${valueLabel(name, setting.value)}`}
|
||||||
|
filter={(message) => message.dtype === want}
|
||||||
|
onPick={(message, dtype) =>
|
||||||
|
onChange({ ...setting, message, dtype })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{setting.message ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={`Stop driving ${name}`}
|
||||||
|
data-testid={`dashboard-${name}-unbind`}
|
||||||
|
onClick={() => onChange({ value: setting.value })}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{issue ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{issue}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.
|
* The dashboard's own settings, in the panel its widgets use.
|
||||||
*
|
*
|
||||||
@@ -968,6 +1051,12 @@ export function DashboardPanel({
|
|||||||
}) {
|
}) {
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
const canvas = canvasOf(dashboard)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1095,6 +1184,55 @@ export function DashboardPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<span className={PANEL_SECTION}>Theme</span>
|
||||||
|
<Segmented
|
||||||
|
value={str(theme.value) || "system"}
|
||||||
|
options={THEME_CHOICES}
|
||||||
|
label="Dashboard theme"
|
||||||
|
testId="dashboard-theme"
|
||||||
|
onChange={(value) => setSetting("theme", { ...theme, value })}
|
||||||
|
/>
|
||||||
|
<SettingBinding
|
||||||
|
name="theme"
|
||||||
|
setting={theme}
|
||||||
|
onChange={(setting) => setSetting("theme", setting)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<span className={PANEL_SECTION}>Lock</span>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
Read-only
|
||||||
|
<Switch
|
||||||
|
checked={locked.value === true}
|
||||||
|
aria-label="Read-only"
|
||||||
|
data-testid="dashboard-lock"
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
setSetting("locked", { ...locked, value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SettingBinding
|
||||||
|
name="locked"
|
||||||
|
setting={locked}
|
||||||
|
onChange={(setting) => setSetting("locked", setting)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<span className={PANEL_SECTION}>Contents</span>
|
<span className={PANEL_SECTION}>Contents</span>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { handleError } from "@/utils"
|
|||||||
// chunked per entry — so the sheet is pulled in wherever the pulse is drawn.
|
// chunked per entry — so the sheet is pulled in wherever the pulse is drawn.
|
||||||
import "./dashboard.css"
|
import "./dashboard.css"
|
||||||
import { usePublishMessage } from "./queries"
|
import { usePublishMessage } from "./queries"
|
||||||
|
import { useLocked } from "./settings"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a control shows what it sent before falling back to the engine.
|
* 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
|
* is refused, or on `HOLD_MS`; success is silent, because the echo is the
|
||||||
* confirmation.
|
* 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
|
* 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
|
* 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.
|
* 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) {
|
export function usePublish(widget: WidgetDef, dashboard: string) {
|
||||||
const cfg = (widget.config ?? {}) as Record<string, unknown>
|
const cfg = (widget.config ?? {}) as Record<string, unknown>
|
||||||
const target = cfg.target == null ? "" : String(cfg.target)
|
const target = cfg.target == null ? "" : String(cfg.target)
|
||||||
|
const locked = useLocked()
|
||||||
const publish = usePublishMessage()
|
const publish = usePublishMessage()
|
||||||
const live = useLiveValue(target || undefined)
|
const live = useLiveValue(target || undefined)
|
||||||
const { showErrorToast } = useCustomToast()
|
const { showErrorToast } = useCustomToast()
|
||||||
@@ -62,8 +75,10 @@ export function usePublish(widget: WidgetDef, dashboard: string) {
|
|||||||
target,
|
target,
|
||||||
/** What the control draws: what it sent, until the engine answers. */
|
/** What the control draws: what it sent, until the engine answers. */
|
||||||
value: held ? held.value : live?.value,
|
value: held ? held.value : live?.value,
|
||||||
|
/** Whether this dashboard is read-only; controls draw themselves disabled. */
|
||||||
|
locked,
|
||||||
send: (value: unknown) => {
|
send: (value: unknown) => {
|
||||||
if (!target) return
|
if (!target || locked) return
|
||||||
setHeld({ value })
|
setHeld({ value })
|
||||||
publish.mutate(
|
publish.mutate(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<LockedContext.Provider value={locked}>{children}</LockedContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the dashboard around this control is read-only. */
|
||||||
|
export const useLocked = () => useContext(LockedContext)
|
||||||
@@ -569,7 +569,7 @@ function NotificationWidget({ widget }: WidgetProps) {
|
|||||||
|
|
||||||
function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, send, pending, pulse } = usePublish(widget, dashboard)
|
const { target, send, pending, pulse, locked } = usePublish(widget, dashboard)
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
// Nothing to hold: a button carries no reading, so the pulse and a refusal
|
// Nothing to hold: a button carries no reading, so the pulse and a refusal
|
||||||
// are the whole of its feedback.
|
// are the whole of its feedback.
|
||||||
@@ -579,7 +579,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full min-w-0"
|
className="w-full min-w-0"
|
||||||
disabled={pending}
|
disabled={pending || locked}
|
||||||
onClick={() => send(cfg.value ?? true)}
|
onClick={() => send(cfg.value ?? true)}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
@@ -598,7 +598,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
*/
|
*/
|
||||||
function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, value, send, pulse } = usePublish(widget, dashboard)
|
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
const on = value === true
|
const on = value === true
|
||||||
@@ -610,6 +610,7 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
className="w-full min-w-0"
|
className="w-full min-w-0"
|
||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
aria-label={widget.title || target}
|
aria-label={widget.title || target}
|
||||||
|
disabled={locked}
|
||||||
onClick={() => send(!on)}
|
onClick={() => send(!on)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{on ? "On" : "Off"}</span>
|
<span className="truncate">{on ? "On" : "Off"}</span>
|
||||||
@@ -622,6 +623,7 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={on}
|
checked={on}
|
||||||
aria-label={widget.title || target}
|
aria-label={widget.title || target}
|
||||||
|
disabled={locked}
|
||||||
onCheckedChange={(checked) => send(checked)}
|
onCheckedChange={(checked) => send(checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -693,7 +695,7 @@ function SliderTicks({
|
|||||||
|
|
||||||
function SliderWidget({ widget, dashboard }: WidgetProps) {
|
function SliderWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
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 min = num(cfg.min, 0)
|
||||||
const max = num(cfg.max, 100)
|
const max = num(cfg.max, 100)
|
||||||
const step = num(cfg.step, 1)
|
const step = num(cfg.step, 1)
|
||||||
@@ -721,6 +723,7 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
step={step}
|
step={step}
|
||||||
value={current}
|
value={current}
|
||||||
aria-label={widget.title || target}
|
aria-label={widget.title || target}
|
||||||
|
disabled={locked}
|
||||||
className="h-11 w-full accent-[var(--primary)] md:h-8"
|
className="h-11 w-full accent-[var(--primary)] md:h-8"
|
||||||
onChange={(event) => setDragging(Number(event.target.value))}
|
onChange={(event) => setDragging(Number(event.target.value))}
|
||||||
// Only the release publishes: dragging would otherwise send a 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) {
|
function InputWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
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<string | null>(null)
|
const [draft, setDraft] = useState<string | null>(null)
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
@@ -760,6 +763,7 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
value={draft ?? text(value)}
|
value={draft ?? text(value)}
|
||||||
type={asNumber ? "number" : "text"}
|
type={asNumber ? "number" : "text"}
|
||||||
aria-label={widget.title || target}
|
aria-label={widget.title || target}
|
||||||
|
disabled={locked}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -778,7 +782,7 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
*/
|
*/
|
||||||
function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
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 }[]
|
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||||
if (!target) return <Unbound />
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
@@ -820,9 +824,10 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
key={text(option.value)}
|
key={text(option.value)}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={index === chosen}
|
aria-pressed={index === chosen}
|
||||||
|
disabled={locked}
|
||||||
onClick={() => send(option.value)}
|
onClick={() => send(option.value)}
|
||||||
className={cn(
|
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
|
index === chosen
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground hover:bg-accent/50",
|
: "text-muted-foreground hover:bg-accent/50",
|
||||||
@@ -844,7 +849,11 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
|||||||
value={text(value)}
|
value={text(value)}
|
||||||
onValueChange={(selected) => send(asOriginal(selected, options))}
|
onValueChange={(selected) => send(asOriginal(selected, options))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full" aria-label={widget.title || target}>
|
<SelectTrigger
|
||||||
|
className="w-full"
|
||||||
|
aria-label={widget.title || target}
|
||||||
|
disabled={locked}
|
||||||
|
>
|
||||||
<SelectValue placeholder="Choose" />
|
<SelectValue placeholder="Choose" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -98,6 +98,11 @@
|
|||||||
* segment is therefore drawn inside a gutter of the fill rather than ever
|
* segment is therefore drawn inside a gutter of the fill rather than ever
|
||||||
* bordering the track.
|
* 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 {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #333232;
|
--foreground: #333232;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
dashboardQueryOptions,
|
dashboardQueryOptions,
|
||||||
panelQueryOptions,
|
panelQueryOptions,
|
||||||
} from "@/components/Dashboard/queries"
|
} from "@/components/Dashboard/queries"
|
||||||
|
import { useDashboardTheme } from "@/components/Dashboard/settings"
|
||||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||||
import { isLoggedIn } from "@/hooks/useAuth"
|
import { isLoggedIn } from "@/hooks/useAuth"
|
||||||
import { useIsMobile } from "@/hooks/useMobile"
|
import { useIsMobile } from "@/hooks/useMobile"
|
||||||
@@ -55,6 +56,11 @@ function PanelRoute() {
|
|||||||
})
|
})
|
||||||
const stacked = useIsMobile()
|
const stacked = useIsMobile()
|
||||||
const rail = dashboards.length > 1
|
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) {
|
if (panel && dashboards.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -69,7 +75,8 @@ function PanelRoute() {
|
|||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative h-svh w-full p-4",
|
"relative h-svh w-full bg-background p-4 text-foreground",
|
||||||
|
theme,
|
||||||
stacked ? "overflow-y-auto" : "overflow-hidden",
|
stacked ? "overflow-y-auto" : "overflow-hidden",
|
||||||
)}
|
)}
|
||||||
style={rail ? { paddingLeft: RAIL_INSET } : undefined}
|
style={rail ? { paddingLeft: RAIL_INSET } : undefined}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { createFileRoute, redirect } from "@tanstack/react-router"
|
|||||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||||
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
|
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
|
||||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||||
|
import { useDashboardTheme } from "@/components/Dashboard/settings"
|
||||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||||
import { isLoggedIn } from "@/hooks/useAuth"
|
import { isLoggedIn } from "@/hooks/useAuth"
|
||||||
import { useIsMobile } from "@/hooks/useMobile"
|
import { useIsMobile } from "@/hooks/useMobile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a wall panel is pointed at when it shows one dashboard and nothing else.
|
* What a wall panel is pointed at when it shows one dashboard and nothing else.
|
||||||
@@ -34,16 +36,19 @@ function PanelView() {
|
|||||||
useFlowSocket()
|
useFlowSocket()
|
||||||
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
||||||
const stacked = useIsMobile()
|
const stacked = useIsMobile()
|
||||||
|
// A tab pointed at one dashboard is that dashboard, so its theme covers the
|
||||||
|
// whole page rather than only the canvas inside it.
|
||||||
|
const theme = useDashboardTheme(dashboard as Dashboard | undefined)
|
||||||
|
|
||||||
if (!dashboard) return <main className="h-svh w-full" />
|
if (!dashboard) return <main className="h-svh w-full" />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
className={
|
className={cn(
|
||||||
stacked
|
"h-svh w-full bg-background p-4 text-foreground",
|
||||||
? "h-svh w-full overflow-y-auto p-4"
|
theme,
|
||||||
: "h-svh w-full overflow-hidden p-4"
|
stacked ? "overflow-y-auto" : "overflow-hidden",
|
||||||
}
|
)}
|
||||||
>
|
>
|
||||||
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
|
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ def process(tick, mode="comfort", away=False, setpoint=21.0):
|
|||||||
"indoor": round(indoor, 2),
|
"indoor": round(indoor, 2),
|
||||||
"outdoor": round(outdoor, 2),
|
"outdoor": round(outdoor, 2),
|
||||||
"humidity": round(humidity, 1),
|
"humidity": round(humidity, 1),
|
||||||
|
# Not a reading: what the panel on the wall should wear. The dashboard
|
||||||
|
# binds its `theme` setting to this, which is what "switch the panel at
|
||||||
|
# sunset" looks like here — a flow publishing, not a rule in the
|
||||||
|
# dashboard.
|
||||||
|
"panel_theme": "dark" if hour < 7.0 or hour >= 20.0 else "light",
|
||||||
}
|
}
|
||||||
'''
|
'''
|
||||||
|
|
||||||
@@ -331,6 +336,8 @@ HOUSE_NODES = [
|
|||||||
# downstream of it is paced by this rather than by the tick.
|
# downstream of it is paced by this rather than by the tick.
|
||||||
{"name": "outdoor", "dtype": "float", "interval": 60},
|
{"name": "outdoor", "dtype": "float", "interval": 60},
|
||||||
{"name": "humidity", "dtype": "float"},
|
{"name": "humidity", "dtype": "float"},
|
||||||
|
# Twice a day at most, so it is paced rather than sent every tick.
|
||||||
|
{"name": "panel_theme", "dtype": "str", "interval": 60},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1214,6 +1221,18 @@ def seed_dashboard(api: Api) -> None:
|
|||||||
"columns": COLUMNS,
|
"columns": COLUMNS,
|
||||||
"canvas_width": CANVAS[0],
|
"canvas_width": CANVAS[0],
|
||||||
"canvas_height": CANVAS[1],
|
"canvas_height": CANVAS[1],
|
||||||
|
# The settings channel: the dashboard itself as a receiver. Costs
|
||||||
|
# no tile, which is why it fits a panel with all 17 rows spoken
|
||||||
|
# for. `system` is the fallback until the flow first says
|
||||||
|
# otherwise; `locked` is left unset, which is the static default
|
||||||
|
# and keeps every control on the demo usable.
|
||||||
|
"settings": {
|
||||||
|
"theme": {
|
||||||
|
"value": "system",
|
||||||
|
"message": msg(HOUSE, "panel_theme"),
|
||||||
|
"dtype": "str",
|
||||||
|
}
|
||||||
|
},
|
||||||
"pages": PAGES,
|
"pages": PAGES,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user