SnapshotView fetches one frame at a time through the backend, pauses while the tab is hidden and backs off after failures. PrinterCard picks it when /stream answers kind "snapshot".
179 lines
6.5 KiB
Vue
179 lines
6.5 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
AlertList,
|
|
FarmStatus,
|
|
type AppAlert,
|
|
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;
|
|
}
|
|
|
|
/** One camera frame (P1/A1) as an image, through the backend. */
|
|
function fetchFrame(printerId: string): Promise<Blob> {
|
|
return apiFetch<Blob>(`/printers/${printerId}/snapshot`, { responseType: "blob" });
|
|
}
|
|
|
|
// 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.
|
|
}
|
|
}
|
|
}
|
|
|
|
// What still needs a person. Polled on the same 5s beat as jobs: an alert is
|
|
// useful for minutes, not milliseconds, and a push channel for it would be a
|
|
// second socket to keep honest.
|
|
const { data: alerts, refresh: refreshAlerts } = await useAsyncData("alerts", () =>
|
|
apiFetch<AppAlert[]>("/alerts", { query: { open: true } }),
|
|
);
|
|
|
|
async function acknowledge(id: string): Promise<void> {
|
|
try {
|
|
await apiFetch(`/alerts/${id}/acknowledge`, { method: "POST" });
|
|
} catch (err) {
|
|
throw new Error(apiErrorMessage(err, "could not dismiss the alert"));
|
|
}
|
|
await refreshAlerts();
|
|
}
|
|
|
|
let farmPoll: ReturnType<typeof setInterval> | undefined;
|
|
let alertPoll: ReturnType<typeof setInterval> | undefined;
|
|
onMounted(() => {
|
|
void refreshFarmStatus();
|
|
farmPoll = setInterval(() => void refreshFarmStatus(), 10_000);
|
|
alertPoll = setInterval(() => void refreshAlerts(), 5_000);
|
|
});
|
|
onBeforeUnmount(() => {
|
|
clearInterval(farmPoll);
|
|
clearInterval(alertPoll);
|
|
});
|
|
|
|
/**
|
|
* 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.cameraSnapshot) || 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>
|
|
<p v-if="farms && !farms.length">
|
|
You have no farm yet. <NuxtLink to="/setup">Set one up</NuxtLink> to connect your printers.
|
|
</p>
|
|
<FarmStatus v-for="farm in farms ?? []" :key="farm.id" :farm="farm" :status="farmStatus[farm.id] ?? null" />
|
|
<AlertList :alerts="alerts ?? []" :acknowledge="acknowledge" />
|
|
<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)"
|
|
:fetch-frame="() => fetchFrame(printer.id)"
|
|
: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>
|