Commit Graph
71 Commits
Author SHA1 Message Date
Melvin StroblandClaude Opus 5.5 603635f947 Round paging follows the finger: faces and dashboards on a track, swiped one to one with the next beside it, turned or sent back on release, rubber-banded at the ends; keys and dots slide the same track; the reading dot a smaller companion of the handle
Docs / docs (push) Successful in 24s
Playwright Tests / test-playwright (1, 2) (push) Failing after 4m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m11s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 3m23s
Compose Smoke Test / test-compose (push) Successful in 34s
Playwright Tests / merge-reports (push) Successful in 1m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:08:51 +02:00
Melvin StroblandClaude Opus 5.5 541dbce584 Round faces: a bar as concentric arcs with a legend in the middle; larger title, gap line and face text; Read-only in the ring's gap; a round dashboard unpadded on a phone; reduced-motion ring assertion
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:52:46 +02:00
Melvin StroblandClaude Opus 5.5 84071ee106 Round dashboards: one face at a time, a face per widget type, paged by swipe, dots and keys; slider reading; face row in the editor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:24:25 +02:00
Melvin StroblandClaude Opus 5.5 8b8cb5cda9 Round dashboards: shape and ring, the ring drawn and turned in every look, Knob preset and face guide in the editor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:23:55 +02:00
stroblmeandClaude Opus 5.5 8d158169e5 Button icon-touch/sm-touch sizes replace the repeated touch-target classes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:02:35 +02:00
stroblmeandClaude Opus 5.5 b086d5d22b Show Secrets to superusers only, and guard its route as Admin is
The page reads GET /secrets/, which is a superuser's, so any other account
opened it onto a permission error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
2026-10-06 12:33:00 +02:00
stroblmeandClaude Opus 5.5 7370105cd8 Make changing engine-run code superuser-only
Writing node source, installing modules and any flow save that adds,
retypes or repoints a code node (Function, Command), edits a Command
node's settings or changes a node's resources.env now answer 403 for
everyone but a superuser. MCP tools and the assistant reach the same
routes, so they inherit the gate. Node types report runs_code.

The editor shows code read-only with a note, disables a Command node's
settings, leaves code nodes and reusable nodes out of the palette and
paste, and the Modules page offers no install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
2026-10-06 11:36:30 +02:00
stroblmeandClaude Opus 5.5 de7ee759f9 Make self-service signup opt-in via SIGNUP_ENABLED
/users/signup answers 403 unless SIGNUP_ENABLED is set. A public
GET /utils/config/ tells the login page, which hides the sign-up link,
and the signup page explains that an administrator creates accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
2026-10-06 11:24:27 +02:00
stroblmeandClaude Opus 5.5 d7ca933478 Float the dashboard rail along the bottom on a phone
Below md a dashboard stacks to one column and dropped its rail. The rail is
now a horizontal row in the dashboard's own look, floated over the bottom
of the screen on /panel/{id} and above the dock in the app's view, with
44px items, sideways scrolling when it overflows, and padding so the last
tile clears it; a panel's read-only notice moves up out of its way. The
three looks share the geometry from ui/core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
2026-10-06 00:22:28 +02:00
stroblmeandClaude Opus 5.5 673061d9fb Talk to wren nodes from the chat's Control tab, opened from Home
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
2026-10-05 20:59:11 +02:00
stroblmeandClaude Opus 5.5 c3031a9960 Find a flow's declared inputs and outputs in the global search
/search lists each input and batch output under its qualified message,
and picking one opens the flow on the label's id, which the editor now
treats like a click on the label: it opens the flow's settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
2026-10-05 18:15:35 +02:00
stroblmeandClaude Fable 5.1 cc4091166e Add the assistant: a chat that drives the MCP tools in-process
Docs / docs (push) Successful in 42s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 10s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 9s
Playwright Tests / merge-reports (push) Failing after 2m17s
A provider set under Settings (OpenAI-compatible; key encrypted in
flow-data/ai.json) turns on an Assistant panel in the sidebar. Each turn runs
the FastMCP tools in-process as the person chatting, streams the rounds over
SSE and keeps one transcript per user. The panel floats on any screen and
shares the editor's lane: a settings panel keeps the top half, the chat the
bottom. The MCP client is now built once, for the endpoint and the chat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 23:28:23 +02:00
stroblmeandClaude Opus 5 5b122341d5 New run: start a run from the app, on the working copy
The site promises simulated inputs and mocked sensor values, and nothing in
the app was that. A run already is: the values are the caller's, the state is
the run's own namespace, and nothing it computes reaches the live flow. What
was missing was a screen to do it from, and the draft flag being honoured.

