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.
This commit is contained in:
@@ -25,6 +25,10 @@ onBeforeUnmount(() => {
|
|||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="app-shell__header">
|
<header class="app-shell__header">
|
||||||
<span class="app-shell__brand">Continuum</span>
|
<span class="app-shell__brand">Continuum</span>
|
||||||
|
<nav class="app-shell__nav">
|
||||||
|
<NuxtLink to="/">Fleet</NuxtLink>
|
||||||
|
<NuxtLink to="/jobs">Jobs</NuxtLink>
|
||||||
|
</nav>
|
||||||
<span class="app-shell__status" :class="telemetry.connected ? 'is-online' : 'is-offline'">
|
<span class="app-shell__status" :class="telemetry.connected ? 'is-online' : 'is-offline'">
|
||||||
{{
|
{{
|
||||||
telemetry.link === "connected"
|
telemetry.link === "connected"
|
||||||
@@ -84,4 +88,20 @@ body {
|
|||||||
.app-shell__main {
|
.app-shell__main {
|
||||||
padding: 1.25rem;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ async function sendCommand(printerId: string, command: PrinterCommand): Promise<
|
|||||||
try {
|
try {
|
||||||
await apiFetch(`/printers/${printerId}/commands`, { method: "POST", body: command });
|
await apiFetch(`/printers/${printerId}/commands`, { method: "POST", body: command });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const body = (err as { data?: { error?: { message?: string } } }).data;
|
throw new Error(apiErrorMessage(err, "the command could not be sent"));
|
||||||
throw new Error(body?.error?.message ?? "the command could not be sent");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { JobList, JobUpload, type AppFarm, type AppJob, type AppPrinterState } from "@continuum/ui";
|
||||||
|
|
||||||
|
const apiFetch = useApiFetch();
|
||||||
|
|
||||||
|
const { data: farms } = await useAsyncData("farms", () => apiFetch<AppFarm[]>("/farms"));
|
||||||
|
const { data: printers } = await useAsyncData("job-printers", () => apiFetch<AppPrinterState[]>("/printers"));
|
||||||
|
const { data: jobs, refresh } = await useAsyncData("jobs", () => apiFetch<AppJob[]>("/jobs"));
|
||||||
|
|
||||||
|
const printerNames = computed(() =>
|
||||||
|
Object.fromEntries((printers.value ?? []).map((printer) => [printer.id, printer.name])),
|
||||||
|
);
|
||||||
|
|
||||||
|
// There is no job push channel yet (the telemetry socket carries printers
|
||||||
|
// only), so a job moving from queued to printing shows up by polling. Five
|
||||||
|
// seconds matches the scheduler's own tick, which is the fastest it can move.
|
||||||
|
let poll: ReturnType<typeof setInterval> | undefined;
|
||||||
|
onMounted(() => {
|
||||||
|
poll = setInterval(() => void refresh(), 5_000);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => clearInterval(poll));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT with progress. `XMLHttpRequest` because `fetch` cannot report upload
|
||||||
|
* progress, and a sliced project is tens of megabytes. Plain request with no
|
||||||
|
* Authorization header on purpose: the URL is presigned, and an extra header
|
||||||
|
* the signature did not cover makes the storage server reject it.
|
||||||
|
*
|
||||||
|
* The Content-Type must be the one the URL was signed for.
|
||||||
|
*/
|
||||||
|
function putFile(url: string, file: File, contentType: string, onProgress: (percent: number) => void) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const request = new XMLHttpRequest();
|
||||||
|
request.open("PUT", url);
|
||||||
|
request.setRequestHeader("Content-Type", contentType);
|
||||||
|
request.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable) onProgress((event.loaded / event.total) * 100);
|
||||||
|
};
|
||||||
|
request.onload = () =>
|
||||||
|
request.status >= 200 && request.status < 300
|
||||||
|
? resolve()
|
||||||
|
: reject(new Error(`storage refused the upload (HTTP ${request.status})`));
|
||||||
|
request.onerror = () => reject(new Error("could not reach storage - blocked, or offline"));
|
||||||
|
request.send(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadJob(farmId: string, file: File, onProgress: (percent: number) => void) {
|
||||||
|
const contentType = file.type || "application/octet-stream";
|
||||||
|
let created: { job: AppJob; uploadUrl: string };
|
||||||
|
try {
|
||||||
|
created = await apiFetch("/jobs", { method: "POST", body: { farmId, fileName: file.name, contentType } });
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(apiErrorMessage(err, "the job could not be created"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await putFile(created.uploadUrl, file, contentType, onProgress);
|
||||||
|
} catch (err) {
|
||||||
|
// The row exists before the file does, so a failed upload would leave a
|
||||||
|
// queued job whose file never arrives. Removed rather than left to sit.
|
||||||
|
await apiFetch(`/jobs/${created.job.id}`, { method: "DELETE" }).catch(() => undefined);
|
||||||
|
await refresh();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeJob(jobId: string) {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/jobs/${jobId}`, { method: "DELETE" });
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(apiErrorMessage(err, "the job could not be deleted"));
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="jobs">
|
||||||
|
<h1>Jobs</h1>
|
||||||
|
<JobUpload :farms="farms ?? []" :upload="uploadJob" />
|
||||||
|
<JobList :jobs="jobs ?? []" :printer-names="printerNames" :remove="removeJob" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jobs {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import type { AppJob } from "../types";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
jobs: AppJob[];
|
||||||
|
/** printer id -> display name, for the jobs already placed on one. */
|
||||||
|
printerNames: Record<string, string>;
|
||||||
|
/** Deletes a job that has not started; rejects with a message fit to show. */
|
||||||
|
remove: (jobId: string) => Promise<void>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const removing = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Only these can be deleted: the backend refuses anything that has reached a
|
||||||
|
// printer, because stopping a running print is a command, not a row delete.
|
||||||
|
const deletable = (job: AppJob) => job.status === "queued" || job.status === "uploading";
|
||||||
|
|
||||||
|
async function del(job: AppJob) {
|
||||||
|
removing.value = job.id;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await props.remove(job.id);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
removing.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass = (status: string) => `is-${status.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||||
|
const when = (iso: string) => new Date(iso).toLocaleString();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="job-list">
|
||||||
|
<p v-if="error" class="job-list__error" role="alert">{{ error }}</p>
|
||||||
|
<table v-if="jobs.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Printer</th>
|
||||||
|
<th>Queued</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="job in jobs" :key="job.id">
|
||||||
|
<td>{{ job.fileName }}</td>
|
||||||
|
<td :class="['job-list__status', statusClass(job.status)]">
|
||||||
|
{{ job.status }}
|
||||||
|
<template v-if="job.status === 'printing' && job.progressPct != null"> ({{ Math.round(job.progressPct) }}%)</template>
|
||||||
|
</td>
|
||||||
|
<td>{{ job.printerId ? (printerNames[job.printerId] ?? "unknown printer") : "-" }}</td>
|
||||||
|
<td>{{ when(job.createdAt) }}</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="deletable(job)" type="button" :disabled="removing === job.id" @click="del(job)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else>No jobs yet.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-bottom: 1px solid #1f232c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-list__error {
|
||||||
|
color: #f2604c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-list__status.is-printing,
|
||||||
|
.job-list__status.is-completed {
|
||||||
|
color: #35d07f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-list__status.is-paused {
|
||||||
|
color: #f0b429;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-list__status.is-failed {
|
||||||
|
color: #f2604c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-list__status.is-cancelled {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import type { AppFarm } from "../types";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
farms: AppFarm[];
|
||||||
|
/**
|
||||||
|
* Creates the job, uploads the file and resolves when both are done, or
|
||||||
|
* rejects with a message fit to show. `onProgress` is 0-100 for the upload.
|
||||||
|
* Supplied by the app, like `send` on the printer card: this package knows
|
||||||
|
* nothing of the API.
|
||||||
|
*/
|
||||||
|
upload: (farmId: string, file: File, onProgress: (percent: number) => void) => Promise<void>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const farmId = ref<string>("");
|
||||||
|
const file = ref<File | null>(null);
|
||||||
|
const busy = ref(false);
|
||||||
|
const percent = ref(0);
|
||||||
|
const notice = ref<{ kind: "ok" | "error"; text: string } | null>(null);
|
||||||
|
const input = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// One farm is the common case; picking it for the user is the right default,
|
||||||
|
// and a select with a single option is noise.
|
||||||
|
const chosenFarm = computed(() => farmId.value || props.farms[0]?.id || "");
|
||||||
|
|
||||||
|
function onPick(event: Event) {
|
||||||
|
file.value = (event.target as HTMLInputElement).files?.[0] ?? null;
|
||||||
|
notice.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!file.value || !chosenFarm.value) return;
|
||||||
|
const sending = file.value;
|
||||||
|
busy.value = true;
|
||||||
|
percent.value = 0;
|
||||||
|
notice.value = null;
|
||||||
|
try {
|
||||||
|
await props.upload(chosenFarm.value, sending, (p) => (percent.value = p));
|
||||||
|
notice.value = {
|
||||||
|
kind: "ok",
|
||||||
|
text: `${sending.name} is queued. It starts on the next idle printer.`,
|
||||||
|
};
|
||||||
|
file.value = null;
|
||||||
|
if (input.value) input.value.value = "";
|
||||||
|
} catch (err) {
|
||||||
|
notice.value = { kind: "error", text: err instanceof Error ? err.message : String(err) };
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="job-upload" @submit.prevent="submit">
|
||||||
|
<label v-if="farms.length > 1">
|
||||||
|
Farm
|
||||||
|
<select v-model="farmId" :disabled="busy">
|
||||||
|
<option v-for="farm in farms" :key="farm.id" :value="farm.id">{{ farm.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref="input"
|
||||||
|
type="file"
|
||||||
|
accept=".3mf,.gcode,.gcode.3mf"
|
||||||
|
:disabled="busy"
|
||||||
|
@change="onPick"
|
||||||
|
/>
|
||||||
|
<button type="submit" :disabled="busy || !file || !chosenFarm">Upload and queue</button>
|
||||||
|
<progress v-if="busy" :value="percent" max="100" />
|
||||||
|
<p v-if="notice" class="job-upload__notice" :class="`is-${notice.kind}`" :role="notice.kind === 'error' ? 'alert' : 'status'">
|
||||||
|
{{ notice.text }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!farms.length" class="job-upload__notice">No farm to queue jobs on yet.</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.job-upload {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-upload progress {
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-upload__notice {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-upload__notice.is-error {
|
||||||
|
color: #f2604c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,4 +2,6 @@ export { default as PrinterCard } from "./components/PrinterCard.vue";
|
|||||||
export { default as PrinterControls } from "./components/PrinterControls.vue";
|
export { default as PrinterControls } from "./components/PrinterControls.vue";
|
||||||
export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
|
export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
|
||||||
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.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";
|
||||||
|
|||||||
@@ -58,3 +58,24 @@ export type AppTelemetryMessage =
|
|||||||
export type PrinterCommand =
|
export type PrinterCommand =
|
||||||
| { action: "control_print"; control: "pause" | "resume" | "stop" }
|
| { action: "control_print"; control: "pause" | "resume" | "stop" }
|
||||||
| { action: "set_fan_speed"; percent: number };
|
| { 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user