This commit is contained in:
2026-10-13 10:02:20 +02:00
parent a93f865e91
commit 80e595c02e
24 changed files with 2210 additions and 4310 deletions
+137 -244
View File
@@ -1,324 +1,217 @@
# Dashboards and panels
A dashboard is a grid of widgets bound to message names, the same names that
wire the graph. It is its own document rather than a set of nodes, so it reads
across flows without belonging to any of them, and a flow stays the logic it
was.
There is no second tool and no separate deployment. You build the panel next to
the thing feeding it.
A **dashboard** is a grid of widgets bound to message names. It can show values
and send controls across several flows. A **panel** assigns published dashboards
to a paired screen, such as a wall tablet.
## Building one
**Dashboards → New dashboard**, then drag widgets onto the grid from the widget
picker and bind each to a message.
1. Open **Dashboards → New dashboard**.
2. Enter edit mode, choose **Add widget** and select a type.
3. Bind the widget to a message in its settings. On desktop, drag and resize
tiles on the grid.
4. **Publish** when the dashboard is ready.
Like flows, dashboards are drafts until you publish. A wall panel reads only
the published version, so a half-arranged page never reaches the wall.
Edits stay in a draft; paired screens show the published version. On phones,
tiles stack in one column and dragging is disabled, but settings remain editable.
On a phone the dashboard is read, not arranged: the grid stacks to one column
and dragging is off. Picking a widget and editing its settings still works.
Controls can publish real values while you edit, so choose their targets with
care. Your new Switch may already be flipping something!
## The widgets
### Display
| Widget | Binds to | Notes |
| Widget | Binds to | Shows |
|---|---|---|
| **Value** | anything | a formatted reading with a unit and a precision |
| **Gauge** | `float`, `int` | min, max, unit |
| **Chart** | `float`, `int` | up to five series; see *Two kinds of chart* below |
| **Bar** | `float`, `int` | up to eight readings, a row each, with a scale per row |
| **Icon** | numbers, booleans, weather strings | maps a value onto a glyph |
| **Text** | — | markdown you write; a label, a note, an instruction |
| **Agenda** | `list` | upcoming items, e.g. from a calendar connector |
| **Forecast** | `list` | a short outlook strip |
| **Notification** | `record` | title, body and severity — what an alert channel writes |
| **Media** | `image`, `audio`, `video` | a camera frame, a clip; see *Media tiles* below |
| **Player** | `record` | what a streamer is playing, with its transport; see *Player tiles* below |
| **Clock** | — | the time, in a size a wall can read |
| **Website** | — | another site's page in the tile; see *Website tiles* below |
| **Value** | anything | formatted reading, unit and precision |
| **Gauge** | `float`, `int` | reading within a min/max range |
| **Chart** | numeric messages, a query response or run metrics | up to five lines |
| **Bar** | `float`, `int` | up to eight readings with separate scales |
| **Icon** | numbers, booleans or strings | a glyph selected from the value |
| **Text** | — | Markdown |
| **Agenda** | `list` | upcoming items |
| **Forecast** | `list` | an outlook strip |
| **Notification** | `record` | title, body and severity |
| **Media** | `image`, `audio`, `video`, `artifact` | image, audio or video |
| **Player** | `record` | playback status and transport controls |
| **Clock** | — | current time |
| **Website** | — | embedded page or image |
Every widget carries a **title**, and **Show title** decides whether the panel
draws it. Turned off, the tile is just the reading, which is what a row of
gauges under one heading wants. The title is still the widget's name: what a
screen reader calls its controls, and what a published value is labelled with.
Every widget has a title. **Show title** controls its visibility; the title
still names the widget for accessibility and published values.
### Input
| Widget | Publishes | Notes |
| Widget | Publishes | Settings |
|---|---|---|
| **Button** | a fixed value | one-shot: run it, open it, reset it |
| **Button** | fixed value | one-shot action |
| **Buttons** | a value per button | grid of actions |
| **Switch** | `bool` | on/off |
| **Slider** | `float`, `int` | min, max, step; **Drawn as** makes it a vertical fader |
| **Input** | text or a number | free entry |
| **Selector** | one of a list | a mode, a scene, a preset — as a menu, or as a row of choices with the active one held |
| **Colour** | `[h, s, v]`, `[r, g, b]` or `"#rrggbb"` | a hue wheel with saturation and brightness, for an RGB fixture |
| **Slider** | `float`, `int` | min, max, step; horizontal or vertical |
| **Input** | text or number | free entry |
| **Selector** | selected value | menu or row of choices |
| **Colour** | list or string | HSV, RGB or hex |
The colour wheel sends `[h, s, v]` by default (hue 0-360 degrees, saturation
and value 0-100 percent, which is what a DMX encoder expects) and its
**Sends** setting switches that to `[r, g, b]` (0-255 each) or to a
`"#rrggbb"` string, because fixtures differ. The first two bind a `list`
message, the third a `str`.
Colour defaults to `[h, s, v]`: hue 0–360°, saturation and brightness 0–100%.
**Sends** can select RGB (`[r, g, b]`, 0–255) or hex (`"#rrggbb"`).
HSV and RGB bind to `list`; hex binds to `str`.
A control publishes the message it is bound to, exactly as a node would. On the
flow canvas it is drawn as a labelled endpoint feeding the nodes that read it,
so nobody has to guess where the value came from.
Controls publish to their bound messages. The flow canvas shows them as
endpoints feeding the nodes that consume those messages.
### Typed bindings
Bindings are checked in the editor and on the server. See
[Payload types](../reference/payload-types.md).
Widgets are type-checked against the message the same way ports are: a switch
takes a `bool`, a gauge takes a number, an agenda takes a `list`, a
notification takes a `record`. Bind one wrong and the editor says so rather
than drawing nothing. The same table is enforced on the server.
## Charts
Charts draw their series in one fixed order of colours: the primary first, then
hues picked to stay apart, colour blindness included. A chart with more than
one series still always draws a legend, so no line is told by colour alone.
Choose the chart's data source:
## Two kinds of chart
- **Live:** recent numeric message history, updated as values arrive.
- **Query:** publishes a `record` request containing `range_s` and
`interval_s`, then draws a flow's `series` response. Include those fields in
the answer so the chart can reject responses for a different window.
- **Runs:** compares a numeric metric from named runs, a sweep or the latest
runs of a flow. See [Runs](../concepts/runs.md).
**Live.** Bind up to five messages and the chart draws the engine's own ring
buffer of recent values, extended over the socket as new ones land. Nothing
else is involved. This is what you want for "the last few hours of the living
room".
Multi-line charts show a legend as well as different colours.
**Querying.** The chart publishes a *request* (the window and resolution it
wants) exactly as a slider publishes a value, and draws the `series` some flow
answers with. What serves that request is the flow's business: typically a
small Python node that builds a Flux query, an InfluxDB node that runs it, and
another Python node that shapes the rows.
!!! tip "Skip the unfinished bucket"
The widget never learns which database answered it, so swapping the store
changes one flow and nothing else. An answer states what it was computed for,
so an answer to a different question is ignored.
!!! note "Drop the bucket that is still filling"
The request carries a window and an interval, and the binning is the flow's
own work, so the newest bucket only ever holds the part of an interval
that has elapsed. Drawn, it reads as a fall that never happened. The
engine's own [Activity charts](index.md#activity) end on the last closed
bin for that reason; a flow answering a chart has to drop or hold back its
newest bucket the same way.
If your query aggregates into time buckets, leave out the bucket still
filling. Otherwise an incomplete total can look like a sudden drop.
## Media tiles
A media widget draws what its message points at: a picture, a clip with
controls, a video. Media does not travel as a message; a reference to it does.
The tile takes the bytes behind whichever reference the message holds, and
redraws when a new one arrives.
Media messages contain artifact references; the widget loads the bytes they
name. **Crop or fit** controls image sizing. **Play as it arrives** enables
autoplay, subject to the browser's rules for sound.
**Crop or fit** decides how a picture fills the tile. **Play as it arrives**
starts a clip by itself, though a browser only plays sound once somebody has
touched the page, so a screen nobody has tapped stays silent.
For local streams, `volatile=True` artifacts can be pushed over the websocket
with their message. Stored artifacts are fetched individually, as are frames
viewed through a portal. Use occasional snapshots for remote screens.
Rate is the thing to get right, and it is decided by the node publishing rather
than by the tile. A frame the engine is holding in memory is pushed down the
same websocket that carries the value, so ten a second is a real view on the
local network; a stored one is fetched, a round trip each, which suits a glance
at a door every second or two. Through the portal everything is fetched, so
make it every few seconds there. Above that, put the camera's own address in
**Live stream** and the browser plays it from source, leaving the messages to
carry the occasional still that a flow can actually react to.
Panels see media the same way, and only their own: a screen is sent, and may
fetch, the bytes its own tiles are showing and nothing else.
For continuous video, set **Live stream** to a browser-compatible camera URL.
The browser must be able to reach that URL; enrollment does not expose the
camera itself. See [Media payloads](../reference/payload-types.md#image-audio-video).
## Player tiles
A player is the one tile that both reads and publishes, so it has two bindings.
It **shows** a `record` describing what is playing and **publishes to** a `str`
carrying what to do about it:
A Player reads a status `record` and publishes commands to a separate `str`
message.
| Field of the record | Means |
| Status field | Meaning |
|---|---|
| `title`, `artist`, `album` | what is playing |
| `status` | `play`, `pause`, `stop`, `load`, or `off` for a streamer with no power |
| `position`, `duration` | seconds, both |
| `title`, `artist`, `album` | track details |
| `status` | `play`, `pause`, `stop`, `load` or `off` |
| `position`, `duration` | seconds |
The buttons and the bar publish words: `toggle`, `next`, `prev` and
`seek:<seconds>`. Those are a streamer's own vocabulary rather than this app's,
which is what lets one tile drive whatever is on the other end: the node that
receives them decides what they mean for its device.
The position counts forward in the browser between readings, so the bar moves
at one second while the device is polled at whatever rate suits it. Every
reading that arrives is taken as the truth and the count restarts from it.
Controls send `toggle`, `next`, `prev` and `seek:<seconds>`. Your receiving
node translates those into device actions. The position advances in the
browser between readings and resets to each fresh reading.
## Website tiles
A website tile frames whatever page its **Address** points at: a weather map, a
camera's own web interface, a timetable. A site that refuses to be framed shows
its own refusal in the tile.
Set **Address** to a page to embed, or choose **Image** for a snapshot or MJPEG
URL. A website may refuse embedding.
The page runs sandboxed. By default it gets an origin of its own that matches
no site, so it can run scripts but has no cookies or storage, and many
interactive pages will not load like that. **Trusted site** lets it keep its
real origin, with its cookies and storage, which is what visiting the site
would give it. Turn it on only for a site you trust. A trusted tile pointing at
this app's own address, or the hub's when the app is reached through one, shows
a notice instead: on that origin the page could reach into the app and read
your session.
**Image** draws the address as a picture scaled to fit the tile, rather than
as a page. Use it for a camera's MJPEG stream (`http://<camera>/stream`) or a
snapshot URL, which a page frame would show at the camera's native size.
Embedded pages run in a sandbox without cookies or storage by default.
**Trusted site** permits the site's own origin, cookies and storage.
Use it only for a trusted destination. The app blocks trusted embeds of its
own origin, including the portal origin, to protect your session.
## Dashboard settings
Most of what a dashboard carries is a widget: a tile bound to a message. A
handful of 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.
Open **Dashboard settings** from the dock. Each setting can hold a fixed value
or follow a message chosen in **Driven by**. The fixed value is the fallback
when no usable message value is available.
| Setting | Is | Driven by |
| Setting | Choices | Message type |
|---|---|---|
| **Look** | `Fluksio`, `Material` or `Glass` | a `str` message |
| **Theme** | `System`, `Light` or `Dark` | a `str` message |
| **Palette** | the dashboard's colours, in order | a `list` message |
| **Background** | the URL of an image | a `str` message |
| **Lock** | read-only on or off | a `bool` message |
| **Look** | `fluksio`, `material`, `glass` | `str` |
| **Theme** | `system`, `light`, `dark` | `str` |
| **Palette** | ordered hex colours | `list` |
| **Background** | image URL | `str` |
| **Lock** | read-only on/off | `bool` |
They all work the same way, and both halves are optional:
To change one on a schedule, have a scheduled node publish its bound message.
- **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.
### Look and theme
A bound setting is type-checked like a widget binding, so a Theme pointed at a
`float` is refused by the editor and by the server. The panel's credential
is extended to it, so a paired screen may read its own theme message and
nothing further.
**Fluksio** is the default look. **Material** uses flat tonal cards; **Glass**
uses translucent tiles. Widgets work the same in all three.
There is no schedule field. **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 a palette is the whole of "warmer after sunset". The same channel
drives anything else, including locking a panel down remotely.
### Look
**Fluksio** is the app's own design (`--card` surfaces separated by a hairline
and a low shadow, pill controls, one slate-blue accent) and is what a dashboard
wears until you choose otherwise. **Material** lays flat, tonal cards on a plain
ground. **Glass** floats translucent tiles over a soft, slowly moving one.
They are three complete sets of components, not three stylesheets, but they are
the same dashboard: every widget, every control and every keystroke behaves
identically, so switching look never changes what a panel can do.
Theme follows the chosen preference unless a palette sets the background
colour.
### Palette
A palette is an ordered list of colours, and **position is the role**:
Paste a colour list or a [Coolors](https://coolors.co) palette link. Colours
have roles by position:
| # | Role |
| Position | Role |
|---|---|
| 1 | the ground the dashboard sits on |
| 2 | the surface a widget is |
| 3 | the primary — fills, active controls, the first chart line |
| 4 | the accent |
| 1 | background |
| 2 | widget surface |
| 3 | primary controls and first chart line |
| 4 | accent |
| 5 | text |
| 6+ | the next chart lines, before the app's own chart colours |
| 6+ | additional chart lines |
Paste a [coolors.co](https://coolors.co) link (or a list of hex colours) and the
whole dashboard is recoloured: every widget, the rail and the charts. Leave the
later roles off and they are worked out from the ones you gave, so **three
colours are a whole dashboard**. **Rotate** turns the list when the roles landed
in the wrong order. Text that could not be read on a surface is replaced with
black or white there, so no palette can produce a line nobody can see.
Missing roles are derived, so three colours are enough to get started.
**Rotate** shifts their order. Text contrast is corrected automatically;
failure and success indicators keep their status colours.
While a palette is set, **Theme is idle**: the first colour is the ground, so
whether the panel reads light or dark is already decided by the palette itself.
Fault and success keep their own colours in every palette, so a failure is never
paintable as a reading.
### Background and lock
### Background
An image drawn under the widgets, covering the canvas. It replaces the ground
the Glass look brings with it. Bound to a message, a flow decides the picture.
### Lock
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.
Background draws an image beneath the widgets. Lock disables publishing from
controls and shows **Read-only** when viewing the dashboard. It does not
restrict API permissions and does not disable controls in edit mode.
## Showing one
- `/view/{name}` — a browser tab pointed at one dashboard. Needs an ordinary
session.
- **Panels** — a named device that pairs instead of logging in. Below.
Open `/view/{name}` for a dashboard-only view with your normal session.
For a dedicated screen, pair a panel.
## Panels: hanging a screen on a wall
A wall tablet has no keyboard, so it pairs.
As a superuser:
1. **Dashboards → Panels**, add a panel named after where it hangs, and tick
the dashboards it shows. More than one and the screen draws a rail to switch
between them, with each dashboard's own icon on it. On a phone the rail is a
row floating along the bottom; the app shows it there too, above its own
buttons, for a dashboard that hangs on such a panel.
2. Tick **Touch friendly** if the screen is touched rather than pointed at.
Controls and the rail grow to a finger's size — the rail gets taller without
taking a wider column, so the arrangement does not move. It sits here rather
than on a dashboard because it describes the screen: the same dashboard may
also be open in a browser with a mouse. A phone gets it anyway, from its own
width.
3. Point the device's browser at the link the dialog shows. The device then
displays a six-character code.
4. Type that code into the same panel's **Pair device** field. The line under
it names what is holding the code. Check it is the screen you just hung,
because approving adopts whatever answered. The screen picks the credential
up within a few seconds and never asks again.
1. Open **Dashboards → Panels**, add a panel and select its dashboards.
2. Enable **Touch friendly** for larger controls.
3. Open the displayed pairing link on the device. It shows a six-character code.
4. Enter that code for the panel, check the device description, then press
**Pair device**.
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.
The screen collects its credential and shows the assigned published dashboards.
Several dashboards get a navigation rail.
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.
A panel credential permits its assigned dashboards, their bound messages and
the artifacts they show. It also permits publishing from its controls and query
charts. **Lock** changes the viewing surface; it does not narrow the credential.
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
credential: the panel, its dashboards and their arrangement stay exactly where
they are, and the screen falls back to asking for a new code, which is how you
swap the device out without rebuilding what hangs there.
**Unpair screen** revokes a locally paired credential and keeps the panel's
dashboard assignments. Deleting the panel removes the assignment and its
access.
!!! note "If the link is wrong"
!!! note "If the local link is wrong"
The pairing link is built from the instance's `FRONTEND_HOST`. If that
is not the address devices on your network actually reach, fix the setting
rather than the link: it is the same one password-reset mails and the OAuth
metadata are built from.
Pairing links use `FRONTEND_HOST`. Set it to the app address reachable
from the screen's network.
### A screen somewhere you cannot reach
Another building, someone else's network, no route in. An instance
[enrolled with a portal](portal.md) shows a second link,
`https://hub.${DOMAIN}/i/{instance-id}/panel`, and the same three steps
work through it: the portal serves that one page without a session, forwards
the pairing calls down the tunnel, and mints the credential when you approve
the code. The pairing line then reads *via portal*.
An [enrolled instance](portal.md) also shows a portal pairing link. Use that
link on the remote screen, then approve its code in the same dialog. The
device description says *via portal*.
The portal names the panel and nothing else. What the panel may read is decided
on the instance, on every call, by the same check a locally paired screen
passes. Two differences: it acts as the account the instance was enrolled
with rather than as whoever approved it, and deleting the panel stops it here
immediately while the portal's copy of the token expires on its own, which is
also why unpairing, which works on a credential this instance signed, does
not reach a remote screen. Revoke that one at the hub.
The instance enforces the same dashboard and message limits. Remote
credentials are issued by the portal, so **Unpair screen** does not revoke
them; revoke them at the portal. Deleting the panel removes access on the
instance.
## See also
- [Payload types](../reference/payload-types.md) — what a widget can bind to
- [Flows, nodes and messages](../concepts/flows.md) — where the names come from
- [Accounts and the portal](portal.md) — reaching all of this from outside
- [Payload types](../reference/payload-types.md)
- [Flows, nodes and messages](../concepts/flows.md)
- [Accounts and the portal](portal.md)