`/runs/new` is a flow, a field per declared input, a seed and Run; `/runs`
stays the log. A comma-separated list in a number field expands into the grid
`fluksio sweep --param` builds and goes to the sweep route, so launching one
no longer needs a terminal. Only numbers split: a comma in a string is
content, and one in JSON is syntax.

`RunCreate.draft` was validated at submit and dropped before the run
executed, so "try the working copy" ran the published one. `Run.draft` is a
column now, the driver reads the same copy the submit checked, and a retry
carries it. `FlowSummary.mode` came with it so the rail can say which flows
are batch before one is picked.

Also here: a Retry button on a finished run, which the route has always had
and the UI never did, and parameter cells truncated to their column with the
full value on hover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TTfoK82awm8wvxXhHz3XF
2026-09-02 15:10:24 +02:00
stroblmeandClaude Opus 5 518231aa39 Touch is a panel setting, and the rail grows with it
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m9s
Playwright Tests / test-playwright (2, 2) (push) Failing after 11s
pre-commit / pre-commit (push) Failing after 1m59s
Test Backend / test-backend (push) Failing after 2m28s
Compose Smoke Test / test-compose (push) Failing after 11s
Playwright Tests / merge-reports (push) Failing after 2m19s
It described the wrong object. A dashboard is a document that may hang on a
hallway tablet and in a desk browser at the same time, and only one of those
has fingers on it — so the flag moves off `DashboardDef.settings` and onto
`PanelDef` as a plain bool, ticked in the Panels dialog. `useCanvasRoot` takes
it as an argument rather than reading the document, and `/panel/{id}` is the
only surface with a panel to ask.

Dropping the message binding with it is deliberate: nothing drove it, and a
flow deciding whether a screen has fingers on it was never the point. A stored
`settings.touch` is inert rather than migrated, which `_check_settings`
skipping unknown names already guaranteed.

The rail was the other half. It had no touch behaviour at all and its 40px
buttons met neither branch of the 44/32 rule. `[data-touch] .dui-rail{-item}`
in `ui/core/core.css` spends the padding and the gap on the buttons instead,
so they reach the 44px target and the rail comes out taller at exactly the
same width — `RAIL_INSET` never moves, and the arrangement under it does not
either.

Also closes the panels-dialog icon gap: `DashboardSummary` carries the `icon`
now, so the dialog draws each assigned dashboard's rail glyph beside its
checkbox. `initials()` went from three identical copies in the looks to one in
`Dashboard/icons.ts`, so the dialog and the rail fall back the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va7ExQDtuwKN7kNpHhWWNQ
2026-08-31 19:04:20 +02:00
stroblmeandClaude Opus 5 2422a9b22b Move the remaining panel explanations into tooltips
A node setting's description comes off the backend JSON schema, so every
node type's panel still printed a paragraph under each field. It moves to
the field label, which is the change that makes the node panel read as a
list rather than a page.

Alerts, Modules and Remote access follow the same shape. A page keeps a
one-line description under its h1 — a page is arrived at rather than
scanned — and the rest goes behind the heading's tooltip.

Verified on the running stack: the tooltip opens on tap in the phone sheet
and on hover on the desktop panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:38:16 +02:00
stroblmeandClaude Opus 5 d01a8dad37 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:12:01 +02:00
stroblmeandClaude Opus 5 45cc7504e1 Notify a phone that has this installation installed
A `webpush` alert channel, and the PWA it needs to arrive. The payload is
encrypted to the subscription (RFC 8291) and the request signed with this
installation's own keypair (RFC 8292), both over `http-ece` — `pywebpush`
does the same in one call but brings `requests` and `aiohttp` with it, two
HTTP stacks beside httpx on a machine that may be a Raspberry Pi.

The manifest and the worker are hand-written rather than `vite-plugin-pwa`:
there is nothing worth precaching when the page carrying the credential is
`no-store`, so the worker handles `push` and `notificationclick` and nothing
else. `registration.scope` is the app's root in both places it runs, which is
why the payload carries no URL.

A run finishing in error is the first event worth waking someone for; `ok`
and `cancelled` describe to nothing, so a nightly batch that works stays
quiet. The events were already on the bus — only the filter changed.

