Files
continuum-app/apps/web/pages/index.vue
T
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

146 lines
5.3 KiB
Vue

<script setup lang="ts">
import {
FarmStatus,
PrinterCard,
type AppFarm,
type AppFarmDiscovery,
type AppPrinterState,
type PrinterCommand,
} from "@continuum/ui";
import { useTelemetryStore } from "~/stores/telemetry";
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;
}
// 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>>({});
/**
* 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="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">
<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>
<style scoped>
.fleet__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
</style>