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.
This commit is contained in:
2026-10-18 19:54:28 +00:00
parent a558156968
commit ba2eb4d379
7 changed files with 282 additions and 96 deletions
+16 -2
View File
@@ -3,9 +3,17 @@ import { useTelemetryStore } from "~/stores/telemetry";
const telemetry = useTelemetryStore(); const telemetry = useTelemetryStore();
const user = useSupabaseUser(); 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(() => { onMounted(() => {
telemetry.connect(); watch(
() => session.value?.access_token,
(token) => (token ? telemetry.connect() : telemetry.disconnect()),
{ immediate: true },
);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -18,7 +26,13 @@ onBeforeUnmount(() => {
<header class="app-shell__header"> <header class="app-shell__header">
<span class="app-shell__brand">Continuum</span> <span class="app-shell__brand">Continuum</span>
<span class="app-shell__status" :class="telemetry.connected ? 'is-online' : 'is-offline'"> <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>
<span v-if="user" class="app-shell__user">{{ user.email }}</span> <span v-if="user" class="app-shell__user">{{ user.email }}</span>
</header> </header>
+26 -18
View File
@@ -1,28 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { PrinterCard } from "@continuum/ui"; import { PrinterCard, type AppPrinterState } from "@continuum/ui";
import { useTelemetryStore } from "~/stores/telemetry"; import { useTelemetryStore } from "~/stores/telemetry";
interface Printer {
id: string;
name: string;
model: string;
farmId: string;
/**
* Set only for a printer whose camera the gateway can actually restream,
* which in a mixed fleet is the minority. Its presence is what decides
* whether to ask for a stream URL at all - see the lookup below.
*/
cameraStream?: string | null;
}
const telemetry = useTelemetryStore(); const telemetry = useTelemetryStore();
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
const { data: printers } = await useAsyncData("printers", () => const { data: printers } = await useAsyncData("printers", () =>
apiFetch<Printer[]>("/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;
}
// Looked up per printer, because the answer is per printer, but only for the // 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 // 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. // mean one request per machine in the fleet, most of them 404 by design.
@@ -54,12 +57,17 @@ watch(
<PrinterCard <PrinterCard
v-for="printer in printers ?? []" v-for="printer in printers ?? []"
:key="printer.id" :key="printer.id"
:printer="printer" :printer="current(printer)"
:live="telemetry.printers[printer.id]"
:stream="streams[printer.id]" :stream="streams[printer.id]"
/> />
</div> </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> </div>
</template> </template>
+115 -44
View File
@@ -1,83 +1,154 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import type { AppPrinterState, AppTelemetryMessage } from "@continuum/ui";
export interface PrinterTelemetry { /**
printerId: string; * Live printer state from `continuum-backend`'s `/ws/telemetry`.
farmId: string; *
state: "idle" | "printing" | "paused" | "error" | "offline"; * The message shape is the backend's, mirrored in `@continuum/ui`'s types
nozzleTempC: number; * from `continuum-schemas`. This file used to declare its own
bedTempC: number; * (`printerId`, `progressPct`, a closed state enum, a `data` envelope) and
progressPct: number; * no backend ever sent it.
updatedAt: string; */
}
export type TelemetryLink = "disconnected" | "connecting" | "connected";
interface TelemetryState { interface TelemetryState {
socket: WebSocket | null; link: TelemetryLink;
connected: boolean; /**
printers: Record<string, PrinterTelemetry>; * 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", { export const useTelemetryStore = defineStore("telemetry", {
state: (): TelemetryState => ({ state: (): TelemetryState => ({
socket: null, link: "disconnected",
connected: false, visiblePrinters: null,
printers: {}, printers: {},
}), }),
getters: { getters: {
connected: (state) => state.link === "connected",
printerList: (state) => Object.values(state.printers), printerList: (state) => Object.values(state.printers),
}, },
actions: { actions: {
connect() { connect() {
if (this.socket) return; wanted = true;
if (socket) return;
const config = useRuntimeConfig(); // Read here, at every attempt, never captured by the store: supabase-js
const client = useSupabaseClient(); // 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 }) => { // No session means no connection, rather than an empty credential the
const token = data.session?.access_token ?? ""; // server can only reject. Deliberately no retry timer either: the
const url = `${config.public.wsBase}/ws/telemetry?token=${encodeURIComponent(token)}`; // caller connects when a session appears (app.vue watches for it), and
this.open(url); // polling for a sign-in would be a loop with nothing to wait on.
}); if (!token) {
}, this.link = "disconnected";
return;
}
open(url: string) { const { wsBase } = useRuntimeConfig().public;
const socket = new WebSocket(url);
socket.onopen = () => { // The token travels in the WebSocket subprotocol list, not the URL: a
this.connected = true; // 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 { try {
const payload = JSON.parse(event.data) as { type: string; data: PrinterTelemetry }; message = JSON.parse(event.data) as AppTelemetryMessage;
if (payload.type === "printer.telemetry") {
this.printers[payload.data.printerId] = payload.data;
}
} catch { } catch {
// Ignore malformed frames rather than crashing the UI. // 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 = () => { ws.onclose = () => {
this.connected = false; // A socket we already replaced or abandoned must not clobber the
this.socket = null; // state of its successor.
setTimeout(() => this.connect(), RECONNECT_DELAY_MS); 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 = () => { ws.onerror = () => {
socket.close(); ws.close();
}; };
this.socket = socket;
}, },
disconnect() { disconnect() {
this.socket?.close(); wanted = false;
this.socket = null; clearTimeout(retryTimer);
this.connected = false; // 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;
}, },
}, },
}); });
+64 -26
View File
@@ -1,27 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import type { AppPrinterState } from "../types";
import TemperatureGraph from "./TemperatureGraph.vue"; import TemperatureGraph from "./TemperatureGraph.vue";
import WebRTCStreamView from "./WebRTCStreamView.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 * Where this printer's camera can be watched, from
* `GET /printers/:id/stream`. * `GET /printers/:id/stream`.
* *
* Passed in rather than fetched here, matching `live` above: this package is * 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 * 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 * in the app is what lets these components be rendered from a test or the
* desktop shell without a server behind them. * desktop shell without a server behind them.
@@ -35,22 +22,55 @@ interface PrinterStream {
} }
const props = defineProps<{ 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; stream?: PrinterStream | null;
}>(); }>();
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> </script>
<template> <template>
<article class="printer-card" :class="`is-${state}`"> <article class="printer-card" :class="statusClass">
<header> <header>
<h3>{{ printer.name }}</h3> <h3>{{ printer.name }}</h3>
<span class="printer-card__model">{{ printer.model }}</span> <span class="printer-card__model">{{ printer.vendor }}</span>
</header> </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>
<WebRTCStreamView <WebRTCStreamView
v-if="stream" v-if="stream"
@@ -59,12 +79,13 @@ const state = computed(() => props.live?.state ?? "offline");
/> />
<TemperatureGraph <TemperatureGraph
v-if="live" v-if="hasTemperature"
:nozzle-temp-c="live.nozzleTempC" :nozzle-temp-c="printer.nozzleTempC"
:bed-temp-c="live.bedTempC" :bed-temp-c="printer.bedTempC"
/> />
<progress v-if="live" :value="live.progressPct" max="100" /> <!-- Omitted when null: an empty bar reads as 0% complete. -->
<progress v-if="printer.progressPercent != null" :value="printer.progressPercent" max="100" />
</article> </article>
</template> </template>
@@ -81,6 +102,14 @@ const state = computed(() => props.live?.state ?? "offline");
font-size: 0.8rem; 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 { .printer-card.is-printing .printer-card__state {
color: #35d07f; color: #35d07f;
} }
@@ -89,7 +118,16 @@ const state = computed(() => props.live?.state ?? "offline");
color: #f2604c; 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; color: #6b7280;
} }
@@ -1,9 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue"; 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<{ const props = defineProps<{
nozzleTempC: number; nozzleTempC?: number | null;
bedTempC: number; bedTempC?: number | null;
maxSamples?: number; maxSamples?: number;
}>(); }>();
@@ -14,8 +18,8 @@ const bedHistory = ref<number[]>([]);
watch( watch(
() => [props.nozzleTempC, props.bedTempC] as const, () => [props.nozzleTempC, props.bedTempC] as const,
([nozzle, bed]) => { ([nozzle, bed]) => {
nozzleHistory.value = [...nozzleHistory.value, nozzle].slice(-MAX_SAMPLES); if (nozzle != null) nozzleHistory.value = [...nozzleHistory.value, nozzle].slice(-MAX_SAMPLES);
bedHistory.value = [...bedHistory.value, bed].slice(-MAX_SAMPLES); if (bed != null) bedHistory.value = [...bedHistory.value, bed].slice(-MAX_SAMPLES);
}, },
{ immediate: true }, { immediate: true },
); );
@@ -45,8 +49,8 @@ onBeforeUnmount(() => {
<polyline class="temp-graph__bed" :points="bedPoints" fill="none" /> <polyline class="temp-graph__bed" :points="bedPoints" fill="none" />
</svg> </svg>
<div class="temp-graph__labels"> <div class="temp-graph__labels">
<span>Nozzle {{ nozzleTempC.toFixed(0) }}°C</span> <span>Nozzle {{ nozzleTempC == null ? "—" : `${nozzleTempC.toFixed(0)}°C` }}</span>
<span>Bed {{ bedTempC.toFixed(0) }}°C</span> <span>Bed {{ bedTempC == null ? "—" : `${bedTempC.toFixed(0)}°C` }}</span>
</div> </div>
</div> </div>
</template> </template>
+1
View File
@@ -1,3 +1,4 @@
export { default as PrinterCard } from "./components/PrinterCard.vue"; export { default as PrinterCard } from "./components/PrinterCard.vue";
export { default as TemperatureGraph } from "./components/TemperatureGraph.vue"; export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue"; export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
export type { AppPrinterState, AppTelemetryMessage } from "./types";
+50
View File
@@ -0,0 +1,50 @@
/**
* 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 };