`WEBPUSH_FILE` is a derived path, so the keypair lands on the data volume
with the alerts beside it. Off it, a rebuild would silently stop every phone
being notified: the key they subscribed against would be gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EbeFPm6WNC3YD9vrqqT3a
2026-08-30 12:12:37 +02:00
stroblmeandClaude Opus 5 1873787ee6 Compress what the browser downloads, and stop one tile taking the page
**nginx served the bundle uncompressed and uncacheable.** The base image
ships gzip commented out and nothing set `Cache-Control`, so every load
carried the whole thing and every reload cost a 304 per asset. Measured on
the built image: the entry chunk 838 kB → 311 kB, the Monaco chunk 2.66 MB
→ 832 kB, and the ~50 content-hashed assets are now immutable for a year.
`index.html` is explicitly `no-cache`, since it is what names the rest.

**A widget that throws no longer blanks the screen.** There was one error
boundary in the app, on the root route, so anything that threw replaced
everything including the navigation — on `/view/{name}`, an unattended wall
panel with no way back. Each tile has its own boundary now, and the app
shell has one inside it so a screen that fails leaves the sidebar standing.
`react-error-boundary` was already a dependency and imported nowhere.

**`localStorage` cannot take the app down.** Reaching it raises where the
browser blocks site data, and `setItem` raises once the origin's quota is
full — which the flow editor's node clipboard, carrying whole Python
sources, can genuinely reach. Thrown from a key handler that escaped to
`window.onerror`, which the single root boundary then turned into a blank
page. `lib/safeStorage.ts` is the guarded pair the pre-paint theme script in
`index.html` was already using; a copy too large to store now says so.

Queries default to `staleTime: 5000` — below every poll interval on any
screen, so nothing polls less often than it did, but a route mounting twice
in a few seconds stops refetching everything it touches. Window-focus
refetching is off: the socket pushes what changes and a reconnect
invalidates what it feeds, so a focus event has nothing of its own to say.
Home alone reads about ten queries on every one of those.

Render cost, two that showed up in the audit:

- `LogsPanel` was rendered unconditionally by the dock and decided inside
  itself whether to draw, so with the panel *shut* it still subscribed to
  the log store and re-filtered five hundred lines per line a flow
  published. It returns before any of that now.
- `HealthActivity` subscribed to the whole engine-event array and used one
  number from it, so a flapping node re-rendered the component that draws
  Home's two uPlot charts — each of which rebuilds its series on every
  render by design. It subscribes to that number.
- the global search bucketed the index nine times per keystroke, once per
  group. One pass, and each group offers at most twenty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
2026-08-29 21:00:02 +02:00
stroblmeandClaude Opus 5 7e506b26c0 Act on a run, and read Home top-down
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m18s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m49s
pre-commit / pre-commit (push) Failing after 2m5s
Test Backend / test-backend (push) Successful in 2m39s
Compose Smoke Test / test-compose (push) Successful in 33s
Playwright Tests / merge-reports (push) Successful in 1m11s
Runs: a run can now be deleted (DELETE /runs/{id}, cancelling a live one
first), exported as csv from the screen's own filters, and its flow label
opens the flow. Its "Parameters" panel became "Inputs" and lists every
input the flow declares, marking the ones that took the flow's own value
rather than the run's — the comparison table resolves the same defaults
instead of printing "unset".

Home reads brain, dashboards, health, flows: the mosaic is one full-width
scrolling strip, and the flows list and the flow-activity rollups merged
into a single left-joined table so a flow's state and its numbers sit on
one row.

Charts take a drag to narrow the x window and a double click or tap to
come back out. UplotChart holds the scale and passes resetScales:false
while a window is held, which is what the old comment said made this
impossible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LrWVRguqbk33YzfEeUx5W
2026-08-29 08:37:11 +02:00
stroblmeandClaude Opus 5 4215e057d1 Add a global search, and stop the sidebar logo squeezing
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m53s
pre-commit / pre-commit (push) Failing after 2m13s
Test Backend / test-backend (push) Successful in 2m38s
Compose Smoke Test / test-compose (push) Successful in 38s
Playwright Tests / merge-reports (push) Successful in 1m8s
`GET /api/v1/search/` hands the client one flat index of everything worth
jumping to — flows and the nodes inside them, dashboards and the widgets on
them, panels, secrets, modules, workers and alert channels — and cmdk matches
it in the browser, so results narrow while typing without a round trip per
keystroke. A node hit is the one thing no list endpoint could answer: it opens
its flow with that node in focus.

