Make the docs state things rather than argue them
Docs / docs (push) Successful in 37s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m35s
Playwright Tests / test-playwright (2, 2) (push) Failing after 17s
pre-commit / pre-commit (push) Failing after 2m8s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 13s
Playwright Tests / merge-reports (push) Failing after 2m25s

The site read as a design journal: rationale paragraphs, hedges
("deliberately", "on purpose", "genuinely"), meta-commentary about the docs
themselves, and one em-dash every ten lines carrying an aside.

Roughly twenty rationale blocks are gone or reduced to what a reader needs
in order to use the thing. Em-dashes go from 507 to 135, and what is left is
structural rather than prose: list and definition separators, table cells,
and four inside code blocks that quote what the CLI actually prints.

Also: api.example.com becomes api.fluksio.com (the emails stay, since
bootstrap.py really defaults to admin@example.com and RFC 2606 reserves it);
the mqtt table gains the two settings it had drifted behind on and inject's
wording matches the engine; llms.txt lists the two connector pages that were
in the nav but not in it; and the two device/device_policy notes now agree.

Builds clean under `zensical build --strict`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
This commit is contained in:
2026-08-31 10:49:58 +02:00
co-authored by Claude Opus 5
parent 2422a9b22b
commit bdad6d7fc2
25 changed files with 450 additions and 479 deletions
+33 -35
View File
@@ -1,6 +1,6 @@
# Dashboards and panels
A dashboard is a grid of widgets bound to message names the same names that
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.
@@ -39,7 +39,7 @@ and dragging is off. Picking a widget and editing its settings still works.
| **Clock** | — | the time, in a size a wall can read |
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
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.
@@ -54,8 +54,8 @@ screen reader calls its controls, and what a published value is labelled with.
| **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 |
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
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`.
@@ -82,21 +82,20 @@ 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".
**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
**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.
That indirection is the point. The widget never learns which database answered
it, so swapping the store is a change to one flow and nothing else. The answer
also states what it was computed for, so an answer to a different question is
ignored rather than two charts overwriting each other's picture.
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
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
@@ -105,8 +104,8 @@ ignored rather than two charts overwriting each other's picture.
## 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
— so the tile fetches the bytes behind whichever reference the message holds,
controls, a video. Media does not travel as a message; a reference to it does.
The tile fetches the bytes behind whichever reference the message holds,
and redraws when a new one arrives.
**Crop or fit** decides how a picture fills the tile. **Play as it arrives**
@@ -115,7 +114,7 @@ touched the page, so a screen nobody has tapped stays silent.
Rate is the thing to get right. A frame every second or two is a glance at a
door, and works; through the portal, make that every few seconds. Live video is
not something to push through the message plane at all — put the camera's own
not something to push through the message plane at all. 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.
@@ -136,7 +135,7 @@ carrying what to do about it:
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
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
@@ -147,7 +146,7 @@ reading that arrives is taken as the truth and the count restarts from it.
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
to any tile on it, and a screen bolted to a wall has nobody standing at it to
set them.
| Setting | Is | Driven by |
@@ -169,25 +168,24 @@ They all work the same way, and both halves are optional:
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
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.
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 a palette, is the whole of "warmer after sunset"
and the same channel then serves anything else you want to drive, including
locking a panel down remotely.
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
**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
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.
@@ -205,7 +203,7 @@ A palette is an ordered list of colours, and **position is the role**:
| 6+ | further chart colours |
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
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
@@ -213,8 +211,8 @@ black or white there, so no palette can produce a line nobody can see.
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 a failure must never
be paintable as a reading.
Fault and success keep their own colours in every palette, so a failure is never
paintable as a reading.
### Background
@@ -249,25 +247,25 @@ A wall tablet has no keyboard, so it pairs.
2. Point the device's browser at the link the dialog shows. The device then
displays a six-character code.
3. 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,
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.
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.
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 *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
you quieten a screen remotely, but that is the surface behaving, not the
credential being narrowed.
Deleting the panel revokes the credential and the assignment together, which is
how you retire a device and what it showed. Unpairing revokes only the
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
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.
!!! note "If the link is wrong"
@@ -290,7 +288,7 @@ 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
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.
+12 -14
View File
@@ -5,14 +5,12 @@ full-bleed graph, so what you are looking at is always the flow.
## The canvas
Nodes are laid out for you left to right on a desktop, top to bottom on a
phone and the arrangement is recomputed as the graph changes. There is
Nodes are laid out for you, left to right on a desktop and top to bottom on a
phone, and the arrangement is recomputed as the graph changes. There is
nothing to drag and nothing to tidy.
That is on purpose. The edges come from message names, so a stored layout would
be a second, weaker description of the same thing, and something to merge when
two people edit the same flow. It also keeps flows honest: a graph nobody can
hand-arrange is one worth splitting.
The edges come from message names, so there is no layout to store or to merge
when two people edit the same flow.
What is drawn:
@@ -38,7 +36,7 @@ the canvas with its panel open.
Floats on the right; a full-screen sheet on a phone.
**Consumes** and **Provides** are the ports. Each row is a message name, a
payload type, and on an output a *streaming* toggle for ports that publish
payload type, and, on an output, a *streaming* toggle for ports that publish
repeatedly during one execution. The name field suggests messages already in
the flow, which is usually how you wire something: type the name that already
exists.
@@ -47,7 +45,7 @@ Renaming an output renames the message everywhere it is read. Inputs are not
renamed, because an input is more often re-pointed at a different message than
renamed.
**Settings** is generated from the node type's parameter schema so an MQTT
**Settings** is generated from the node type's parameter schema, so an MQTT
node shows broker, topic and QoS, and an ntfy node shows server, topic and
priority. A field marked as a credential renders as a picker over your stored
[secrets](operations.md) instead of a text box.
@@ -59,7 +57,7 @@ ports. See [Where a node's values come from](../concepts/values.md).
**Timeout** (Function nodes) is how many seconds the code may be *silent*
before it is stopped: it measures silence, not duration, so a node that yields
every few seconds can run for hours under a timeout of 300. Left empty there is
no limit, which is the default — set one where a node going quiet means it is
no limit, which is the default. Set one where a node going quiet means it is
stuck rather than working. A `0` says so explicitly.
**Code** is the editor. It saves as you type and applies on ⌘S; the node
@@ -71,10 +69,10 @@ instead.
Editing a shared node edits the copy every flow using it runs, which is the
point and also the caution.
!!! note "Not in the panel (yet)"
!!! note "Not in the panel yet"
`device` and `device_policy` which machine a node's code runs on are set
through the API rather than the panel. See
`device` and `device_policy`, which machine a node's code runs on, are set
through the API rather than the panel, with `PUT /flows/{name}`. See
[Remote workers](../code/workers.md).
## The flow panel
@@ -95,8 +93,8 @@ Click the canvas title to open the flow's own settings.
Click a wire. You get the last payload that went down it, when, and a sparkline
of its recent numeric history. You can also republish that value from the node
that produced it, which re-runs everything downstream the fastest way to test
a change without waiting for the real sensor.
that produced it, which re-runs everything downstream. It is the fastest way to
test a change without waiting for the real sensor.
## The dock
+5 -5
View File
@@ -1,6 +1,6 @@
# The dashboard app
The web interface is a single-page app served at `app.${DOMAIN}` or, for an
The web interface is a single-page app served at `app.${DOMAIN}`, or, for an
instance reached through a portal, at `${DOMAIN}/i/{instance-id}`.
Either way it is the same application, and it is a client of the same REST API
you can script against.
@@ -54,7 +54,7 @@ Each flow also has a switch beside it in the list, which starts and stops it.
Two columns under the graph, exactly as tall as each other, most recently
worked on first. The flows column is the list with the switches; the dashboards
column is a mosaic, each tile a schematic of that dashboard's layout blocks
column is a mosaic, each tile a schematic of that dashboard's layout: blocks
where its widgets sit, shaded by what kind of widget each one is. Neither
column grows past about six rows: past that it scrolls in place rather than
pushing the health block down the page.
@@ -98,7 +98,7 @@ Opening one takes you to [the flow editor](flow-editor.md).
### Deleting several at once
Press and hold a card or ctrl-click it to pick it, then tap the rest. While
Press and hold a card, or ctrl-click it, to pick it, then tap the rest. While
anything is picked, **New flow** in the toolbar becomes a trash button, and it
asks once before deleting the lot. Unpicking the last one puts the list back;
so does Escape. The Dashboards screen works the same way.
@@ -117,5 +117,5 @@ so does Escape. The Dashboards screen works the same way.
## Appearance
Light and dark follow your system by default; **Settings → Appearance**
overrides it. Both themes are first-class the wall-panel view in particular is
designed to be legible in dark from across a room.
overrides it. Both themes are first-class, and the wall-panel view is designed
to be legible in dark from across a room.
+6 -6
View File
@@ -23,8 +23,8 @@ They are encrypted at rest with a key derived from the instance's
flows are a git repository you may well push somewhere: what gets committed and
shared never contains a password.
A name that no longer resolves is a clear error on the node that wanted it
"No secret named 'x' add it under Secrets" rather than a mysterious
A name that no longer resolves is a clear error on the node that wanted it
("No secret named 'x'; add it under Secrets") rather than a mysterious
authentication failure.
!!! warning "Rotating `SECRET_KEY`"
@@ -36,7 +36,7 @@ authentication failure.
## Modules
Node code runs in a virtual environment of its own, on the instance's data
volume separate from the packages Fluksio itself runs on. A pin of yours can
volume, separate from the packages Fluksio itself runs on. A pin of yours can
never shadow one of ours, and vice versa.
**Modules** is a pip manifest and an **Apply** button. It is installed with
@@ -49,7 +49,7 @@ and whether the manifest and the environment agree. A failed resolve comes back
as `uv`'s own output, in the pane, because that is the only thing anyone can
act on.
An install takes effect immediately the worker processes pick up the new
An install takes effect immediately: the worker processes pick up the new
interpreter state without the engine restarting.
!!! note "Big scientific stacks"
@@ -79,7 +79,7 @@ literal, so a webhook URL with a token in it does not sit in the config.
### Rules
A rule is a set of events and the channels they go to. **A rule with no events
ticked covers all of them**, including ones added in later versions which is
ticked covers all of them**, including ones added in later versions, which is
usually what you want for the first rule you write.
| Event | Fires when |
@@ -113,7 +113,7 @@ Superusers get an **Admin** screen: add, edit and remove accounts, and see who
is a superuser.
Accounts that arrived through a portal are badged *Portal*. They have no
password they sign in at the portal, not here and they are never
password (they sign in at the portal, not here) and they are never
superusers. Deleting one cuts their access immediately and independently of the
portal, so it works even if the portal cannot be reached. See
[Accounts and the portal](portal.md).
+9 -10
View File
@@ -13,13 +13,13 @@ The **portal** is optional, and it exists to solve two specific problems:
An enrolled instance dials *out* to the portal and holds one websocket
open. The portal serves the dashboard from its own side, and only the API calls
travel down the tunnel so the interface loads at portal speed and your
travel down the tunnel, so the interface loads at portal speed and your
machine stays unreachable from the internet.
## Enrolling
Two halves, deliberately: whoever performs the second step decides what the
instance's owner gets.
Two halves: whoever performs the second step decides what the instance's owner
gets.
**On the portal** (`hub.${DOMAIN}`, or [fluksio.com](https://fluksio.com) for
the hosted one): **Instances → Add instance**, give it a name, and copy
@@ -39,10 +39,9 @@ fluksio enroll ABCD-1234 --portal https://hub.fluksio.com
The code is single-use and expires in fifteen minutes.
A portal session then arrives as *that local account* the settings screen
states this plainly, because it is the whole security model in one sentence.
Use `--as someone@example.com` to enrol as a specific local account when the
instance has several superusers.
A portal session then arrives as *that local account*, which the settings screen
states plainly. Use `--as someone@example.com` to enrol as a specific local
account when the instance has several superusers.
Once enrolled, the instance appears under **Instances** with its
status, when it was last seen and its version. **Open** takes you to its
@@ -55,7 +54,7 @@ the tunnel. What those requests may do is decided **on the instance**, by
the same checks a local session passes.
The trust anchor is a signing keypair on the portal. Every instance pins
its public half at enrolment and rejects anything else which is what stops a
its public half at enrolment and rejects anything else, which is what stops a
hijacked DNS entry or a mis-issued certificate from impersonating the portal.
## Letting someone else in
@@ -72,7 +71,7 @@ admits them, and they arrive as a local user of their own rather than as you.
you.
The instance redeems that code against the portal using its own credential.
A portal session cannot do this which is what stops somebody you let in from
A portal session cannot do this, which is what stops somebody you let in from
letting others in.
On the instance they appear under **Admin → Users**, badged *Portal*, never
@@ -95,7 +94,7 @@ unsure: it does not need the portal's cooperation.
## Running your own portal
The portal is the `index` stack's `hub` service accounts, the registry of
The portal is the `index` stack's `hub` service: accounts, the registry of
connected instances, and the websocket each one dials in on. Two things
about it are load-bearing: