The console could watch and control printers but not give them work. The Jobs page uploads a .3mf/.gcode: it creates the job (POST /jobs), PUTs the file straight to the presigned storage URL with a progress bar, and lists the farm's jobs, polling every 5s since there is no job push channel yet. The scheduler then starts it on the next idle printer, so there is no Start button - a queued job IS the request. Deliberate details: the PUT carries no Authorization header (the URL is presigned and an uncovered header makes storage reject it) and the same Content-Type the URL was signed for; a failed upload deletes the job row it just created, so no queued job waits for a file that never arrives; only queued/uploading jobs offer Delete, matching what the backend allows. JobUpload and JobList live in @continuum/ui and take the API calls as props, like the printer card. A shared apiErrorMessage() replaces the inline copy on the fleet page. Checked in real Chrome against the live stack with job dispatch OFF: upload to the real bucket (57 bytes landed intact), listed as queued, still queued after a scheduler tick, Delete clears it, no page errors; the test object was removed from the bucket afterwards. pnpm -r typecheck is clean.
115 lines
4.2 KiB
Vue
115 lines
4.2 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) {
|
|
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>
|
|
<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>
|