The panel is reached from **Search** above Documentation in the sidebar, or
⌘K anywhere. The flow canvas palette moves to ⌘P, being the narrower of the two.

The panels dialog gains an address (`/dashboards?panels`) so a panel hit has
somewhere to land, and the sidebar logo gets `shrink-0`: the rail's width
animates while the logo is already back, and a flex item short of room is
squeezed rather than clipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vGH7jqcXxWKP9wZFPyVdU
2026-08-28 22:19:08 +02:00
stroblmeandClaude Opus 5 96cf1fc0c8 Accept the hub's shape of a panel token
Docs / docs (push) Successful in 31s
Playwright Tests / test-playwright (1, 2) (push) Successful in 5m15s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m16s
pre-commit / pre-commit (push) Failing after 2m19s
Test Backend / test-backend (push) Successful in 2m51s
Compose Smoke Test / test-compose (push) Successful in 39s
Playwright Tests / merge-reports (push) Successful in 1m12s
A screen paired through the portal asked the household for a fresh code on
every boot: it looks for the panel in a `panel` claim, and the hub — which
knows nothing of this installation's users — puts it in `sub` and says so with
`scope`. A local token carries no scope, so the two cannot be confused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:15:48 +02:00
stroblmeandClaude Opus 5 5da5606f79 Settings as panels, the way the portal does it
Four tabs over three cards was navigation for its own sake, and the two shells
disagreed about what a settings screen looks like. It is one grid of cards now,
matching the portal: the account card carries changing a password and deleting
the account in its footer, appearance is a card with one row, and remote access
— an operator's concern, not a personal preference — is a card of its own for a
superuser.

SettingRow, alert-dialog and UserAvatar come across from the portal, so an
account renders the same face in both shells.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:04:42 +02:00
stroblmeandClaude Opus 5 6d09500a73 Fold the connection banner into the notification stack
The offline pill was drawn inside a `flex h-0` container with align-items at
its default stretch, so the pill was stretched to a zero-height box while still
carrying its padding — the "box too small" it was reported as. It is a
persistent notification now, keyed on one id so a poll that keeps confirming
"still offline" replaces the card instead of restacking it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:00:56 +02:00
stroblmeandClaude Opus 5 7d87dfffdd Put resources where somebody would look for them
`resources` was reachable from the SDK and the API and nowhere else, and
`node_queued` was published to nothing at all — so a node waiting for a machine
looked exactly like a node that had hung, which is the failure the event was
added for.

The node panel gets a Resources section: a size by name, the numbers for a node
that wants its own, and how long it is expected to take. Picking a flavor drops
the numbers, because saying both is two answers and the engine refuses it.

A queued node draws a neutral dot rather than one of the three status colours —
it is not running, it did not go well, and it did not go wrong; it is idle with
a reason, which the tooltip gives.

And a Workers screen, which is the first UI for any of this: every machine the
engine can reach with what is free of each, what is attached, what a cluster
has been asked for, and the sizes, editable. `fluksio status` grew a line of
the same.

The demo's training node already preferred a GPU worker, which was exactly the
declaration that used to be dropped, so it now says how much of that machine it
takes as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
2026-08-27 09:32:08 +02:00
stroblmeandClaude Opus 5 65f859c04d Land a paired panel on its own screen instead of a new code
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
2026-08-25 21:24:41 +02:00
stroblmeandClaude Opus 5 476dc6a1e9 Make the brand CTA visible and fix two more v3 variable classes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UytviPMJbXzD8P84nLvXcq
2026-08-25 18:33:28 +02:00
stroblmeandClaude Opus 5 8679b0bcf7 Draw a wall panel at the size it was drawn for
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m41s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m40s
pre-commit / pre-commit (push) Failing after 3m16s
Test Backend / test-backend (push) Successful in 2m18s
Compose Smoke Test / test-compose (push) Successful in 30s
Playwright Tests / merge-reports (push) Canceled after 0s
A dashboard whose canvas matches the screen came out at 95% of it: the
panel and view routes inset the canvas by a further 16px, on top of the
margin the grid already keeps from its own edges. Two margins, one of
them costing the scale. The padding now applies only to the stacked
phone layout, which has no canvas to shrink.

A slider's tick labels are the third row of a control that has three,
and the first thing a short tile runs out of; `ticks: false` drops them
and keeps the reading and the track. The house's two sliders set it.

