A new user could sign in and see an empty page with no way forward. Setup
walks them through it: create a farm (reusing their organisation if they
already have one); get the gateway settings with a freshly minted token,
shown once and removed from the page when dismissed, with a confirmation
first if a gateway is already connected, since minting replaces the old
token; add a Bambu printer by address, serial and access code (a printer
the gateway discovered fills the form in); remove a printer behind a named
confirmation that says it also deletes the stored credential.
The access code field is cleared after every submit, success or failure, and
the page says plainly that the code goes to the gateway and nowhere else.
Add is disabled while no gateway is connected, because the code has nowhere
to go.
The fleet page now lists open alerts above the printers (polled every 5s)
with a Dismiss button, and points an empty account at Setup.
Verified in real Chrome by scripts/e2e-setup-browser.sh in continuum-backend:
nothing -> farm -> gateway -> printer -> failed-print alert -> dismissed ->
removed, with no page errors.
When the fleet page is empty or a printer is "unknown", the reason is
almost always upstream: the gateway is not connected, or discovery never
reported. A strip per farm now says which - "Gateway online/offline/
checking" (no answer yet is not offline) - with a collapsible list of the
printers discovery found, each marked already added, same address as an
added printer, or not added yet, using the backend's serial/address match.
It also says plainly when discovery has not reported at all, which is the
default, as distinct from a scan that found nothing.
Read-only on purpose: adding a printer from the UI still needs the proxy's
offline-capable store and a credential pass-through that is never kept in
the cloud. Polled every 10s (gateway presence has no push channel); a
failed poll keeps the last answer instead of flipping to offline.
Checked in real Chrome against the live stack (Gateway online, discovery
not reported, no page errors); the static SPA still generates, now 6 routes.
The console could watch and control printers but not give them work. The
Jobs page uploads a .3mf/.gcode: it creates the job (POST /jobs), PUTs the
file straight to the presigned storage URL with a progress bar, and lists
the farm's jobs, polling every 5s since there is no job push channel yet.
The scheduler then starts it on the next idle printer, so there is no Start
button - a queued job IS the request.
Deliberate details: the PUT carries no Authorization header (the URL is
presigned and an uncovered header makes storage reject it) and the same
Content-Type the URL was signed for; a failed upload deletes the job row
it just created, so no queued job waits for a file that never arrives;
only queued/uploading jobs offer Delete, matching what the backend allows.
JobUpload and JobList live in @continuum/ui and take the API calls as
props, like the printer card. A shared apiErrorMessage() replaces the
inline copy on the fleet page.
Checked in real Chrome against the live stack with job dispatch OFF: upload
to the real bucket (57 bytes landed intact), listed as queued, still queued
after a scheduler tick, Delete clears it, no page errors; the test object
was removed from the bucket afterwards. pnpm -r typecheck is clean.
The console could only watch. A printer card now offers what
POST /printers/:id/commands accepts and a person should press: pause
while printing, resume while paused, and a fan percentage.
Stop is the one command with no undo (the printer reports FAILED), so it
sits apart from Pause and needs a second, named confirmation
("Cancel <job>? ... cannot be resumed"). Offline and never-reported
printers get no controls, since every command would be refused as
unreachable. A successful reply only means the printer accepted the
command, so the notice says to watch the status line rather than claiming
the state changed. The backend's own error message is shown for a 403, 409
or 502.
PrinterControls takes a `send` function like the card takes `signal`, so
@continuum/ui still knows nothing of the API; no function, no controls.
send_gcode_line and start_print are deliberately not offered here.
Verified in real Chrome against the live stack with the command requests
intercepted in the browser (the H2C was mid-print): controls render per
state, Stop shows its confirmation and Keep printing sends nothing, the
body is {action:"set_fan_speed",percent:100}, a 409 surfaces its message,
no page errors. pnpm -r typecheck is clean.
The login page offered only a magic link, and the devstack's Supabase
cannot send email: POST /auth/v1/otp for an existing user answers
500 "Error sending magic link email". So nobody could sign in through
the page at all; the e2e browser run only got in by calling the Supabase
client directly.
NUXT_PUBLIC_PASSWORD_LOGIN=true now shows a password form beside the
magic-link one. It is off by default and the form says it is testing
only, so a build for real users never shows it unless someone sets the
flag on purpose.
Verified against the static build (nuxt generate, 5 routes, 524 KB): in
real Chrome the SPA loads, the form signs in, the app leaves /login and
the header shows the signed-in user, with no uncaught page errors.
Also: the Nitro build leaves apps/web/dist as a symlink to the output,
and the `dist/` ignore rule matches only directories, so it showed up as
untracked. The rule is now `dist`.
The player posted its SDP offer to the gateway's go2rtc directly, which
cannot work from a browser on another origin: no CORS headers on any
go2rtc response, a preflight required because application/sdp is not
CORS-safelisted, and the password checked before CORS so that preflight
gets a 401. It now hands the offer to a `signal` function the app
supplies, which relays it through the backend.
Pinned to iceTransportPolicy "relay" whenever ICE servers are present,
so media always goes through the TURN relay and never peer-to-peer. A
hosted console cannot rely on reaching a customer's gateway directly,
and a camera that works from the workshop but not from home is worse
than one that behaves the same everywhere.
With no TURN configured it falls back to "all", which still works for a
viewer on the gateway's own network. Relay-only with no relay would
gather no candidates and fail every time, so the fallback is what makes
an unconfigured deployment degrade rather than break - and a failed
connection in that state now says so, since the browser reports nothing
but "failed" and the fix is a deployment setting.
It also waits for ICE gathering before signalling rather than trickling:
the answer comes back through a single request/response relay hop, so
there is no channel to trickle over afterwards and the offer has to
carry its candidates. Bounded at 3s, because gathering can hang on a
candidate that never resolves and signalling with what we have beats
waiting forever.
`signal` is a prop rather than a fetch in the component, like `stream`
already was: @continuum/ui stays plain Vue that knows nothing of the API,
which is what lets it render from a test or the desktop shell.
Measured against go2rtc 1.9.14: it sends no Access-Control-Allow-Origin
on any response, auth or not; the SDP POST needs a preflight anyway
because application/sdp is not a CORS-safelisted Content-Type; and
go2rtc checks basic auth before CORS, so the preflight - which browsers
always send without credentials - gets a 401.
So this component has never worked from a browser on another origin, and
no credential handed to the frontend could fix it. The gateway's API now
requires a password, which closes the hole where anything on the
workshop LAN could watch, and changes the symptom here from a CORS error
to a 401. Both are the same missing piece: the relay.
Also stops the two most misleading errors in this path. A blocked
cross-origin request rejects as an opaque TypeError ("Failed to fetch")
that reads as the gateway being down, and a 401 reads as a wrong URL or
a missing camera. Both now say what they are.
apiBase and wsBase defaulted to localhost:3001; continuum-backend
listens on 3000 when PORT is unset, in both src/index.ts and its
.env.example. Starting both with no configuration therefore produced a
console whose every request was refused by a port with nothing behind
it - which presents as a broken API rather than a wrong address.
The telemetry store and the printer card described a protocol no backend
ever spoke. The store expected `{ type, data: { printerId, progressPct,
state } }` with a closed five-value state enum; `/ws/telemetry` sends
`{ type: "printer.telemetry", printer: {...} }` with `id`,
`progressPercent` and an open `status`, and prefaces it with a `ready`
frame. The card also displayed `printer.model`, which the printer
resource has never had - it has `vendor`.
Authentication was the other half: the socket now connects with the
subprotocol list `["bearer", token]`, which is what the backend gates
the upgrade on, and never a query parameter. The token is read from the
session on each attempt, because supabase-js refreshes it hourly and a
reconnect replaying the first token would work right up until it
mattered. With no session it does not connect at all, and app.vue drives
connect/disconnect from the session rather than a one-shot onMounted.
Reconnection is now backed off from 1s to 30s instead of a flat 3s. A
browser cannot see why an upgrade failed - a rejected token and an
unreachable server are both just onerror then onclose - so a token the
server will never accept previously retried every 3s for as long as the
tab stayed open.
`disconnect()` no longer reconnects the socket it just closed, via a
`wanted` flag; handlers from a replaced socket are ignored.
Null is no longer rendered as zero. A claimed printer whose gateway has
never reported has no temperature, and 0 degrees is a different claim:
null readings stay out of the graph's history, the labels show a dash,
the progress bar is omitted rather than drawn empty, and a null status
reads "unknown" rather than the old "offline", which asserted something
nobody had observed. `lastSeenAt` is shown, since it is what separates
an idle printer from one nothing has heard from in an hour.
The card takes one `printer` prop instead of a row plus a `live`
overlay - the push and the REST row are the same shape by design, so
pages/index.vue replaces one with the other and @continuum/ui stays
plain Vue that knows nothing of stores or the API. Status styling now
covers all six values of the backend's printer_status enum; paused and
maintenance previously drew as ordinary text.
Types are hand-mirrored in packages/ui/src/types.ts from
continuum-schemas, the convention this project already uses across
TS and Rust. @continuum/ts-types is in another repository and is not
resolvable here; a `link:` out of the repo would break on any checkout
not laid out like this machine's. Field names are verified identical to
the schema's.
Typechecks clean (nuxt typecheck, vue-tsc). Not exercised at runtime;
the protocol it now implements is the one verified live by
continuum-backend's scripts/e2e-edge.sh.
Auth is Supabase end to end and always was: the backend verifies access
tokens asymmetrically against the project's published JWKS and holds no
signing key of its own, and `requireUser` wants that token in
`Authorization: Bearer`. Nothing about that needed changing.
What was wrong is only here. The two REST calls forwarded
`useRequestHeaders(["cookie"])`, which is what you do when a Nuxt server
is proxying a browser's request - and this app sets `ssr: false` so the
same static bundle can serve the browser and the Tauri shell. There is no
server to forward anything, so the header arrived empty and every call
should have been getting a flat 401.
The token was to hand the whole time. `stores/telemetry.ts` has been
reading `session.access_token` for its socket since it was written; the
REST side simply never did.
`useApiFetch` is now the one way this app calls the backend, and it reads
the token inside the fetcher rather than capturing it at setup, because
supabase-js refreshes the session in the background about once an hour - a
captured token would work until it quietly stopped. With no session it
omits the header entirely rather than sending an empty Bearer, which the
backend rejects with a message about the header's shape and reads like a
client bug; getting the user a session is the Supabase module's redirect's
job.
The socket keeps using a `?token=` query parameter, which is not an
inconsistency: a browser WebSocket cannot set request headers.
`nuxt typecheck` clean.
`WebRTCStreamView` has existed since before this commit with no consumer:
nothing rendered it, and the URL it would have used came from a
`go2rtcStreamBase` in `nuxt.config.ts` defaulting to
`http://localhost:1984`.
That default was the real problem. A camera stream lives on a gateway box
inside one farm's network, each farm has its own, and the gateway now
reports its address to the backend when it connects. A compiled-in
constant could only ever be correct for a single-farm install, and its
localhost default pointed every player at the operator's own laptop. It
is gone, replaced by `fetchPrinterStream`, which asks
`GET /printers/:id/stream`.
Absence is the normal case here, not an error: only Bambu's X1/H2 class
publishes a camera URL a gateway can restream, so a P1 or A1 has a camera
and nothing to connect to. The backend 404s for those, this resolves to
null, and the card renders no player - offering one that can never load
would be worse than offering nothing. The lookup also runs only for
printers whose `cameraStream` the fleet list already reports, so a
twenty-printer farm does not make twenty requests to learn that eighteen
of them 404.
`PrinterCard` takes the stream as a prop rather than fetching it, like
`live` above it: `packages/ui` is plain Vue that knows nothing about the
API or about Nuxt, and that is what lets these components render from a
test or the desktop shell with no server behind them.
Three things in `WebRTCStreamView` that would have bitten whoever first
pointed it at a real printer:
- The fetch status was never checked. go2rtc answers a request for a
stream it does not have with an error page, and handing that to
`setRemoteDescription` throws about SDP parsing - which sends you
looking at the wrong layer entirely.
- `connect()` is async inside a watcher with nothing catching it, so a
failure left the badge on "connecting" forever and showed up only as an
unhandled rejection in the console.
- Two prop changes in quick succession raced: the slower request could
finish last and apply its answer to a connection already replaced and
closed, leaving a black video with a "connected" badge.
No `iceServers`, deliberately and now commented: both ends are on the
same LAN, so host candidates are enough. The same fact is the MVP's
limitation - there is no relay, so this plays for someone on the
printers' network and for nobody else.
`nuxt typecheck` and `vue-tsc --noEmit` both clean.
Continuum is intended to be sold, so every dependency has to permit
distribution in closed-source commercial software. Our own crate and npm
packages carried no licence field, which made `cargo deny` report the
Tauri crate as `unlicensed`.
The Nuxt/Tauri dependency graph audited clean (MIT/Apache/ISC/BSD, plus
BlueOak-1.0.0 - which is why this allow list carries BlueOak where
continuum-cad's did not). Two entries worth knowing, both recorded in
continuum-common's docs/LICENSES.md: `node-forge` is
`(BSD-3-Clause OR GPL-2.0)` and we elect BSD-3-Clause, and
`caniuse-lite` is CC-BY-4.0 build-time data that is not bundled into the
built SPA, so no attribution obligation today.
Real bugs found by actually compiling the Tauri crate for the first time:
- Cargo.toml declared a [lib] with no src/lib.rs (cargo refused to parse
the manifest) — added the standard Tauri 2 main.rs/lib.rs split.
- generate_context!() needs real icon files; icons/ only had a .gitkeep.
Added placeholder PNG/ICO/ICNS icons.
Also simplified: dropped the tray icon + menu (one more moving part not
needed yet), and the telemetry store's exponential backoff is now a fixed
3s retry, matching the same simplification made to continuum-proxy's
uplink client.
Fixed two real TS bugs while typechecking apps/web for the first time
(missing @types/node for nuxt.config.ts's process.env access, untyped
useFetch call inferring 'never'). Verified: cargo check (desktop),
vue-tsc --noEmit (web + ui packages) all clean.