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:
+16
-2
@@ -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(() => {
|
||||
@@ -18,7 +26,13 @@ onBeforeUnmount(() => {
|
||||
<header class="app-shell__header">
|
||||
<span class="app-shell__brand">Continuum</span>
|
||||
<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>
|
||||
|
||||
+26
-18
@@ -1,28 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { PrinterCard } from "@continuum/ui";
|
||||
import { PrinterCard, type AppPrinterState } from "@continuum/ui";
|
||||
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 apiFetch = useApiFetch();
|
||||
|
||||
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
|
||||
// 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.
|
||||
@@ -54,12 +57,17 @@ watch(
|
||||
<PrinterCard
|
||||
v-for="printer in printers ?? []"
|
||||
:key="printer.id"
|
||||
:printer="printer"
|
||||
:live="telemetry.printers[printer.id]"
|
||||
:printer="current(printer)"
|
||||
:stream="streams[printer.id]"
|
||||
/>
|
||||
</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>
|
||||
|
||||
|
||||
+115
-44
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user