The 1024x600 preset is named for what it is rather than for a diagonal
it may not have — the same resolution is sold as a seven-inch panel and
as a ten-inch one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012n6CehUsHYYUXJ48rxaD18
2026-08-25 17:30:37 +02:00
stroblme 4350916bd8 Scroll the runs table, pick a range, and choose what a comparison plots against
Docs / docs (push) Successful in 35s
Playwright Tests / test-playwright (1, 2) (push) Failing after 3m14s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m44s
pre-commit / pre-commit (push) Failing after 3m54s
Test Backend / test-backend (push) Successful in 3m12s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Failing after 1m28s
2026-08-25 16:32:34 +02:00
stroblme 7ff29ca939 Make a dashboard its widgets: drop the pages and sections nobody drew 2026-08-25 12:30:22 +02:00
stroblme 5ece544b14 Let a dashboard be read against runs instead of the live engine 2026-08-25 11:48:15 +02:00
stroblme d2951a325e Add a runs screen: a table, a run in full, and curves side by side 2026-08-25 11:44:13 +02:00
stroblme 6238728dce Draw the panel as a panel
Five things a wall panel showed that a wall panel should not.

**A tile's body no longer clips.** It scrolled, and a box that scrolls
also cuts whatever crosses its edge — which took the glow off a lit
button at exactly the width where the button filled its tile, and off a
gauge's arc at exactly the height where the dial filled its own. Only
what is written or listed asks for a scroller now; everything else is a
picture drawn to fit, and what overflows is left to the frame, which
clips at the tile's edge where a shadow has already faded out. The
slider's phantom scrollbar goes with it.

**The selector is a selector.** Named for what it does rather than what
it is, and the choice it is holding is held in the dashboard's own
primary — a pill that slides between the options rather than a grey one
that had to be looked for. The stored type is untouched, so no document
changes meaning.

**The arrangement is held off the panel's edges**, by the same distance
it holds between two widgets. The ground is not held off with it: a
background covers the whole panel, and only what is arranged on it has
a margin. No stored panel loses a row to it.

**The rail is drawn on the panel.** It was chrome bolted to the edge of
the screen beside the canvas — in the app's own design rather than the
dashboard's, and on a scaled canvas not even lined up with it. It now
takes a column out of the canvas the way the margin does, scaled with
it and wearing its look. Which cell each widget sits in is unchanged;
only how big a cell is.

Two of these were the same mistake twice: an unlayered rule stating
`position` for everything wearing a class — `.gl-surface` on a rail
placed by a utility, and a blanket lift over every child of a pressable
on a pill placed by `layoutId`. Both now say it one element at a time.
2026-08-24 10:44:53 +02:00
stroblmeandClaude Fable 5 fea57064f9 Run node code on the venv Fluksio was installed into
The workflow this serves: make a venv, install what you work with, then `pip
install fluksio` into the same one. Building a second environment beside it
was exactly wrong — the packages the nodes need are already here, and the
Modules screen was asking for them a second time.

`NODE_VENV=auto` (the default) adopts that venv. It declines in the three
cases where adopting would be wrong: `managed` says otherwise, a managed venv
already exists and may hold packages somebody installed on purpose, or the
engine is not running from a venv at all. The images set `managed`, since the
venv in them holds the app and nothing of anybody else's.

An adopted venv is never written to. `uv pip sync` makes a venv hold exactly
the manifest, so pointed at somebody's own environment it uninstalls their
work and the engine with it — `sync()` refuses outright and `reconcile()`
returns before it can be called at startup, which is where that would have
happened first. The Modules screen lists what is installed and drops its
editor; `pip` is how that environment changes.

`fluksio serve` now names the interpreter node code runs on, which is the
thing a data scientist most needs to know at that moment. `fluksio-worker`
already defaulted `--python` to its own interpreter, so a GPU box works the
same way — that was only ever undocumented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ue1tkFWB1bcGy3aWhCKpU
2026-08-24 10:35:13 +02:00
stroblme 0b5ce4fcbb Rework the dashboard into two looks over one behaviour
A dashboard is a wall panel somebody hangs in their own hallway, so it
now wears what they choose: a look, and a palette of their own colours.

Two complete component sets live under `Dashboard/ui/` — `glass`
(translucent panes over a slowly moving ground) and `material` (Material
3 tonal cards) — behind one prop contract. Every control's state,
keyboard and `aria-` live in `ui/core` and are shared, so the two sets
are the same dashboard drawn twice rather than two products: a set only
decides what a control looks like while doing it.

