script-src admits the bundle and the pre-paint theme script by hash, with no
unsafe-inline or unsafe-eval; style-src keeps unsafe-inline for the <style>
elements Monaco, Radix and the toasts insert. frame-ancestors 'self',
nosniff and strict-origin-when-cross-origin go with it, and the Dockerfile
adds the API origin to connect-src when the bundle calls one elsewhere. Zod
runs jitless so its eval probe is not reported on every page. The embed
widget keeps allow-same-origin: the demo's weather map does not render
without it; the comment now says so and what it costs.
A bound chord used to be `preventDefault`ed before its handler ran, so the
canvas took every ⌘C and ⌘V whether or not the node clipboard would act —
which is what blocked copying selected text out of the chrome floating over
the canvas. A binding may now return `false` to decline the key; ⌘C does so
when there is a text selection or no node selected, ⌘V when nothing of ours
is stored.
The rate the media dtypes could carry was one frame every second or two: each
was a file on the data volume, an event on the socket, and a request back for
the bytes. This closes both halves of that, and they are one feature.
`save_artifact(..., volatile=True)` writes to a `VolatileStore` — the same
content-addressed store, in `/dev/shm`, bounded by size with the oldest falling
out (`ARTIFACT_VOLATILE_BYTES`, 48 MB under the container's raised `shm_size`).
Nothing sweeps it: a frame nobody kept is not worth walking the store to find.
`ArtifactStore.path` falls through to it, which is what lets a volatile frame be
an ordinary reference everywhere else — the dtype check, a panel's digest scope,
`load_artifact` in a node, and the widget's own fetch all work on one unchanged.
`adopt` copies one into the store when a run records it, so "returned media is
kept, emitted media is not" stays true.
The bytes then go down the flows websocket as a length-prefixed binary frame,
sent just ahead of the `message_value` naming them, so a tile has the frame when
it hears the value moved. Nothing is pushed unasked: a client names the messages
it is drawing (`{"type":"media","names":[…]}`), a panel's list is intersected
with the scope it already had, and only the newest frame per name in a batch is
sent — a client that fell behind is not handed frames it would draw over. The
tunnel relays text only, so a screen reached through a portal falls back to
fetching, which is why the rate table now has two rows.
Around the edges: the remote worker's fetch cache is bounded at last
(`FLUKSIO_ARTIFACT_CACHE_BYTES`), since content addressing means nothing in it
ever expires and a media stream fills it with chunks nothing asks for twice; a
port carrying an image draws the frame in the node panel rather than only
saying `image/png · frame.png · 1.79kB`; and an edge chip says that much instead
of a line of hash. The media screenshot stops waiting for `networkidle` — a
camera is a socket that never goes quiet, which is the point of it.
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.
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.
**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.
The runs table drew when a run started and how long it took in two
notations ('56m ago | 55.5 min'), and for a run still going those are the
same reading twice. `dur` now steps past the minute into hours and days,
the ago reading is derived from it, and a running run writes only the
elapsed one.
The app said things in two places: sonner's bottom-right toasts and the
custom top-centre chrome. This is the second of those, generalised — a
module-level store anyone can call, drawn as a stack of frosted cards that go
on their own unless raised as persistent, with room for buttons and for a
message longer than a line.
useCustomToast keeps its signatures, so the forty-odd call sites behind it and
handleError are untouched. The component and its store are byte-identical with
the portal's copy and checked by `make design-check`.
- 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.
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.
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.
The "running" tile counted paused and invalid flows as running, so it read
"5/5" beside "1 flow(s) cannot run". Its note is now additive rather than a
precedence chain, so a quarantine no longer hides the invalid count.
Adds dur() beside si(): a ms reading picks its own unit, so a slow run reads
"1.24 s" instead of "1.2k ms".
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.
The trend column carried `w-full`, which made it swallow every spare pixel:
the five columns beside it huddled in the left 40% with a dead gap before the
curve. It takes a bounded third now, so the auto layout spreads the rest over
the columns that have content to show, and a long flow name still widens its
own column into the card's scroll.
`si` reaches the rollup counts it was written for — 179240 executions reads as
"179k", and the failure badge and the failures tile follow. The plain window
leaves the small ones alone: 615 is still 615. The standing counts (nodes,
flows running, queue depth) stay exact, as does everything acted on digit by
digit.
The fade ran a full 40 of the 100-unit box. In a table cell three times the
width of a panel that washed out readings still worth seeing, so it is 25 —
a hint at the left end rather than a quarter of the series.
The trend curve was two components: a rich one in the node panel and on edges,
and a bare line in the flow table. It is one `Sparkline` now, taking the colour
token, the height and whether the live dot and the readout show. The flow table
draws its rollup in the chart ramp with no dot — the rollups are polled, so the
right edge is the last completed slice rather than this instant — and keeps its
"nothing yet" state, as the panel keeps its three distinct silences.
All of them fade out to the left, through an SVG mask over the curve and its
area. The dot sits outside the mask: the newest reading is the one thing that
must stay solid.
`si` replaces `compact` and the ad-hoc "k" the uPlot axis carried. It prefixes
k/M/G and m/µ, but only outside 0.01–1000, where the plain number is already
the shortest thing to read and a written unit ("0.4 ms") stays honest. The
sparkline readout asks for four digits, so two neighbouring readings never
collapse into one string. Exact counts and anything the user acts on — a
payload, a form field, the edge inspector's value — are left unrounded.
`src/lib/utils.check.ts` asserts the rounding cases.
The activity table now spans its card: the flow name anchors the left, the four
numbers read down their own centre, and the trend takes the slack on the right.
Panel rhythm steps up one notch, gap-5 to gap-6 outside and gap-2 to gap-3
within a section.
Home is the one overview now: the brain graph flat across the top, the flow
switches, then the health sections. The /brain and /health routes and their
sidebar entries are gone.
- charts report their cursor and clicks, so hovering one filters the list
beside it to that minute and a click pins it until Escape or Clear
- chart values round to about three significant digits, the legend mounts
under the plot so it can wrap without leaving the card, and axis ticks
shorten past a thousand
- the embedded brain leaves the wheel to the page rather than zooming
- number fields no longer draw their up/down spinner (NOTEPAD)
The floating bars used to make way for a side panel, which hid Publish
exactly when a node had just been edited. They keep their lane now and
the panel is inset out of it; the enlarged code editor stays a floating
surface between the two bars rather than covering them.
Shortcuts are one small registry (`lib/shortcuts.ts`): undo, redo, ⌘K,
copy, paste, and ⌘S — which publishes the flow, or applies the code when
the editor has focus. Copying carries a node's own source through
localStorage, so a paste works in another flow too.
A flow is now named where a node is, at the top of its panel and only
there, and both take effect on Confirm. Flow-level edits go through the
undo stack with everything else, clicking the canvas puts the flow panel
away, a failing node opens the logs filtered to its own traceback, and
the panel carries its test id on a phone as well.
The shared duration tokens had been stretched to slow the pulses down, which
also slowed every panel and dock animation and left `motion.ts` out of step
with the CSS. Both are back to 200/300 ms, and the pulses use a new
`--duration-pulse` (500 ms): a message arriving is a signal, not a state
change, and it is the only thing here that should linger. The edge pulse read
its own 300 ms constant, so it now takes the token through `motion.ts` and the
two sides cannot drift again.
The code editor also expands: the same node panel fills the content region
beside the sidebar, the canvas chrome steps aside rather than floating on top,
and the settings above the editor keep a readable width.
The repo moves to git.stroblme.de:Fluksio/app.git and its sibling to
Fluksio/index.git, so every cross-repo path reference moves with it. The
architecture sources the roadmap cites now live in the docs repo under
architecture/, and the brand marks in the index repo's src/assets.
Also replaces the placeholder README with one that reflects the actual layout.
Replace the stock shadcn OKLCH neutral palette (whose teal --primary had no
relation to the brand) with the fluksio token block duplicated verbatim from
the root DESIGN-GUIDELINES.md, add the self-hosted Inter variable face, the
shared lib/motion.ts presets, a DESIGN.md pointer stub and the
no-arbitrary-font-size hook.
The submodule collapse was only half applied: .gitmodules was deleted but
backend/ and frontend/ were still recorded as gitlinks, so none of their
files were tracked. Replace the gitlinks with the real trees.
Also untrack .env (it carried placeholder secrets) in favour of a tracked
.env.example, drop the committed __pycache__, and narrow the blanket *.png
ignore that would have swallowed design assets.