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:
@@ -1,27 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { AppPrinterState } from "../types";
|
||||
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 `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
|
||||
* in the app is what lets these components be rendered from a test or the
|
||||
* desktop shell without a server behind them.
|
||||
@@ -35,22 +22,55 @@ interface PrinterStream {
|
||||
}
|
||||
|
||||
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;
|
||||
}>();
|
||||
|
||||
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>
|
||||
|
||||
<WebRTCStreamView
|
||||
v-if="stream"
|
||||
@@ -59,12 +79,13 @@ const state = computed(() => props.live?.state ?? "offline");
|
||||
/>
|
||||
|
||||
<TemperatureGraph
|
||||
v-if="live"
|
||||
:nozzle-temp-c="live.nozzleTempC"
|
||||
:bed-temp-c="live.bedTempC"
|
||||
v-if="hasTemperature"
|
||||
:nozzle-temp-c="printer.nozzleTempC"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
@@ -81,6 +102,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;
|
||||
}
|
||||
@@ -89,7 +118,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as PrinterCard } from "./components/PrinterCard.vue";
|
||||
export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
|
||||
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
|
||||
export type { AppPrinterState, AppTelemetryMessage } from "./types";
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user