Four settings join the channel, each drivable by a flow like any other:
`look`, `palette`, `background` and `touch`. A palette is an ordered list
of hex colours — background, surface, primary, accent, text, then more
chart colours — pasted from a coolors.co link or typed, written onto the
canvas as the token variables everything already reads. Trailing roles
are derived, so three colours are a whole dashboard, and derived text is
held to AA rather than trusted (`theme.check.ts` measures it). A palette
also decides light or dark, since its first colour is the ground.

Widgets are measured against their own tile with container queries rather
than against the viewport, animate through `motion`, and can be drawn
without their title. The three reworks:

- a bar draws a row per reading, up to eight, each in the dashboard's own
  data colours and each able to carry its own scale — replacing readings
  nested in one fill, which could only ever share one colour and stop at
  three. Documents written the old way are read as rows.
- a chart's range picker moved to a column down its right-hand edge, which
  gives the plot back a whole row of a short tile.
- the colour wheel became a disc: hue is the angle and saturation the
  distance from the middle, so a colour is one gesture rather than three,
  with brightness on a slider beside it.

`index.css` and `lib/motion.ts` are untouched — the dashboard overrides
token *values* on its canvas, never the blocks the two repos share.
2026-08-23 21:52:14 +02:00
stroblmeandClaude Opus 5 d958d7cde6 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
2026-08-22 13:17:56 +02:00
stroblme 3674099758 Centre the offline banner over the content column 2026-08-22 12:09:18 +02:00
stroblmeandClaude Opus 5 f17d51c12f Home mosaic, multi-select delete, offline banner and loading states
- Home puts the dashboards beside the flows: two equal-height columns,
  capped and scrollable, most recently worked on first. Each tile is a
  schematic footprint built from the stored widget placements.
- Flows and dashboards can be picked by long press or ctrl-click; the
  create button becomes a trash and one dialog covers the batch.
- The offline banner is drawn on the body so it centres on the viewport,
  and the live socket now releases the offline latch a stray 503 set.
- A boot spinner before React's first commit, a router pending screen for
  code-split pages, and skeletons where an empty list used to flash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
2026-08-22 12:02:14 +02:00
stroblmeandClaude Opus 5 c912388ed6 Make lint report instead of rewriting, and stop verify guessing the domain
`make lint-frontend` was `biome check --write --unsafe ./` — a lint target that
reformatted the whole tree rather than checking it, which is why every parallel
change in this repo has had to work around it. `lint` checks now and a new
`format` writes. The pre-commit hook and CI needed no edit at all: both call
`bun run lint`, so they became checks the moment its meaning changed.

`app/Makefile` assigned DOMAIN from .env, and a plain assignment beats an
inherited environment variable and is not exported — so `cd app && make
dev-local` served localhost while the same checkout's tests targeted the
configured domain. `export DOMAIN ?=` gives the lattice that was intended:
command line, then environment, then .env.

Alongside: the backend's htmlcov bind mount created that directory as root, so
`make test-backend` died on the coverage step after every test had passed,
which reads like a test failure and is not one. The alerts screen's copy of
ALERTING_EVENTS is now checked by a test rather than trusted. And the shard
comment claimed two spec files where there are nine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
2026-08-21 14:34:14 +02:00
stroblmeandClaude Opus 5 b0efb4b0f1 Let a node's failure outlive the run that followed it
A node's error cleared the moment it ran again, so a failure that genuinely
fired an alert could leave no trace on the canvas by the time anyone looked.
The engine records it now — on the node's status, so it survives a reload and
every client agrees — and reading the traceback is what clears it. The seam is
the event bus, which is where every failing path already meets: a queued live
run, an explicit run, a preview, and a single triggered node all publish
`node_error`, while the controller's own observer would have seen only one of
them.

That was half the confusion. The other half: clicking a failed neuron on Home
often landed on a flow where everything looked fine. Nodes merge into one
neuron by instance key — every InfluxDB node pointing at the same bucket is one
neuron — and the click went to whichever flow contributed a member first, not
the one that failed. It now goes to the failing member and selects it, and the
canvas marks a failing node rather than leaving it to the dot alone.

The inject node emitted one payload to every port it declared, whatever their
types, so an inject on a bool port carrying the text "true" raised at publish
time. Each port gets its own field now, typed and parsed by that port's dtype,
and remembers what it last sent. A port that is renamed carries its value with
it; one that is removed takes its value with it. An inject written before this
keeps emitting exactly what it did.

