Files
continuum-app/apps/web/pages/index.vue
T
octoturge 8bf56d1325 Add printer controls to the fleet cards: pause, resume, stop, fan
The console could only watch. A printer card now offers what
POST /printers/:id/commands accepts and a person should press: pause
while printing, resume while paused, and a fan percentage.

Stop is the one command with no undo (the printer reports FAILED), so it
sits apart from Pause and needs a second, named confirmation
("Cancel <job>? ... cannot be resumed"). Offline and never-reported
printers get no controls, since every command would be refused as
unreachable. A successful reply only means the printer accepted the
command, so the notice says to watch the status line rather than claiming
the state changed. The backend's own error message is shown for a 403, 409
or 502.

PrinterControls takes a `send` function like the card takes `signal`, so
@continuum/ui still knows nothing of the API; no function, no controls.
send_gcode_line and start_print are deliberately not offered here.

Verified in real Chrome against the live stack with the command requests
intercepted in the browser (the H2C was mid-print): controls render per
state, Stop shows its confirmation and Keep printing sends nothing, the
body is {action:"set_fan_speed",percent:100}, a 409 surfaces its message,
no page errors. pnpm -r typecheck is clean.
2026-10-29 10:21:34 +00:00

116 lines
4.3 KiB
Vue

<script setup lang="ts">
import { PrinterCard, 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;
}
/**
* 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) {
const body = (err as { data?: { error?: { message?: string } } }).data;
throw new Error(body?.error?.message ?? "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>
<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>