From c7f85870db42e7ffe523989e504419707dcf37c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Thu, 29 Oct 2026 14:39:27 +0000 Subject: [PATCH] Add a Jobs page: upload a sliced file, watch it queue, delete it 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. --- apps/web/app.vue | 20 +++++ apps/web/composables/useApi.ts | 10 +++ apps/web/pages/index.vue | 3 +- apps/web/pages/jobs.vue | 91 ++++++++++++++++++++ packages/ui/src/components/JobList.vue | 103 +++++++++++++++++++++++ packages/ui/src/components/JobUpload.vue | 98 +++++++++++++++++++++ packages/ui/src/index.ts | 4 +- packages/ui/src/types.ts | 21 +++++ 8 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 apps/web/pages/jobs.vue create mode 100644 packages/ui/src/components/JobList.vue create mode 100644 packages/ui/src/components/JobUpload.vue diff --git a/apps/web/app.vue b/apps/web/app.vue index d9673c2..1ddf06a 100644 --- a/apps/web/app.vue +++ b/apps/web/app.vue @@ -25,6 +25,10 @@ onBeforeUnmount(() => {
Continuum + {{ telemetry.link === "connected" @@ -84,4 +88,20 @@ body { .app-shell__main { padding: 1.25rem; } + +.app-shell__nav { + display: flex; + gap: 0.75rem; +} + +.app-shell__nav a { + color: inherit; + opacity: 0.7; + text-decoration: none; +} + +.app-shell__nav a.router-link-exact-active { + opacity: 1; + text-decoration: underline; +} diff --git a/apps/web/composables/useApi.ts b/apps/web/composables/useApi.ts index ff2a476..a416504 100644 --- a/apps/web/composables/useApi.ts +++ b/apps/web/composables/useApi.ts @@ -41,3 +41,13 @@ export function useApiFetch() { }); }; } + +/** + * A sentence for a failed API call. The backend's `{ error: { message } }` + * body is what explains a 403 (a viewer cannot do this), a 409 or a 502, so + * it is surfaced instead of a bare "fetch failed". + */ +export function apiErrorMessage(err: unknown, fallback: string): string { + const body = (err as { data?: { error?: { message?: string } } }).data; + return body?.error?.message ?? fallback; +} diff --git a/apps/web/pages/index.vue b/apps/web/pages/index.vue index 6395f30..fe3507c 100644 --- a/apps/web/pages/index.vue +++ b/apps/web/pages/index.vue @@ -58,8 +58,7 @@ async function sendCommand(printerId: string, command: PrinterCommand): Promise< 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"); + throw new Error(apiErrorMessage(err, "the command could not be sent")); } } diff --git a/apps/web/pages/jobs.vue b/apps/web/pages/jobs.vue new file mode 100644 index 0000000..afc9592 --- /dev/null +++ b/apps/web/pages/jobs.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/packages/ui/src/components/JobList.vue b/packages/ui/src/components/JobList.vue new file mode 100644 index 0000000..bd2fbf8 --- /dev/null +++ b/packages/ui/src/components/JobList.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/packages/ui/src/components/JobUpload.vue b/packages/ui/src/components/JobUpload.vue new file mode 100644 index 0000000..3f3c18c --- /dev/null +++ b/packages/ui/src/components/JobUpload.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 0ab3fab..2278115 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,4 +2,6 @@ export { default as PrinterCard } from "./components/PrinterCard.vue"; export { default as PrinterControls } from "./components/PrinterControls.vue"; export { default as TemperatureGraph } from "./components/TemperatureGraph.vue"; export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue"; -export type { AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types"; +export { default as JobList } from "./components/JobList.vue"; +export { default as JobUpload } from "./components/JobUpload.vue"; +export type { AppFarm, AppJob, AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types"; diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index c0a33fe..7257fb3 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -58,3 +58,24 @@ export type AppTelemetryMessage = export type PrinterCommand = | { action: "control_print"; control: "pause" | "resume" | "stop" } | { action: "set_fan_speed"; percent: number }; + +/** A farm as `GET /farms` returns it (the fields the console uses). */ +export interface AppFarm { + id: string; + name: string; +} + +/** + * A print job as `GET /jobs` returns it. `status` is the backend's + * `print_job_status` enum but typed open, like `AppPrinterState.status`: an + * unknown value renders in the default colour rather than breaking. + */ +export interface AppJob { + id: string; + farmId: string; + printerId?: string | null; + fileName: string; + status: string; + progressPct?: number | null; + createdAt: string; +}