The derived-cron chip also appeared on the delay node, where `interval` is a
rate limit and a schedule derived from it means nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
2026-08-21 14:33:41 +02:00
stroblmeandClaude Opus 5 c3ea884d72 Hold a new dashboard back until someone publishes it
A dashboard went live the moment it was created — an empty document straight to
the panels — while a new flow starts as a draft. It now works the way flows do:
published means `dashboard.json` exists, so every dashboard on every running
installation is already published and nothing needs migrating. Only the ones
created from here on start as drafts.

Mirroring FlowStore turned up a latent 500: discarding the draft of a dashboard
that had never been published unlinked its only file, and the read that followed
raised out of a 200 handler. It answers 400 now, the way a flow does.

Publishing all of them was 2N requests, because a publish has to name the
version it expects and the summaries did not carry one. They do now — and so do
the flow summaries, which had the same defect nobody had written down.

A panel had no way to hear about any of this. A publish, or a change to which
dashboards a panel carries, now puts one event on the bus and the screen
refetches what changed: no reload, so a wall display never blanks or asks for
its credential again. The subtle half is that a socket's message allowlist was
computed once at handshake — a reassigned panel would have fetched its new
document and then shown tiles that never updated.

The panels dialog logged non-superusers out. Every write in it needs a
superuser, not only the checkboxes the report mentioned, so the dialog is
read-only for everyone else. The logout itself was `main.tsx` treating 403 as a
dead session, against the contract deps.py spells out: only a 401 ends a
session, and a 403 now says so rather than silently signing someone out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
2026-08-21 14:32:57 +02:00
stroblmeandClaude Opus 5 4c8339e643 Pair a wall panel through the portal
A screen somewhere this installation is not reachable from asks the portal for
a code instead, and the portal mints its credential — because a token signed
here is one such a device could never present.

Where it was minted changes nothing about what it may do. The panel gate moved
off the branch that decodes a local panel token and onto whatever claims name
a panel, so the portal's and this installation's are bounded by the same check
against the same panel's dashboards. A token of that scope naming no panel is
refused rather than left holding the account it borrows.

The connector marks what arrives on its socket, since that is the only thing
that makes it true, and the approval screen now names what is holding a code —
approving adopts whatever answers, so it is worth a look first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
2026-08-20 23:42:58 +02:00
stroblmeandClaude Opus 5 ce77262f81 Point the panel link at the installation, not at the browser's origin
Both links out of the dashboard editor were built root-relative, so a portal
serving the app under `/i/{id}` got a URL to itself: the hub has no route
there and answers a bare 404. That is what a device link and "open what a
wall panel sees" both landed on.

They want different answers. The view link is for the person already looking,
so it takes the router's basepath — `appPath` in `lib/portal` is the same
prefix the router applies to every `Link`, for the places that step outside
it. The device link is for a screen, which cannot go through the portal at
all: the shell is served only to a portal session, and the credential that
page carries is the portal's rather than the panel's. So the server now says
where it answers, and `FRONTEND_HOST` is that answer — the same setting the
password-reset links already use.

Also fixes the panel branch in the query error handler, which compared a raw
pathname and so never fired under a portal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
2026-08-20 17:24:20 +02:00
stroblmeandClaude Opus 5 ffae24c16b Panels: per-device dashboard sets, paired by code
A panel is one screen and the ordered set of whole dashboards it shows, so a
hallway tablet and a workshop tablet carry different sets without either
dashboard knowing about the other. More than one and the device draws a rail
to switch between them — the same rail the editor puts on screen, because the
wall has it and it takes room off the canvas.

A screen has no keyboard, so it pairs rather than logs in: it shows a
six-character code, somebody approves it against a panel from the dashboards
overview, and the credential that mints reaches that panel's published
dashboards and the message endpoints its widgets speak, and nothing else.
Deleting the panel revokes it.

Closes the per-device view and the kiosk credential; supersedes the
multi-page/multi-section UI, since a page is now a dashboard of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
2026-08-20 16:23:36 +02:00
stroblmeandClaude Opus 5 bbff78766e Own the live socket outside React so leaving Home cannot orphan it
The socket belonged to whichever hook instance ran its effect first. Passive
effects run children before parents, so on Home that was the brain graph rather
than the shell: navigating to a sibling route unmounted the graph, which closed
the socket, while the shell kept the reference count above zero. From there the
page was deaf for the rest of its life, with nothing left to reconnect it.

A module-level connection with a real refcount replaces it — connect on the
first subscriber, disconnect on the last — and the hook is a thin subscription
with the same signature, correct under StrictMode's mount/unmount/mount.

