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.
88 lines
1.8 KiB
Vue
88 lines
1.8 KiB
Vue
<script setup lang="ts">
|
|
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(() => {
|
|
watch(
|
|
() => session.value?.access_token,
|
|
(token) => (token ? telemetry.connect() : telemetry.disconnect()),
|
|
{ immediate: true },
|
|
);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
telemetry.disconnect();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="app-shell">
|
|
<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.link === "connected"
|
|
? "Live"
|
|
: telemetry.link === "connecting"
|
|
? "Connecting…"
|
|
: "Offline"
|
|
}}
|
|
</span>
|
|
<span v-if="user" class="app-shell__user">{{ user.email }}</span>
|
|
</header>
|
|
<main class="app-shell__main">
|
|
<NuxtPage />
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
background: #0b0d12;
|
|
color: #e6e8ec;
|
|
}
|
|
|
|
.app-shell__header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
padding: 0.75rem 1.25rem;
|
|
border-bottom: 1px solid #1f232c;
|
|
}
|
|
|
|
.app-shell__brand {
|
|
font-weight: 700;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.app-shell__status.is-online {
|
|
color: #35d07f;
|
|
}
|
|
|
|
.app-shell__status.is-offline {
|
|
color: #f2a33c;
|
|
}
|
|
|
|
.app-shell__user {
|
|
margin-left: auto;
|
|
opacity: 0.7;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.app-shell__main {
|
|
padding: 1.25rem;
|
|
}
|
|
</style>
|