Compare commits

..
11 Commits
Author SHA1 Message Date
octoturge 571a2c0ca7 Show each farm's gateway status and what discovery found
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.
2026-10-29 14:41:26 +00:00
octoturge c7f85870db Add a Jobs page: upload a sliced file, watch it queue, delete it
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.
2026-10-29 14:39:37 +00:00
octoturge 8bf56d1325 Add printer controls to the fleet cards: pause, resume, stop, fan
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.
2026-10-29 10:21:34 +00:00
octoturge 4519c3ba50 Let testers sign in: an opt-in password form on the login page
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`.
2026-10-29 10:12:19 +00:00
octoturge 6fab9072d4 Play cameras through the relay instead of straight at the gateway
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.
2026-10-19 00:33:18 +00:00
octoturge 1874bbe8da Record why the camera player cannot reach a gateway directly
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.
2026-10-18 22:02:02 +00:00
octoturge 35f39ae502 Point the console at the port the backend actually listens on
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.
2026-10-18 19:54:48 +00:00
octoturge ba2eb4d379 Make the console speak the backend's telemetry protocol
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.
2026-10-18 19:54:28 +00:00
octoturge a558156968 Send the Supabase token the app already had
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.
2026-10-15 23:21:01 +00:00
octoturge e928fcf658 Show a printer's camera, from the backend's address not a build-time one
`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.
2026-10-15 23:09:54 +00:00
octoturge 6d6ed62836 Declare proprietary licensing and enforce permissive-only dependencies
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.
2026-10-03 10:10:15 +00:00
24 changed files with 1365 additions and 105 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
node_modules/
.nuxt/
.output/
dist/
dist
.env
.env.local
*.log
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@continuum/desktop",
"license": "UNLICENSED",
"private": true,
"version": "0.1.0",
"type": "module",
+4
View File
@@ -4,6 +4,10 @@ version = "0.1.0"
description = "Continuum operator console desktop shell"
edition = "2021"
rust-version = "1.77"
# Proprietary: deliberately not an SPDX id, so cargo-deny treats it as
# first-party. See docs/LICENSES.md in continuum-common.
license = "LicenseRef-Proprietary"
publish = false
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
+36 -2
View File
@@ -3,9 +3,17 @@ import { useTelemetryStore } from "~/stores/telemetry";
const telemetry = useTelemetryStore();
const user = useSupabaseUser();
const session = useSupabaseSession();
// Driven by the session rather than a one-shot onMounted: the session can
// arrive after mount, and sign-out should drop the socket instead of leaving
// it running on a token that is about to stop being valid.
onMounted(() => {
telemetry.connect();
watch(
() => session.value?.access_token,
(token) => (token ? telemetry.connect() : telemetry.disconnect()),
{ immediate: true },
);
});
onBeforeUnmount(() => {
@@ -17,8 +25,18 @@ onBeforeUnmount(() => {
<div class="app-shell">
<header class="app-shell__header">
<span class="app-shell__brand">Continuum</span>
<nav class="app-shell__nav">
<NuxtLink to="/">Fleet</NuxtLink>
<NuxtLink to="/jobs">Jobs</NuxtLink>
</nav>
<span class="app-shell__status" :class="telemetry.connected ? 'is-online' : 'is-offline'">
{{ telemetry.connected ? "Live" : "Reconnecting…" }}
{{
telemetry.link === "connected"
? "Live"
: telemetry.link === "connecting"
? "Connecting…"
: "Offline"
}}
</span>
<span v-if="user" class="app-shell__user">{{ user.email }}</span>
</header>
@@ -70,4 +88,20 @@ body {
.app-shell__main {
padding: 1.25rem;
}
.app-shell__nav {
display: flex;
gap: 0.75rem;
}
.app-shell__nav a {
color: inherit;
opacity: 0.7;
text-decoration: none;
}
.app-shell__nav a.router-link-exact-active {
opacity: 1;
text-decoration: underline;
}
</style>
+53
View File
@@ -0,0 +1,53 @@
/**
* The one way this app calls `continuum-backend`.
*
* Auth is Supabase throughout: the backend verifies access tokens
* asymmetrically against the project's published JWKS and holds no signing
* key of its own (`continuum-backend/src/auth/verifier.ts`), and
* `requireUser` wants that token in `Authorization: Bearer`. So this attaches
* it, and that is the whole of the client's side of authentication.
*
* What this replaces: `useRequestHeaders(["cookie"])` on each call site.
* Forwarding a cookie is what you do when a Nuxt *server* is proxying a
* browser's request, and this app sets `ssr: false` so that it can ship the
* same static bundle to the browser and to the Tauri shell - there is no
* server to forward anything, so the header arrived empty and every call got
* a 401. The token was already to hand the whole time; the telemetry store
* has been reading it for its socket.
*
* The token is read inside the fetcher rather than captured when this is
* called, because supabase-js refreshes the session in the background about
* once an hour. A token captured at setup would work until it quietly
* stopped.
*/
export function useApiFetch() {
const config = useRuntimeConfig();
const session = useSupabaseSession();
return <T>(path: string, options: Parameters<typeof $fetch>[1] = {}) => {
const token = session.value?.access_token;
return $fetch<T>(`${config.public.apiBase}${path}`, {
...options,
headers: {
// Omitted rather than sent empty when there is no session. An empty
// Bearer is a malformed credential the backend rejects with a
// message about the header's shape, which reads like a client bug;
// no header at all is the honest "not signed in". The Supabase
// module's own redirect is what handles getting the user a session.
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
};
}
/**
* A sentence for a failed API call. The backend's `{ error: { message } }`
* body is what explains a 403 (a viewer cannot do this), a 409 or a 502, so
* it is surfaced instead of a bare "fetch failed".
*/
export function apiErrorMessage(err: unknown, fallback: string): string {
const body = (err as { data?: { error?: { message?: string } } }).data;
return body?.error?.message ?? fallback;
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Where a printer's camera can be watched, from `GET /printers/:id/stream`.
*/
export interface PrinterStream {
/** The go2rtc stream name to negotiate for. */
cameraId: string;
/**
* ICE servers for the player's `RTCPeerConnection`, minted per request and
* short-lived. Empty when the backend has no TURN configured.
*/
iceServers: RTCIceServer[];
/**
* Whether TURN is configured at all, which is the difference between "no
* relay is needed here" and "the relay is missing". Empty `iceServers`
* with this true would mean minting silently failed.
*/
turnConfigured: boolean;
}
/**
* Asks the backend where a printer's camera can be watched, or `null` if it
* cannot.
*
* It returns no URL, and that is the point. A browser cannot reach a gateway
* directly: go2rtc sends no CORS headers and checks its API password before
* CORS, so an unauthenticated preflight gets a 401 - measured against
* 1.9.14 - and a gateway sits behind a workshop NAT anyway. This used to
* return the gateway's LAN address, which could only have worked for an
* operator standing on that same network.
*
* So what comes back is a camera name and the ICE servers to negotiate with.
* The offer goes to the backend, which relays it to the gateway over the
* socket the gateway already holds open; the media then travels
* browser-to-gateway through the TURN relay, DTLS-SRTP encrypted, so neither
* the backend nor the relay can read it.
*
* A plain function and not a `useAsyncData` wrapper despite the file it lives
* in: the caller needs one of these per printer, discovered from a list that
* itself arrives asynchronously, and Nuxt's data composables may only be
* called while a component is setting up. A loop over a resolved list is not
* that. It takes the fetcher as an argument for the same reason - the caller
* builds it in setup, where `useApiFetch` is valid.
*
* **Absence is the normal case, not an error.** Only Bambu's X1/H2 class
* publishes a camera URL a gateway can restream; a P1 or A1 reports that it
* has a camera and gives nothing to connect to. The backend answers 404 for
* those, so this answers `null` and the caller shows no player at all -
* rendering one that can never load would be worse than rendering nothing.
*
* The credentials are deliberately not cached: they are short-lived, so a
* player that reconnects should ask again rather than retry a session with a
* credential that has since expired.
*/
export async function fetchPrinterStream(
apiFetch: ReturnType<typeof useApiFetch>,
printerId: string,
): Promise<PrinterStream | null> {
try {
return await apiFetch<PrinterStream>(`/printers/${printerId}/stream`);
} catch (err: unknown) {
// A 404 means "this printer has no watchable camera", which is expected
// often enough that treating it as a failure would put an error state on
// most cards in a mixed fleet. Anything else is a real problem: logged
// and then treated as "no stream", because a broken camera lookup should
// not take the fleet overview down with it.
const status = (err as { statusCode?: number }).statusCode;
if (status !== 404) {
console.warn(`could not look up the camera stream for printer ${printerId}`, err);
}
return null;
}
}
+21 -3
View File
@@ -21,9 +21,27 @@ export default defineNuxtConfig({
runtimeConfig: {
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3001",
wsBase: process.env.NUXT_PUBLIC_WS_BASE || "ws://localhost:3001",
go2rtcStreamBase: process.env.NUXT_PUBLIC_GO2RTC_STREAM_BASE || "http://localhost:1984",
// 3000, which is what continuum-backend listens on when PORT is unset
// (src/index.ts, and its .env.example). These defaulted to 3001, so a
// developer who started both with no configuration got a console that
// could not reach its own backend - every request refused by a port
// with nothing behind it, which reads like a broken API rather than a
// wrong address. Keep these in step with that default.
apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3000",
wsBase: process.env.NUXT_PUBLIC_WS_BASE || "ws://localhost:3000",
// Shows a password form on the login page. Off unless asked for: the
// real sign-in is a magic link, but that needs the Supabase project to
// send email, and the devstack's does not (`POST /auth/v1/otp` answers
// 500 "Error sending magic link email"), which left no way to sign in
// at all. A build meant for real users must not set this.
passwordLogin: process.env.NUXT_PUBLIC_PASSWORD_LOGIN === "true",
// There is deliberately no go2rtcStreamBase here any more. A camera
// stream lives on a gateway inside a particular farm's network, each
// farm has its own, and the gateway tells the backend its address when
// it connects - so a build-time constant could only ever be right for a
// single-farm install, and its localhost:1984 default meant the UI
// quietly pointed every player at the operator's own machine. Asked for
// per printer instead: see `usePrinterStream`.
},
},
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@continuum/web",
"license": "UNLICENSED",
"private": true,
"version": "0.1.0",
"type": "module",
+116 -13
View File
@@ -1,34 +1,137 @@
<script setup lang="ts">
import { PrinterCard } from "@continuum/ui";
import {
FarmStatus,
PrinterCard,
type AppFarm,
type AppFarmDiscovery,
type AppPrinterState,
type PrinterCommand,
} from "@continuum/ui";
import { useTelemetryStore } from "~/stores/telemetry";
interface Printer {
id: string;
name: string;
model: string;
farmId: string;
const telemetry = useTelemetryStore();
const apiFetch = useApiFetch();
const { data: printers } = await useAsyncData("printers", () =>
apiFetch<AppPrinterState[]>("/printers"),
);
// Which row to draw: the live push if there is one, else the fetched row.
//
// Merged here rather than passing `PrinterCard` a second `live` prop. A push
// is the same shape as a `GET /printers` row (that is the contract's whole
// point), so the pushed object simply *replaces* the fetched one - replaces,
// not spreads, because a push is the complete resource and a spread would let
// a field the push omitted survive from the older row. The page does this
// rather than the component because `@continuum/ui` is plain Vue that knows
// nothing of stores or the API, so it can render in a test or the Tauri shell
// with no server. Known edge: a push that predates a refetch can briefly
// shadow the fresher row; the next push corrects it.
// The REST list still decides *which* printers exist and in what order.
function current(printer: AppPrinterState): AppPrinterState {
return telemetry.printers[printer.id] ?? printer;
}
const telemetry = useTelemetryStore();
const config = useRuntimeConfig();
// Looked up per printer, because the answer is per printer, but only for the
// printers the list already says have a camera. Asking for all of them would
// mean one request per machine in the fleet, most of them 404 by design.
const streams = ref<Record<string, PrinterStream | null>>({});
const { data: printers } = await useFetch<Printer[]>(`${config.public.apiBase}/printers`, {
headers: useRequestHeaders(["cookie"]),
/**
* Relays one printer's WebRTC offer through the backend and returns the
* gateway's answer.
*
* Lives here rather than in `@continuum/ui` for the same reason the stream
* lookup does: that package is plain Vue that knows nothing of the API, so
* the app supplies the one function that talks to it.
*/
async function signalOffer(printerId: string, sdp: string): Promise<string> {
const answer = await apiFetch<{ sdp: string }>(`/printers/${printerId}/webrtc`, {
method: "POST",
body: { sdp },
});
return answer.sdp;
}
// Which farms exist, and for each whether its gateway is connected and what
// discovery has found. Polled, because gateway presence has no push channel;
// ten seconds is quick enough to notice an unplugged box without hammering.
const { data: farms } = await useAsyncData("farms", () => apiFetch<AppFarm[]>("/farms"));
const farmStatus = ref<Record<string, AppFarmDiscovery>>({});
async function refreshFarmStatus() {
for (const farm of farms.value ?? []) {
try {
farmStatus.value[farm.id] = await apiFetch<AppFarmDiscovery>(`/farms/${farm.id}/discovered-printers`);
} catch {
// Keep the last answer rather than flip to "offline" over a failed poll.
}
}
}
let farmPoll: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
void refreshFarmStatus();
farmPoll = setInterval(() => void refreshFarmStatus(), 10_000);
});
onBeforeUnmount(() => clearInterval(farmPoll));
/**
* Sends one command to a printer and turns a refusal into a sentence.
*
* The backend's `{ error: { message } }` body is what explains a 403 (a
* viewer cannot command), a 409 (no gateway connected) or a 502 (the printer
* would not do it), so it is surfaced rather than a bare "fetch failed".
*/
async function sendCommand(printerId: string, command: PrinterCommand): Promise<void> {
try {
await apiFetch(`/printers/${printerId}/commands`, { method: "POST", body: command });
} catch (err) {
throw new Error(apiErrorMessage(err, "the command could not be sent"));
}
}
watch(
printers,
(list) => {
for (const printer of list ?? []) {
if (!printer.cameraStream || printer.id in streams.value) continue;
// Marked before the request resolves, so a re-render in the meantime
// does not fire a second lookup for the same printer. `null` renders
// no player, which is also the right thing to show while this is in
// flight.
streams.value[printer.id] = null;
void fetchPrinterStream(apiFetch, printer.id).then((stream) => {
streams.value[printer.id] = stream;
});
}
},
{ immediate: true },
);
</script>
<template>
<div class="fleet">
<h1>Fleet overview</h1>
<FarmStatus v-for="farm in farms ?? []" :key="farm.id" :farm="farm" :status="farmStatus[farm.id] ?? null" />
<div class="fleet__grid">
<PrinterCard
v-for="printer in printers ?? []"
:key="printer.id"
:printer="printer"
:live="telemetry.printers[printer.id]"
:printer="current(printer)"
:stream="streams[printer.id]"
:signal="(sdp: string) => signalOffer(printer.id, sdp)"
:send="(command: PrinterCommand) => sendCommand(printer.id, command)"
/>
</div>
<p v-if="!printers?.length">No printers registered yet.</p>
<p v-if="!printers?.length">
<template v-if="telemetry.link !== 'connected' || telemetry.visiblePrinters === null">
No printers loaded yet.
</template>
<template v-else-if="telemetry.visiblePrinters === 0">No printers registered yet.</template>
<template v-else>Printers exist but none has been listed - try refreshing.</template>
</p>
</div>
</template>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { JobList, JobUpload, type AppFarm, type AppJob, type AppPrinterState } from "@continuum/ui";
const apiFetch = useApiFetch();
const { data: farms } = await useAsyncData("farms", () => apiFetch<AppFarm[]>("/farms"));
const { data: printers } = await useAsyncData("job-printers", () => apiFetch<AppPrinterState[]>("/printers"));
const { data: jobs, refresh } = await useAsyncData("jobs", () => apiFetch<AppJob[]>("/jobs"));
const printerNames = computed(() =>
Object.fromEntries((printers.value ?? []).map((printer) => [printer.id, printer.name])),
);
// There is no job push channel yet (the telemetry socket carries printers
// only), so a job moving from queued to printing shows up by polling. Five
// seconds matches the scheduler's own tick, which is the fastest it can move.
let poll: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
poll = setInterval(() => void refresh(), 5_000);
});
onBeforeUnmount(() => clearInterval(poll));
/**
* PUT with progress. `XMLHttpRequest` because `fetch` cannot report upload
* progress, and a sliced project is tens of megabytes. Plain request with no
* Authorization header on purpose: the URL is presigned, and an extra header
* the signature did not cover makes the storage server reject it.
*
* The Content-Type must be the one the URL was signed for.
*/
function putFile(url: string, file: File, contentType: string, onProgress: (percent: number) => void) {
return new Promise<void>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("PUT", url);
request.setRequestHeader("Content-Type", contentType);
request.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress((event.loaded / event.total) * 100);
};
request.onload = () =>
request.status >= 200 && request.status < 300
? resolve()
: reject(new Error(`storage refused the upload (HTTP ${request.status})`));
request.onerror = () => reject(new Error("could not reach storage - blocked, or offline"));
request.send(file);
});
}
async function uploadJob(farmId: string, file: File, onProgress: (percent: number) => void) {
const contentType = file.type || "application/octet-stream";
let created: { job: AppJob; uploadUrl: string };
try {
created = await apiFetch("/jobs", { method: "POST", body: { farmId, fileName: file.name, contentType } });
} catch (err) {
throw new Error(apiErrorMessage(err, "the job could not be created"));
}
try {
await putFile(created.uploadUrl, file, contentType, onProgress);
} catch (err) {
// The row exists before the file does, so a failed upload would leave a
// queued job whose file never arrives. Removed rather than left to sit.
await apiFetch(`/jobs/${created.job.id}`, { method: "DELETE" }).catch(() => undefined);
await refresh();
throw err;
}
await refresh();
}
async function removeJob(jobId: string) {
try {
await apiFetch(`/jobs/${jobId}`, { method: "DELETE" });
} catch (err) {
throw new Error(apiErrorMessage(err, "the job could not be deleted"));
}
await refresh();
}
</script>
<template>
<div class="jobs">
<h1>Jobs</h1>
<JobUpload :farms="farms ?? []" :upload="uploadJob" />
<JobList :jobs="jobs ?? []" :printer-names="printerNames" :remove="removeJob" />
</div>
</template>
<style scoped>
.jobs {
display: grid;
gap: 1.25rem;
}
</style>
+33
View File
@@ -3,6 +3,22 @@ const email = ref("");
const sent = ref(false);
const error = ref<string | null>(null);
const client = useSupabaseClient();
const passwordLogin = useRuntimeConfig().public.passwordLogin;
const password = ref("");
async function signInWithPassword() {
error.value = null;
const { error: authError } = await client.auth.signInWithPassword({
email: email.value,
password: password.value,
});
if (authError) {
error.value = authError.message;
return;
}
// The Supabase module redirects away from /login once a session exists.
await navigateTo("/");
}
async function sendMagicLink() {
error.value = null;
@@ -23,6 +39,12 @@ async function sendMagicLink() {
<button type="submit">Send magic link</button>
</form>
<p v-else>Check your inbox for a sign-in link.</p>
<form v-if="passwordLogin" class="login__password" @submit.prevent="signInWithPassword">
<p class="login__hint">Password sign-in is enabled for this build (testing only).</p>
<input v-model="email" type="email" placeholder="you@farm.com" required />
<input v-model="password" type="password" placeholder="password" required autocomplete="current-password" />
<button type="submit">Sign in</button>
</form>
<p v-if="error" class="login__error">{{ error }}</p>
</div>
</template>
@@ -36,6 +58,17 @@ async function sendMagicLink() {
gap: 0.75rem;
}
.login__password {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.login__hint {
font-size: 0.75rem;
opacity: 0.7;
}
.login__error {
color: #f2604c;
}
+115 -44
View File
@@ -1,83 +1,154 @@
import { defineStore } from "pinia";
import type { AppPrinterState, AppTelemetryMessage } from "@continuum/ui";
export interface PrinterTelemetry {
printerId: string;
farmId: string;
state: "idle" | "printing" | "paused" | "error" | "offline";
nozzleTempC: number;
bedTempC: number;
progressPct: number;
updatedAt: string;
}
/**
* Live printer state from `continuum-backend`'s `/ws/telemetry`.
*
* The message shape is the backend's, mirrored in `@continuum/ui`'s types
* from `continuum-schemas`. This file used to declare its own
* (`printerId`, `progressPct`, a closed state enum, a `data` envelope) and
* no backend ever sent it.
*/
export type TelemetryLink = "disconnected" | "connecting" | "connected";
interface TelemetryState {
socket: WebSocket | null;
connected: boolean;
printers: Record<string, PrinterTelemetry>;
link: TelemetryLink;
/**
* How many printers the server says this user can see, from its `ready`
* message; null until that arrives. This is what lets a view tell "not
* connected yet" (null) from "connected and there is genuinely nothing"
* (0) from "connected, printers exist, none has reported" (> 0 with an
* empty `printers`).
*/
visiblePrinters: number | null;
/** Latest push per printer, keyed by `printer.id`. */
printers: Record<string, AppPrinterState>;
}
const RECONNECT_DELAY_MS = 3_000;
/**
* Backoff, not a fixed interval, and the reason is that a browser cannot see
* why an upgrade failed. A rejected credential and an unreachable server look
* identical from here - no status code, just `onerror` then `onclose` - so a
* socket whose token the server will never accept would otherwise reconnect
* every 3s for as long as the tab is open. Capped, because the other failure
* is a server that is merely restarting and should be picked up promptly.
*/
const RECONNECT_DELAY_MS = 1_000;
const RECONNECT_MAX_MS = 30_000;
let retryDelay = RECONNECT_DELAY_MS;
// Held outside the reactive state: a WebSocket and a timer handle have no
// business being wrapped in proxies or serialised by devtools.
let socket: WebSocket | null = null;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
// Whether anyone wants a connection. Without it `disconnect()` would close
// the socket and the close handler would cheerfully reconnect it.
let wanted = false;
export const useTelemetryStore = defineStore("telemetry", {
state: (): TelemetryState => ({
socket: null,
connected: false,
link: "disconnected",
visiblePrinters: null,
printers: {},
}),
getters: {
connected: (state) => state.link === "connected",
printerList: (state) => Object.values(state.printers),
},
actions: {
connect() {
if (this.socket) return;
wanted = true;
if (socket) return;
const config = useRuntimeConfig();
const client = useSupabaseClient();
// Read here, at every attempt, never captured by the store: supabase-js
// refreshes the session about hourly (see `useApiFetch`), so a
// reconnect that replayed the first connection's token would work
// until the first time it was needed.
const token = useSupabaseSession().value?.access_token;
client.auth.getSession().then(({ data }) => {
const token = data.session?.access_token ?? "";
const url = `${config.public.wsBase}/ws/telemetry?token=${encodeURIComponent(token)}`;
this.open(url);
});
},
// No session means no connection, rather than an empty credential the
// server can only reject. Deliberately no retry timer either: the
// caller connects when a session appears (app.vue watches for it), and
// polling for a sign-in would be a loop with nothing to wait on.
if (!token) {
this.link = "disconnected";
return;
}
open(url: string) {
const socket = new WebSocket(url);
const { wsBase } = useRuntimeConfig().public;
socket.onopen = () => {
this.connected = true;
// The token travels in the WebSocket subprotocol list, not the URL: a
// bearer credential in a query string lands in access logs, proxy
// logs and error breadcrumbs. "bearer" must be first because the
// server echoes back the first offered protocol, and echoing the token
// would put it in a response header.
const ws = new WebSocket(`${wsBase}/ws/telemetry`, ["bearer", token]);
socket = ws;
this.link = "connecting";
ws.onopen = () => {
if (socket !== ws) return;
this.link = "connected";
// Reset only on a socket that actually opened. Resetting in
// `connect()` would make every backoff step start over, and the
// delay would never grow past the first one.
retryDelay = RECONNECT_DELAY_MS;
};
socket.onmessage = (event) => {
ws.onmessage = (event) => {
if (socket !== ws) return;
let message: AppTelemetryMessage;
try {
const payload = JSON.parse(event.data) as { type: string; data: PrinterTelemetry };
if (payload.type === "printer.telemetry") {
this.printers[payload.data.printerId] = payload.data;
}
message = JSON.parse(event.data) as AppTelemetryMessage;
} catch {
// Ignore malformed frames rather than crashing the UI.
return;
}
// Unknown `type`s fall through and are ignored, so the backend can
// add a message kind without breaking consoles that predate it.
if (message.type === "ready") {
this.visiblePrinters = message.visiblePrinters;
} else if (message.type === "printer.telemetry") {
this.printers[message.printer.id] = message.printer;
}
};
socket.onclose = () => {
this.connected = false;
this.socket = null;
setTimeout(() => this.connect(), RECONNECT_DELAY_MS);
ws.onclose = () => {
// A socket we already replaced or abandoned must not clobber the
// state of its successor.
if (socket !== ws) return;
socket = null;
this.link = "disconnected";
// `printers` is kept across the gap: a stale reading beats a blank
// card, and `lastSeenAt` on the row says how stale. `visiblePrinters`
// is reset because the next `ready` re-states it.
this.visiblePrinters = null;
if (wanted) {
retryTimer = setTimeout(() => this.connect(), retryDelay);
retryDelay = Math.min(retryDelay * 2, RECONNECT_MAX_MS);
}
};
socket.onerror = () => {
socket.close();
ws.onerror = () => {
ws.close();
};
this.socket = socket;
},
disconnect() {
this.socket?.close();
this.socket = null;
this.connected = false;
wanted = false;
clearTimeout(retryTimer);
// A deliberate disconnect is not a failure: signing back in should
// connect at once rather than inheriting the previous session's
// accumulated delay.
retryDelay = RECONNECT_DELAY_MS;
const ws = socket;
socket = null;
ws?.close();
this.link = "disconnected";
this.visiblePrinters = null;
},
},
});
+36
View File
@@ -0,0 +1,36 @@
# Licence policy for a closed-source commercial product.
# See docs/LICENSES.md in continuum-common for the rationale and the
# non-cargo dependencies (external binaries, ML models) this file cannot see.
#
# Run: cargo deny check licenses
[licenses]
# Permissive only. Anything copyleft reaching a shipped binary would make
# the product undistributable without releasing our source.
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"Unicode-3.0",
"Unlicense",
"MPL-2.0",
"CC0-1.0",
"BlueOak-1.0.0",
]
confidence-threshold = 0.9
# Our own crates declare `LicenseRef-Proprietary`, which is deliberately not
# an SPDX identifier and must never appear in the allow list above - a
# third-party crate claiming it would be a real finding. They are recognised
# as first-party by `publish = false` in their manifests.
private = { ignore = true }
[bans]
deny = []
[advisories]
yanked = "deny"
+1
View File
@@ -1,5 +1,6 @@
{
"name": "continuum-app",
"license": "UNLICENSED",
"private": true,
"version": "0.1.0",
"description": "Continuum print farm operator console - Nuxt SPA + Tauri 2 desktop shell",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@continuum/ui",
"license": "UNLICENSED",
"private": true,
"version": "0.1.0",
"type": "module",
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from "vue";
import type { AppFarm, AppFarmDiscovery } from "../types";
const props = defineProps<{ farm: AppFarm; status: AppFarmDiscovery | null }>();
// Three honest answers, not two: no answer yet is not "offline".
const gateway = computed(() =>
props.status === null ? "checking" : props.status.gatewayOnline ? "online" : "offline",
);
const candidates = computed(() => props.status?.discovery?.printers ?? []);
const discoveryNote = computed(() => {
if (!props.status) return "";
if (props.status.discovery === null)
return "Discovery has not reported - it is off by default on a gateway.";
if (!candidates.value.length)
return `Scanned ${new Date(props.status.discovery.scannedAt).toLocaleString()}: nothing found.`;
return `Scanned ${new Date(props.status.discovery.scannedAt).toLocaleString()}.`;
});
const label = (c: { name?: string | null; model?: string | null; vendor: string }) =>
[c.name, c.model].filter(Boolean).join(" / ") || c.vendor;
</script>
<template>
<section class="farm-status" :aria-label="`Farm ${farm.name}`">
<span class="farm-status__name">{{ farm.name }}</span>
<span class="farm-status__gateway" :class="`is-${gateway}`">Gateway {{ gateway }}</span>
<details v-if="status" class="farm-status__discovery">
<summary>Discovered printers ({{ candidates.length }})</summary>
<p>{{ discoveryNote }}</p>
<ul v-if="candidates.length">
<li v-for="c in candidates" :key="c.address + (c.serial ?? '')">
<strong>{{ label(c) }}</strong> - {{ c.vendor }} at {{ c.address }}
<template v-if="c.matchedOn === 'serial'"> - already added</template>
<template v-else-if="c.matchedOn === 'address'"> - same address as an added printer (check)</template>
<template v-else> - not added yet</template>
</li>
</ul>
</details>
</section>
</template>
<style scoped>
.farm-status {
display: flex;
gap: 1rem;
align-items: baseline;
flex-wrap: wrap;
font-size: 0.9rem;
}
.farm-status__name {
font-weight: 600;
}
.farm-status__gateway.is-online {
color: #35d07f;
}
.farm-status__gateway.is-offline {
color: #f2604c;
}
.farm-status__gateway.is-checking {
opacity: 0.6;
}
.farm-status__discovery ul {
margin: 0.25rem 0 0;
padding-left: 1.25rem;
}
</style>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { ref } from "vue";
import type { AppJob } from "../types";
const props = defineProps<{
jobs: AppJob[];
/** printer id -> display name, for the jobs already placed on one. */
printerNames: Record<string, string>;
/** Deletes a job that has not started; rejects with a message fit to show. */
remove: (jobId: string) => Promise<void>;
}>();
const error = ref<string | null>(null);
const removing = ref<string | null>(null);
// Only these can be deleted: the backend refuses anything that has reached a
// printer, because stopping a running print is a command, not a row delete.
const deletable = (job: AppJob) => job.status === "queued" || job.status === "uploading";
async function del(job: AppJob) {
removing.value = job.id;
error.value = null;
try {
await props.remove(job.id);
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
removing.value = null;
}
}
const statusClass = (status: string) => `is-${status.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
const when = (iso: string) => new Date(iso).toLocaleString();
</script>
<template>
<div class="job-list">
<p v-if="error" class="job-list__error" role="alert">{{ error }}</p>
<table v-if="jobs.length">
<thead>
<tr>
<th>File</th>
<th>Status</th>
<th>Printer</th>
<th>Queued</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="job in jobs" :key="job.id">
<td>{{ job.fileName }}</td>
<td :class="['job-list__status', statusClass(job.status)]">
{{ job.status }}
<template v-if="job.status === 'printing' && job.progressPct != null"> ({{ Math.round(job.progressPct) }}%)</template>
</td>
<td>{{ job.printerId ? (printerNames[job.printerId] ?? "unknown printer") : "-" }}</td>
<td>{{ when(job.createdAt) }}</td>
<td>
<button v-if="deletable(job)" type="button" :disabled="removing === job.id" @click="del(job)">
Delete
</button>
</td>
</tr>
</tbody>
</table>
<p v-else>No jobs yet.</p>
</div>
</template>
<style scoped>
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid #1f232c;
}
.job-list__error {
color: #f2604c;
}
.job-list__status.is-printing,
.job-list__status.is-completed {
color: #35d07f;
}
.job-list__status.is-paused {
color: #f0b429;
}
.job-list__status.is-failed {
color: #f2604c;
}
.job-list__status.is-cancelled {
color: #6b7280;
}
</style>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { AppFarm } from "../types";
const props = defineProps<{
farms: AppFarm[];
/**
* Creates the job, uploads the file and resolves when both are done, or
* rejects with a message fit to show. `onProgress` is 0-100 for the upload.
* Supplied by the app, like `send` on the printer card: this package knows
* nothing of the API.
*/
upload: (farmId: string, file: File, onProgress: (percent: number) => void) => Promise<void>;
}>();
const farmId = ref<string>("");
const file = ref<File | null>(null);
const busy = ref(false);
const percent = ref(0);
const notice = ref<{ kind: "ok" | "error"; text: string } | null>(null);
const input = ref<HTMLInputElement | null>(null);
// One farm is the common case; picking it for the user is the right default,
// and a select with a single option is noise.
const chosenFarm = computed(() => farmId.value || props.farms[0]?.id || "");
function onPick(event: Event) {
file.value = (event.target as HTMLInputElement).files?.[0] ?? null;
notice.value = null;
}
async function submit() {
if (!file.value || !chosenFarm.value) return;
const sending = file.value;
busy.value = true;
percent.value = 0;
notice.value = null;
try {
await props.upload(chosenFarm.value, sending, (p) => (percent.value = p));
notice.value = {
kind: "ok",
text: `${sending.name} is queued. It starts on the next idle printer.`,
};
file.value = null;
if (input.value) input.value.value = "";
} catch (err) {
notice.value = { kind: "error", text: err instanceof Error ? err.message : String(err) };
} finally {
busy.value = false;
}
}
</script>
<template>
<form class="job-upload" @submit.prevent="submit">
<label v-if="farms.length > 1">
Farm
<select v-model="farmId" :disabled="busy">
<option v-for="farm in farms" :key="farm.id" :value="farm.id">{{ farm.name }}</option>
</select>
</label>
<input
ref="input"
type="file"
accept=".3mf,.gcode,.gcode.3mf"
:disabled="busy"
@change="onPick"
/>
<button type="submit" :disabled="busy || !file || !chosenFarm">Upload and queue</button>
<progress v-if="busy" :value="percent" max="100" />
<p v-if="notice" class="job-upload__notice" :class="`is-${notice.kind}`" :role="notice.kind === 'error' ? 'alert' : 'status'">
{{ notice.text }}
</p>
<p v-if="!farms.length" class="job-upload__notice">No farm to queue jobs on yet.</p>
</form>
</template>
<style scoped>
.job-upload {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.job-upload progress {
width: 12rem;
}
.job-upload__notice {
flex-basis: 100%;
margin: 0;
}
.job-upload__notice.is-error {
color: #f2604c;
}
</style>
+107 -24
View File
@@ -1,45 +1,111 @@
<script setup lang="ts">
import { computed } from "vue";
import type { AppPrinterState, PrinterCommand } from "../types";
import PrinterControls from "./PrinterControls.vue";
import TemperatureGraph from "./TemperatureGraph.vue";
import WebRTCStreamView from "./WebRTCStreamView.vue";
interface Printer {
id: string;
name: string;
model: string;
farmId: string;
}
interface LiveTelemetry {
state: "idle" | "printing" | "paused" | "error" | "offline";
nozzleTempC: number;
bedTempC: number;
progressPct: number;
/**
* Where this printer's camera can be watched, from
* `GET /printers/:id/stream`.
*
* Passed in rather than fetched here, matching `printer`: this package is
* plain Vue with no knowledge of the API or of Nuxt, and keeping the fetching
* in the app is what lets these components be rendered from a test or the
* desktop shell without a server behind them.
*
* Undefined for most printers and that is expected - see
* `usePrinterStream`. The player is simply not rendered.
*/
interface PrinterStream {
cameraId: string;
iceServers: RTCIceServer[];
turnConfigured: boolean;
}
const props = defineProps<{
printer: Printer;
live?: LiveTelemetry;
/**
* The printer as it should be drawn right now. One prop, not a fetched row
* plus a `live` overlay: the REST row and the websocket push are the same
* shape by design, so the page replaces the one with the other before
* handing it down. That keeps this package ignorant of stores and the API,
* which is what lets it render from a test or the Tauri shell with no
* server - and means there is no second place to decide which of two
* values wins.
*/
printer: AppPrinterState;
stream?: PrinterStream | null;
/**
* Exchanges an offer SDP for an answer SDP, via the backend's relay.
*
* Passed in rather than called here, like `stream` itself: this package is
* plain Vue with no knowledge of the API or of Nuxt, which is what lets
* these components render from a test or the desktop shell with no server.
* No function means no player, rather than a player that cannot signal.
*/
signal?: (offerSdp: string) => Promise<string>;
/**
* Delivers a command to this printer. No function means no controls, so a
* read-only embedding of the card cannot offer buttons that do nothing.
*/
send?: (command: PrinterCommand) => Promise<void>;
}>();
const state = computed(() => props.live?.state ?? "offline");
// `status` is open-ended and null for a printer that has never reported.
// "unknown" is the honest word for that; the old fallback of "offline"
// asserted something nobody had observed.
const status = computed(() => props.printer.status ?? "unknown");
// Class-safe form of an arbitrary backend string.
const statusClass = computed(() => `is-${status.value.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`);
const hasTemperature = computed(
() => props.printer.nozzleTempC != null || props.printer.bedTempC != null,
);
// `lastSeenAt` is what separates "idle" from "nobody has heard from it in an
// hour", which `status` alone cannot say. Shown as an absolute time rather
// than "5 minutes ago": a relative label needs a ticking clock to stay true,
// and a card that says "just now" for an hour is worse than no label.
const lastSeen = computed(() => {
const at = props.printer.lastSeenAt;
return at ? new Date(at).toLocaleString() : null;
});
</script>
<template>
<article class="printer-card" :class="`is-${state}`">
<article class="printer-card" :class="statusClass">
<header>
<h3>{{ printer.name }}</h3>
<span class="printer-card__model">{{ printer.model }}</span>
<span class="printer-card__model">{{ printer.vendor }}</span>
</header>
<p class="printer-card__state">{{ state }}</p>
<p class="printer-card__state">
{{ status }}
<span v-if="printer.vendorState" class="printer-card__model">({{ printer.vendorState }})</span>
</p>
<p class="printer-card__model">
{{ lastSeen ? `Last heard from ${lastSeen}` : "Never reported" }}
</p>
<TemperatureGraph
v-if="live"
:nozzle-temp-c="live.nozzleTempC"
:bed-temp-c="live.bedTempC"
<WebRTCStreamView
v-if="stream && signal"
:camera-id="stream.cameraId"
:ice-servers="stream.iceServers"
:turn-configured="stream.turnConfigured"
:signal="signal"
/>
<progress v-if="live" :value="live.progressPct" max="100" />
<TemperatureGraph
v-if="hasTemperature"
:nozzle-temp-c="printer.nozzleTempC"
:bed-temp-c="printer.bedTempC"
/>
<!-- Omitted when null: an empty bar reads as 0% complete. -->
<progress v-if="printer.progressPercent != null" :value="printer.progressPercent" max="100" />
<PrinterControls v-if="send" :printer="printer" :send="send" />
</article>
</template>
@@ -56,6 +122,14 @@ const state = computed(() => props.live?.state ?? "offline");
font-size: 0.8rem;
}
/*
* One rule per value of the backend's `printer_status` enum - offline, idle,
* printing, paused, error, maintenance - plus the "unknown" this card
* substitutes for a null status. The enum was only two thirds covered, so a
* paused printer and one in maintenance drew in the default colour and read
* as ordinary. `status` is not a closed enum on the wire, so anything new
* still falls back to that default rather than breaking.
*/
.printer-card.is-printing .printer-card__state {
color: #35d07f;
}
@@ -64,7 +138,16 @@ const state = computed(() => props.live?.state ?? "offline");
color: #f2604c;
}
.printer-card.is-offline .printer-card__state {
.printer-card.is-paused .printer-card__state {
color: #f0b429;
}
.printer-card.is-maintenance .printer-card__state {
color: #9f7aea;
}
.printer-card.is-offline .printer-card__state,
.printer-card.is-unknown .printer-card__state {
color: #6b7280;
}
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { AppPrinterState, PrinterCommand } from "../types";
const props = defineProps<{
printer: AppPrinterState;
/**
* Delivers one command and resolves once the gateway has answered, or
* rejects with a message fit to show. Supplied by the app, like `signal`
* on the card: this package knows nothing of the API.
*/
send: (command: PrinterCommand) => Promise<void>;
}>();
// Offline and never-reported printers get no controls at all: a command
// would be refused with "unreachable" and the buttons would only suggest
// something could be done.
const reachable = computed(() => !["offline", "unknown"].includes(props.printer.status ?? "unknown"));
const printing = computed(() => props.printer.status === "printing");
const paused = computed(() => props.printer.status === "paused");
const busy = ref(false);
// `stop` ends the print for good and there is no undo, so it takes two
// deliberate clicks and is kept away from Pause in the layout.
const confirmingStop = ref(false);
const fanPercent = ref(100);
const notice = ref<{ kind: "ok" | "error"; text: string } | null>(null);
async function run(command: PrinterCommand, done: string) {
busy.value = true;
notice.value = null;
try {
await props.send(command);
// "Accepted" is all a successful reply means: a printer answers success
// to pause/resume/stop even when idle. The card's own status line,
// driven by telemetry, is what shows whether anything changed.
notice.value = { kind: "ok", text: `${done} - watch the status above for the result.` };
} catch (err) {
notice.value = { kind: "error", text: err instanceof Error ? err.message : String(err) };
} finally {
busy.value = false;
confirmingStop.value = false;
}
}
const fanValid = computed(
() => Number.isInteger(fanPercent.value) && fanPercent.value >= 0 && fanPercent.value <= 100,
);
</script>
<template>
<section v-if="reachable" class="controls" aria-label="Printer controls">
<div class="controls__row">
<button
v-if="printing"
type="button"
:disabled="busy"
@click="run({ action: 'control_print', control: 'pause' }, 'Pause sent')"
>
Pause
</button>
<button
v-if="paused"
type="button"
:disabled="busy"
@click="run({ action: 'control_print', control: 'resume' }, 'Resume sent')"
>
Resume
</button>
<template v-if="(printing || paused) && !confirmingStop">
<button type="button" class="controls__danger" :disabled="busy" @click="confirmingStop = true">
Stop print…
</button>
</template>
</div>
<div v-if="confirmingStop" class="controls__confirm" role="alertdialog">
<p>
Cancel <strong>{{ printer.currentJobName ?? "the running print" }}</strong
>? The print ends and cannot be resumed.
</p>
<button
type="button"
class="controls__danger"
:disabled="busy"
@click="run({ action: 'control_print', control: 'stop' }, 'Stop sent')"
>
Yes, cancel the print
</button>
<button type="button" :disabled="busy" @click="confirmingStop = false">Keep printing</button>
</div>
<form class="controls__row" @submit.prevent="fanValid && run({ action: 'set_fan_speed', percent: fanPercent }, `Fan set to ${fanPercent}%`)">
<label>
Fan
<input v-model.number="fanPercent" type="number" min="0" max="100" step="1" :disabled="busy" />
%
</label>
<button type="submit" :disabled="busy || !fanValid">Set fan</button>
</form>
<p v-if="notice" class="controls__notice" :class="`is-${notice.kind}`" :role="notice.kind === 'error' ? 'alert' : 'status'">
{{ notice.text }}
</p>
</section>
</template>
<style scoped>
.controls {
margin-top: 0.75rem;
display: grid;
gap: 0.5rem;
font-size: 0.85rem;
}
.controls__row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.controls input {
width: 4.5rem;
}
.controls__danger {
color: #f2604c;
}
.controls__confirm {
border: 1px solid #f2604c;
border-radius: 6px;
padding: 0.5rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.controls__confirm p {
flex-basis: 100%;
margin: 0;
}
.controls__notice {
margin: 0;
}
.controls__notice.is-error {
color: #f2604c;
}
.controls__notice.is-ok {
opacity: 0.7;
}
</style>
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
// Null or undefined means "no reading", which is not 0: a printer whose
// gateway has never reported has no temperature. Such a value is never
// pushed into the history (it would draw as a real cold sample) and its
// label shows a dash.
const props = defineProps<{
nozzleTempC: number;
bedTempC: number;
nozzleTempC?: number | null;
bedTempC?: number | null;
maxSamples?: number;
}>();
@@ -14,8 +18,8 @@ const bedHistory = ref<number[]>([]);
watch(
() => [props.nozzleTempC, props.bedTempC] as const,
([nozzle, bed]) => {
nozzleHistory.value = [...nozzleHistory.value, nozzle].slice(-MAX_SAMPLES);
bedHistory.value = [...bedHistory.value, bed].slice(-MAX_SAMPLES);
if (nozzle != null) nozzleHistory.value = [...nozzleHistory.value, nozzle].slice(-MAX_SAMPLES);
if (bed != null) bedHistory.value = [...bedHistory.value, bed].slice(-MAX_SAMPLES);
},
{ immediate: true },
);
@@ -45,8 +49,8 @@ onBeforeUnmount(() => {
<polyline class="temp-graph__bed" :points="bedPoints" fill="none" />
</svg>
<div class="temp-graph__labels">
<span>Nozzle {{ nozzleTempC.toFixed(0) }}°C</span>
<span>Bed {{ bedTempC.toFixed(0) }}°C</span>
<span>Nozzle {{ nozzleTempC == null ? "—" : `${nozzleTempC.toFixed(0)}°C` }}</span>
<span>Bed {{ bedTempC == null ? "—" : `${bedTempC.toFixed(0)}°C` }}</span>
</div>
</div>
</template>
+124 -12
View File
@@ -2,19 +2,74 @@
import { onBeforeUnmount, ref, watch } from "vue";
const props = defineProps<{
streamBaseUrl: string;
/** The go2rtc stream name to negotiate for, from `GET /printers/:id/stream`. */
cameraId: string;
/**
* ICE servers for this session, minted per request by the backend and
* short-lived. Empty means TURN is not configured.
*/
iceServers: RTCIceServer[];
/**
* Whether the backend has TURN configured at all. Empty `iceServers` with
* this true means minting failed, which is a fault; empty with this false
* is a deployment that has not set it up, which is merely a limit.
*/
turnConfigured: boolean;
/**
* Exchanges an offer SDP for an answer SDP. Supplied by the app, because
* this package knows nothing of the API or of Nuxt - which is what lets it
* render from a test or the desktop shell with no server behind it.
*/
signal: (offerSdp: string) => Promise<string>;
}>();
const videoEl = ref<HTMLVideoElement | null>(null);
const pc = ref<RTCPeerConnection | null>(null);
const status = ref<"connecting" | "connected" | "failed">("connecting");
const error = ref<string | null>(null);
/**
* Opens a WebRTC session with the printer's gateway, signalling through the
* backend.
*
* ## Why the backend is in the middle
*
* A browser cannot signal to a gateway's go2rtc directly, and that is
* measured against go2rtc 1.9.14 rather than assumed: it sends no
* `Access-Control-Allow-Origin` on any response, the SDP `POST` needs a
* preflight anyway because `application/sdp` is not a CORS-safelisted
* content type, and it checks its API password *before* CORS - so the
* preflight, which a browser always sends without credentials, gets a 401.
* Handing the gateway's password to the frontend could not fix that. A
* gateway is also behind a workshop NAT, so nothing can dial in to it.
*
* The backend relays only the SDP exchange, over the socket the gateway
* already holds open. Media never passes through it.
*
* ## Why relay-only when TURN is configured
*
* Pinned to `iceTransportPolicy: "relay"`, so media always travels through
* the TURN relay and never peer-to-peer. That is deliberate: a hosted
* console cannot rely on reaching a customer's gateway directly, and a
* feature that works from the workshop but not from home is worse than one
* that behaves the same everywhere. It is DTLS-SRTP encrypted end to end, so
* the relay forwards packets it cannot decrypt.
*
* With no TURN configured it falls back to `"all"`, which still works for a
* viewer on the gateway's own LAN via host candidates. Relay-only without a
* relay would gather no candidates at all and fail every time, so the
* fallback is what makes an unconfigured deployment degrade instead of
* break.
*/
async function connect() {
status.value = "connecting";
error.value = null;
pc.value?.close();
const connection = new RTCPeerConnection();
const connection = new RTCPeerConnection({
iceServers: props.iceServers,
iceTransportPolicy: props.iceServers.length > 0 ? "relay" : "all",
});
pc.value = connection;
connection.addTransceiver("video", { direction: "recvonly" });
@@ -23,24 +78,79 @@ async function connect() {
};
connection.onconnectionstatechange = () => {
if (connection.connectionState === "connected") status.value = "connected";
if (connection.connectionState === "failed") status.value = "failed";
if (connection.connectionState === "failed") {
status.value = "failed";
// ICE failure has one overwhelmingly likely cause when there is no
// relay: the viewer is not on the gateway's network, and host
// candidates were the only ones available. Worth saying, because the
// browser reports nothing more than "failed" and the fix is a
// deployment setting rather than anything the viewer can do.
error.value = props.turnConfigured
? "could not establish a media connection to the gateway"
: "no TURN relay is configured, so this camera can only be watched from the " +
"printers' own network";
}
};
const offer = await connection.createOffer();
await connection.setLocalDescription(offer);
// go2rtc's WebRTC WHEP-style signaling endpoint.
const response = await fetch(`${props.streamBaseUrl}/api/webrtc?src=${props.cameraId}`, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: offer.sdp,
});
// Waits for ICE gathering before signalling, rather than trickling.
// go2rtc's answer comes back through one request/response relay hop, so
// there is no channel to trickle candidates over afterwards - the offer
// has to carry them all.
await gathered(connection);
if (pc.value !== connection) return;
const answerSdp = await props.signal(connection.localDescription?.sdp ?? offer.sdp ?? "");
// The props can change again while the exchange above is in flight, which
// starts a second connect(). Without this check the slower of the two
// finishes last and applies its answer to a connection that has been
// replaced and closed, leaving a black video element and a "connected"
// badge.
if (pc.value !== connection) return;
const answerSdp = await response.text();
await connection.setRemoteDescription({ type: "answer", sdp: answerSdp });
}
watch(() => [props.streamBaseUrl, props.cameraId], connect, { immediate: true });
/**
* Resolves once ICE gathering finishes, or after a short grace period.
*
* Bounded because gathering can hang on a candidate that never resolves, and
* a player that waits forever is worse than one that signals with the
* candidates it already has - which, relay-only, is usually the one that
* matters.
*/
function gathered(connection: RTCPeerConnection): Promise<void> {
if (connection.iceGatheringState === "complete") return Promise.resolve();
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer);
connection.removeEventListener("icegatheringstatechange", onChange);
resolve();
};
const onChange = () => {
if (connection.iceGatheringState === "complete") done();
};
const timer = setTimeout(done, 3_000);
connection.addEventListener("icegatheringstatechange", onChange);
});
}
// `connect` is async inside a watcher, so nothing would otherwise catch a
// rejection: the status would sit on "connecting" forever and the only sign
// of the failure would be an unhandled rejection in the console.
watch(
() => [props.cameraId, props.iceServers],
() => {
connect().catch((err: unknown) => {
status.value = "failed";
error.value = err instanceof Error ? err.message : String(err);
});
},
{ immediate: true },
);
onBeforeUnmount(() => {
pc.value?.close();
@@ -51,7 +161,9 @@ onBeforeUnmount(() => {
<template>
<div class="webrtc-view">
<video ref="videoEl" autoplay playsinline muted />
<span class="webrtc-view__status" :class="`is-${status}`">{{ status }}</span>
<span class="webrtc-view__status" :class="`is-${status}`" :title="error ?? undefined">
{{ status }}
</span>
</div>
</template>
+5
View File
@@ -1,3 +1,8 @@
export { default as PrinterCard } from "./components/PrinterCard.vue";
export { default as PrinterControls } from "./components/PrinterControls.vue";
export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
export { default as JobList } from "./components/JobList.vue";
export { default as JobUpload } from "./components/JobUpload.vue";
export { default as FarmStatus } from "./components/FarmStatus.vue";
export type { AppDiscoveredCandidate, AppFarm, AppFarmDiscovery, AppJob, AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types";
+103
View File
@@ -0,0 +1,103 @@
/**
* A printer as the console displays it - the shape `GET /printers/:id`
* returns and the shape `/ws/telemetry` pushes, which are deliberately the
* same so a push can replace a fetched row outright.
*
* Hand-mirrored from `continuum-schemas`
* (`schemas/continuum/v1/app/telemetry_message.schema.json`, typed in
* `packages/ts-types/src/schemas/app.ts`). The schema is canonical: change
* it first and then this. It is not imported from `@continuum/ts-types`
* because that package lives in another repository and is not published
* anywhere this workspace can resolve it; a relative `link:` out of the repo
* would break on any checkout that is not laid out exactly like this
* machine's. Types only, so drift is a copy-paste away from fixed.
*
* Every field after `vendor` is optional AND nullable, and that is
* load-bearing: a claimed printer whose gateway has never reported has no
* temperature, and "no data yet" is not 0 degrees. Never render these with
* `?? 0`.
*/
export interface AppPrinterState {
id: string;
farmId: string;
name: string;
vendor: string;
/** The backend's own narrowed status, which the UI filters and groups on. */
status?: string | null;
/** The printer's own word (`RUNNING`, `PREPARE`...). Not an enum on purpose. */
vendorState?: string | null;
vendorErrorCode?: number | null;
nozzleTempC?: number | null;
nozzleTargetTempC?: number | null;
bedTempC?: number | null;
bedTargetTempC?: number | null;
progressPercent?: number | null;
currentLayer?: number | null;
totalLayers?: number | null;
remainingMinutes?: number | null;
currentJobName?: string | null;
/** A count of active HMS entries, not messages. */
activeAlerts?: number | null;
/** go2rtc stream name; null/absent means render no player. */
cameraStream?: string | null;
/** When a gateway last reported this printer; null means never. */
lastSeenAt?: string | null;
}
/** What `/ws/telemetry` sends, discriminated on `type`. */
export type AppTelemetryMessage =
| { type: "ready"; visiblePrinters: number }
| { type: "printer.telemetry"; printer: AppPrinterState };
/**
* What `POST /printers/:id/commands` accepts: the edge schema's
* `printer_command.schema.json`, minus `start_print` (a job's dispatch sends
* that, never a person) and `send_gcode_line` (arbitrary g-code is not
* offered to a button). Hand-mirrored like the rest of this file.
*/
export type PrinterCommand =
| { action: "control_print"; control: "pause" | "resume" | "stop" }
| { action: "set_fan_speed"; percent: number };
/** A farm as `GET /farms` returns it (the fields the console uses). */
export interface AppFarm {
id: string;
name: string;
}
/**
* A print job as `GET /jobs` returns it. `status` is the backend's
* `print_job_status` enum but typed open, like `AppPrinterState.status`: an
* unknown value renders in the default colour rather than breaking.
*/
export interface AppJob {
id: string;
farmId: string;
printerId?: string | null;
fileName: string;
status: string;
progressPct?: number | null;
createdAt: string;
}
/**
* `GET /farms/:id/discovered-printers`. `discovery` is null when the gateway
* has never reported a scan - almost always because discovery is off, which
* is its default - and that is not the same as a report with no printers.
*/
export interface AppDiscoveredCandidate {
address: string;
vendor: string;
name?: string | null;
model?: string | null;
serial?: string | null;
/** The printer row this candidate looks like, and how sure that is. */
existingPrinterId?: string | null;
matchedOn?: "serial" | "address" | null;
}
export interface AppFarmDiscovery {
farmId: string;
gatewayOnline: boolean;
discovery: { scannedAt: string; printers: AppDiscoveredCandidate[] } | null;
}