A 1008 now reconnects instead of returning silently: the token is read afresh
per attempt, and three consecutive rejections fall through to the caller's auth
handler so a revoked session surfaces rather than spins.

The snapshot's emit counts are read into a store of their own, apart from the
live count, so a graph that connects into a busy engine is drawn as busy without
every neuron claiming it just fired. The neuron and edge pulses now key off a
change seen while they were mounted, so returning to Home no longer replays
every emission of the session.

Home gets a live indicator for the case none of this can fix: quiet while the
socket is up, and named in words when it is down, since HTTP polling keeps the
rest of the page looking current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
2026-08-20 11:58:49 +02:00
stroblmeandClaude Opus 5 a9f91db619 Grow the flow editor in from the centre, hide the empty brain band
The editor's mount-time fitView animated from React Flow's default viewport,
which read as the graph swiping in from the corner on every open. The first
fit is instant now, later ones stay animated, and a scaleIn wrapper gives the
same entrance the brain view has — with a re-measure on completion so the
handle bounds are not stored mid-scale.

Home only renders the brain band once some flow has nodes, so a fresh install
no longer reserves a screenful of empty space above the flows card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
2026-08-20 08:43:16 +02:00
stroblmeandClaude Opus 5 9ed0caa14f Settings: connect this installation to a portal
A superuser-only tab that redeems a claim code and shows the link's state. It
names the account a remote session acts as, because that is the thing being
granted and the person granting it should see it spelled out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtBzdDyLsmDaF1W7DLYtYM
2026-08-19 17:39:14 +02:00
stroblmeandClaude Opus 5 32d8b42682 Dashboard: run unchanged when a portal serves it
The same bundle is served by a portal under /i/{id}, so it reads its API base,
credential and router basepath from an injected config instead of the build-time
URL and localStorage. A normal installation finds no config and behaves exactly
as before; the credential deliberately never touches localStorage, since two
installations open in one browser share an origin and would overwrite each
other's session.

The websocket URL was resolving an absolute path against the base, which
discards the base's own path — harmless until the base gained one, then it
aimed the socket at the wrong host entirely.

Connection state gets a store of its own, apart from the engine's: the proxy's
503 carries {offline, last_seen}, which raises a banner naming when the
installation was last heard from and turns a failed mutation into 'not
delivered' rather than a generic error. The screen keeps its last data
underneath, since stale readings with a timestamp beat a blank page. A
reconnecting socket invalidates every query, because whatever happened while it
was down was missed.

Verified in a browser against a real hub and installation: the full UI loads
through the tunnel with no console errors, and killing the installation raises
the banner within a poll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtBzdDyLsmDaF1W7DLYtYM
2026-08-19 17:09:53 +02:00
stroblme 887e552ce1 gc
Signed-off-by: stroblme <stroblme@posteo.de>
2026-08-17 20:07:56 +02:00
stroblmeandClaude Opus 5 bb90a24b90 Computed flow layout, and mobile written into the design
The canvas lays itself out: a layered graph, left to right on a desktop and
top to bottom on a phone, with room reserved for the value each edge carries.
Nodes cannot be dragged and `NodeDef.position` is gone from the document —
a graph nobody can arrange is one worth keeping small, which is what keeps
flows atomic. Endpoints join the same layout, so their lanes and the
localStorage that remembered where they were dragged go too.

Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps
its width and wraps instead of running off the screen, the dashboard stacks
into one column rather than shrinking a wall panel to a fifth of its size, and
Home stops widening its grid track past the viewport. A Playwright project at
a phone's width fails the build when a screen no longer fits.

Along the way: publish is the checkmark that was already there rather than a
button that appears and disappears, with discard beside it on both the flow
and the dashboard; the brain reveals a neuron's name on the first tap; and the
port sparklines get room to breathe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
2026-08-17 17:35:14 +02:00
stroblmeandClaude Opus 5 413501c6ce flow: structured dtypes, and the widgets that read them
A series, record or list message declares its shape instead of riding
DType.JSON, so a widget binds a shape rather than some JSON and a wrong
binding is refused before anything runs. A list declares its item type,
which is what keeps list[float] expressible for a pipeline.

On top of that: an agenda over a list, a notification over a record, and
a dashboard alert channel that publishes engine faults as one — so a
panel can show what went wrong without a flow wiring it by hand.

Also: only None means a node published nothing, a falsy value of the
wrong shape is now the named error it always should have been; and the
gauge's readout says its size is viewBox geometry rather than type scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:06:45 +02:00