Files
continuum-app/packages/ui/src/components/JobUpload.vue
T
octoturge c7f85870db 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.
2026-10-29 14:39:37 +00:00

99 lines
2.8 KiB
Vue

<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>