Publish the documentation site: docs.fluksio.com

A zensical site under docs/, served by a new `docs` compose service behind
Traefik, built with --strict in CI. Same pattern the sibling n3xd workspace
uses.

Getting started splits the way the landing page does — one path is
`pip install fluksio` and a training script, the other is a Docker stack and
an afternoon in the browser — because the two audiences will not spend the same
amount of time. Everything after that is shared: the concepts, the web
interface (app and portal), the CLI and the API, and a reference for node types,
payload types and configuration.

The three flow guides move here from the docs submodule rather than being
copied, so there is one version of them.

Styling mirrors DESIGN-GUIDELINES.md: the app's token palette remapped onto
Material's variables in both schemes, Inter, the 16px panel radius, and the one
terracotta accent spent on the facility lane of the audience split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7Xv3cJEW5c8AXxn2hoojV
This commit is contained in:
2026-08-22 05:55:34 +02:00
co-authored by Claude Opus 5
parent 8632d975e6
commit 11e032386b
34 changed files with 4167 additions and 2 deletions
+137
View File
@@ -0,0 +1,137 @@
# 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.
## Building one
**Dashboards → New dashboard**, then drag widgets onto the grid from the widget
picker and bind each to a message.
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.
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.
## The widgets
### Display
| Widget | Binds to | Notes |
|---|---|---|
| **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` | a reading, optionally nesting up to three inside it |
| **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 |
| **Clock** | — | the time, in a size a wall can read |
### Input
| Widget | Publishes | Notes |
|---|---|---|
| **Button** | a fixed value | one-shot: run it, open it, reset it |
| **Switch** | `bool` | on/off |
| **Slider** | `float`, `int` | min, max, step |
| **Input** | text or a number | free entry |
| **Dropdown** | one of a list | a mode, a scene, a preset |
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.
### Typed bindings
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 are the exception worth knowing: because identity across five series is
carried by lightness alone, a chart with more than one series always draws a
legend.
## Two kinds of chart
**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".
**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.
## 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.
## Panels: hanging a screen on a wall
A wall tablet has no keyboard, so it pairs.
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.
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,
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 message endpoints its widgets speak, and nothing else.
It cannot be made strictly read-only, and that is honest rather than an
oversight: a querying chart publishes its request, and a control on a panel is
the reason you put one there. Deleting the panel revokes the credential, which
is also how you retire a device — the screen falls back to asking for a new
code.
!!! note "If the link is wrong"
The pairing link is built from the installation'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.
### A screen somewhere you cannot reach
Another building, someone else's network, no route in. An installation
[enrolled with a portal](portal.md) shows a second link,
`https://hub.${DOMAIN}/i/{installation-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*.
The portal names the panel and nothing else. What the panel may read is decided
on the installation, on every call, by the same check a locally paired screen
passes. Two differences: it acts as the account the installation 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.
## 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
+156
View File
@@ -0,0 +1,156 @@
# The flow editor
The canvas is where most of the work happens. It floats its chrome over a
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
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.
What is drawn:
- **Your nodes**, with a status dot — running, succeeded, failed, or nothing at
all when idle. Status is always named in words too, never colour alone.
- **Edges**, which pulse when a value goes down them. Click one to inspect it.
- **Endpoints** — anything wired into this flow that is not a node in it: a
dashboard control writing a message, a tile reading one, a node in another
flow on the far side of a dotted name. Drawn as labelled boxes at the
boundary, so a value never appears to come from nowhere.
Values are live. The editor holds a websocket to the engine, and the connection
banner tells you when it is not.
## Adding a node
**Add node** on the dock, or ⌘K / Ctrl-K for the command palette, which also
jumps between flows and offers your shared nodes. Pick a type and it appears on
the canvas with its panel open.
## The node panel
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
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.
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
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.
A Function node gets a second settings section where you add your own: a
setting is a constant of this node's code and arrives as an argument beside its
ports. See [Where a node's values come from](../concepts/values.md).
**Timeout** (Function nodes) is how many seconds the code may run before it is
stopped. Once a node streams, this becomes an *idle* timeout: it measures
silence, not duration, so a node that yields every few seconds can run for
hours under a timeout of 300.
**Code** is the editor. It saves as you type and applies on ⌘S; the node
reloads without the flow stopping. A node file defines `process(...)`; if it
defines exactly one public function under another name, that one is used
instead.
**Shared** turns a node's code into a library entry other flows can reuse.
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)"
`device` and `device_policy` — which machine a node's code runs on — are set
through the API rather than the panel. See
[Remote workers](../code/workers.md).
## The flow panel
Click the canvas title to open the flow's own settings.
- **Running** — the switch that starts and stops the flow. Stopped means none
of its subscriptions, schedules or webhooks exist.
- **Mode** — `live` or `batch`. Live reacts to what arrives; batch only runs
when a run asks it to. See [Runs](../concepts/runs.md).
- **Inputs** — messages the flow takes from outside, with the value each starts
from. For a batch flow these are also the run's parameters.
- **Outputs** (batch only) — which messages a run reports as its result.
- **Contents** — a reminder that this flow's name namespaces every message
inside it.
## Inspecting an edge
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.
## The dock
Along the bottom, floating over the canvas.
| Control | What it does |
|---|---|
| **Add node** | opens the palette |
| **Fit** | returns to the view the flow opened with |
| **Run** | injects a value by hand; on a batch flow, opens the parameters dialog |
| **Pause / Resume** | holds messages instead of running them |
| **Step** | releases exactly one held message |
| **Publish** | puts the draft live |
| **Discard** | throws the draft away |
| **Issues** | what is stopping this flow from running, per node |
| **Logs** | what the nodes printed, filterable |
Pause and step together are how you test a flow before it moves anything
physical: pause it, inject a value, step it through, watch the values appear on
the edges, then publish.
## Shortcuts
| Chord | Action |
|---|---|
| ⌘K / Ctrl-K | command palette |
| ⌘S / Ctrl-S | publish the flow — or, with focus in the code editor, apply the code |
| ⌘Z / ⌘⇧Z | undo / redo (the flow; the code editor has its own) |
| ⌘C / ⌘V | copy and paste nodes, including between flows |
## Drafts and publishing
Saving writes a draft; the engine keeps running what was published. Publishing
promotes it and reloads.
If someone else saved the same flow while you were working, publishing answers
with a conflict rather than overwriting them. The Flows list offers **Publish
all changes** when several flows are sitting on drafts.
## Issues
The canvas validates as you edit and marks the node each issue belongs to:
- a port that needs a message nothing in reach provides
- a message that exists but has never held a value
- a dependency cycle
- a node reading a message it also writes, with nothing to start it from
- code that did not load
- a webhook with no shared secret (advisory — it does not stop the flow)
A flow with any of these except the last does not run, and the health summary
on Home counts it.
## See also
- [Flows, nodes and messages](../concepts/flows.md) — the model behind the canvas
- [Node types](../reference/node-types.md) — everything the palette offers
- [Writing node code](../code/nodes.md) — what goes in the editor
+88
View File
@@ -0,0 +1,88 @@
# The dashboard app
The web interface is a single-page app served at `app.${DOMAIN}` — or, for an
installation reached through a portal, at `${DOMAIN}/i/{installation-id}`.
Either way it is the same application, and it is a client of the same REST API
you can script against.
Sign in with the account the installation was created with. On a fresh
installation that account was printed once, on the first start.
## The shell
A floating sidebar on the left, the selected screen filling the rest. On a
phone the sidebar collapses to a sheet.
| Entry | What lives there |
|---|---|
| **Home** | the brain graph, health, and everything that recently happened |
| **Flows** | the list of flows, and the canvas for each |
| **Dashboards** | the widget canvases, and the panels that display them |
| **Secrets** | credentials your nodes reference without holding |
| **Modules** | the Python packages your node code may import |
| **Alerts** | where failures get sent |
| **Admin** | users (superusers only) |
| **Settings** | your account, appearance, and remote access |
## Home
The one screen you leave open. Three things share it.
### The brain graph
Every flow drawn as a neuron, wired to the flows it exchanges messages with.
This is the brand mark made live, and it is also the fastest read on the
installation: a neuron pulses when its flow is running work, and its ring turns
terracotta when the flow cannot run as written. A neuron with a problem keeps
its label showing so you can see which one it is without hovering.
Each flow also has a switch beside it in the list, which starts and stops it.
### Health
Always answers, degraded or not. The tiles cover:
- **Flows** — total, running, paused, quarantined, and how many cannot run
because their graph does not validate
- **Nodes** — how many failed to load
- **Queue** — depth, and how old the oldest pending item is
- **Loop lag** — whether the engine's event loop is keeping up
`status: degraded` comes with a list of named problems, in words. "3 flow(s)
cannot run: house, pv, hallway" is more useful than a red dot, so that is what
it says.
### Activity
Charts of executions and failures over the selected range (1h / 6h / 24h / 7d),
with the recent runs, recent failures, dead-lettered work and the audit trail
underneath.
The charts are scrubbable: hover a moment and the lists below filter to it,
click to hold it while you read. That turns "something went wrong around two
o'clock" into the actual rows.
## Flows
The list shows each flow's title, node count, whether it has unpublished
changes, and whether it is enabled, paused or quarantined. The toolbar searches,
creates, and offers **Publish all changes** when several flows have drafts.
Opening one takes you to [the flow editor](flow-editor.md).
## Everything else
- [The flow editor](flow-editor.md) — the canvas, the code editor, running and
testing
- [Dashboards and panels](dashboards.md) — widgets, bindings, and hanging a
screen on a wall
- [Secrets, modules and alerts](operations.md) — the three screens that keep an
installation running
- [Accounts and the portal](portal.md) — reaching an installation from outside
its network
## 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.
+131
View File
@@ -0,0 +1,131 @@
# Secrets, modules and alerts
Three screens that have nothing to do with each other except that an
installation you actually depend on needs all of them.
## Secrets
A node never holds a credential. A parameter written as
```json
{"$secret": "influx-token"}
```
is replaced with the stored value when the node is built, and the editor
renders any field a node type marks as a credential as a picker over what you
have stored.
**Secrets** is where the values live. Add a name and a value; the value is
never shown again, and the list only ever shows names.
They are encrypted at rest with a key derived from the installation's
`SECRET_KEY`, and kept **outside** the flow repository. That matters because
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
authentication failure.
!!! warning "Rotating `SECRET_KEY`"
The secrets store is encrypted with a key derived from it. Change
`SECRET_KEY` and the store no longer decrypts. Re-enter the secrets, or
plan the rotation properly.
## Modules
Node code runs in a virtual environment of its own, on the installation's data
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
`uv pip sync`, so a line you delete is uninstalled rather than left behind.
The manifest is versioned alongside your flows, so what a deployment installed
is recorded with what uses it.
The page shows what is currently installed, the interpreter's Python version,
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
interpreter state without the engine restarting.
!!! note "Big scientific stacks"
Installing torch into this venv works and is often the simplest thing. The
alternative, when you already have an environment you would rather not
duplicate, is to attach it as a worker: `fluksio-worker --python
/path/to/venv/bin/python`. See [Remote workers](../code/workers.md).
## Alerts
Everything that goes wrong already travels the engine's event bus. Alerts is
where you say who hears about it.
### Channels
| Kind | Settings |
|---|---|
| **ntfy** | server, topic, and a token for a protected topic |
| **SMTP** | an address to send to (the installation's mail settings do the rest) |
| **Webhook** | a URL to POST to |
| **Dashboard** | a message name a notification widget reads |
Any channel setting may hold a `{"$secret": "name"}` reference instead of a
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
usually what you want for the first rule you write.
| Event | Fires when |
|---|---|
| `node_error` | a node failed |
| `node_health` | a connection dropped |
| `flow_quarantined` | a flow crashed often enough that the engine stopped restarting it |
| `task_crashed` | a background task crashed |
| `engine_degraded` | the engine is struggling |
| `cascade_dropped` | work was given up on |
| `queue_unavailable` | the work queue is unreachable |
**Test** sends one message through a channel so you find out it is misconfigured
now rather than at three in the morning.
### What it mostly does is not send
This is the part worth knowing before you decide it is broken:
- the same fault repeating is **one** alert, with a cooldown (15 minutes by
default per rule)
- a connection flapping is one story, not one alert per transition
- however bad it gets, **ten alerts an hour** is the ceiling
An engine that is genuinely on fire produces a handful of messages, not
thirty-six thousand.
## Admin: users
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
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).
The same screen manages OAuth clients, which is how [agents](../code/agents.md)
are registered.
## Settings
Your own account: name, email, password, and appearance (light, dark, or
follow the system).
**Remote access** is where an installation is paired with a portal, remote
users are admitted, and the link is cut again. That has [its own
page](portal.md).
+117
View File
@@ -0,0 +1,117 @@
# Accounts and the portal
Fluksio is self-hosted by default. An installation runs offline, keeps its data
on its own disk, and never contacts anything unless you tell it to.
The **portal** is optional, and it exists to solve two specific problems:
1. **Your machine has no inbound route.** A homelab behind CGNAT, a cluster
node with no open ports, a laptop. Opening one is work, and often not
allowed.
2. **You want a browser on it anyway.** A `pip install fluksio` installation
has no web server for the dashboard at all.
An enrolled installation 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
machine stays unreachable from the internet.
## Enrolling
Two halves, deliberately: whoever performs the second step decides what the
installation's owner gets.
**On the portal** (`hub.${DOMAIN}`, or [fluksio.com](https://fluksio.com) for
the hosted one): **Installations → Add installation**, give it a name, and copy
the code.
**On the installation**, either from the dashboard:
> **Settings → Remote access**, enter the portal URL and the code, press
> **Connect**.
or from the command line, which is the path for an installation with no web
interface of its own:
```sh
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
installation has several superusers.
Once enrolled, the installation appears under **Installations** with its
status, when it was last seen and its version. **Open** takes you to its
dashboard at `${DOMAIN}/i/{installation-id}`.
## What the portal can and cannot do
The portal holds one credential for your installation and proxies requests down
the tunnel. What those requests may do is decided **on the installation**, by
the same checks a local session passes.
The trust anchor is a signing keypair on the portal. Every installation pins
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
Anyone else on the portal reaches your installation only if a superuser there
admits them, and they arrive as a local user of their own rather than as you.
1. **They**: **Installations → Join an installation**, and copy the code. It is
bound to their portal account and expires in fifteen minutes.
2. **You**, on the installation: **Settings → Remote access → Add remote
user**, and enter the code.
3. They now see the installation under **Installations**, marked *Shared by*,
with **Open** and nothing else. Renaming, re-keying and removing stay with
you.
The installation 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
letting others in.
On the installation they appear under **Admin → Users**, badged *Portal*, never
a superuser and with no password.
## Cutting it off
| From | Action | Effect |
|---|---|---|
| The portal | **New code** | rotates the credential and drops the current link |
| The portal | **Remove** | deletes the registration and cuts the connection |
| The installation | **Disconnect** | unilateral and immediate — the portal's tokens stop verifying here whatever the portal still has on file |
| The installation | delete a user under **Admin → Users** | that one person, immediately, independent of the portal |
**New code** also cuts every credential the portal minted for this installation,
including [wall panels paired through it](dashboards.md#a-screen-somewhere-you-cannot-reach).
The installation's own **Disconnect** is the one to reach for if you are ever
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
connected installations, and the websocket each one dials in on. Two things
about it are load-bearing:
- **It runs a single process.** It keeps its attached installations in the
memory of the process holding their sockets, so a second worker would answer
for links it does not hold. Scaling out needs a routing layer first.
- **Back up the signing keypair with the database.** Replacing it forces every
installation to be enrolled again.
Websocket upgrades must be enabled on the `hub` hostname in whatever proxy
fronts it. Without them every installation sits in a reconnect loop and the
portal shows them all offline.
## See also
- [The `fluksio` command](../code/cli.md) — `fluksio enroll`, and what it writes
- [Dashboards and panels](dashboards.md) — pairing a wall screen through the portal
- [Getting started: data science](../getting-started/data-science.md) — the
shortest path from a pip install to a browser