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.
This commit is contained in:
2026-10-29 10:21:34 +00:00
parent 4519c3ba50
commit 8bf56d1325
5 changed files with 197 additions and 3 deletions
+18 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { PrinterCard, type AppPrinterState } from "@continuum/ui";
import { PrinterCard, type AppPrinterState, type PrinterCommand } from "@continuum/ui";
import { useTelemetryStore } from "~/stores/telemetry";
const telemetry = useTelemetryStore();
@@ -47,6 +47,22 @@ async function signalOffer(printerId: string, sdp: string): Promise<string> {
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) => {
@@ -76,6 +92,7 @@ watch(
: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">
+9 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import type { AppPrinterState } from "../types";
import type { AppPrinterState, PrinterCommand } from "../types";
import PrinterControls from "./PrinterControls.vue";
import TemperatureGraph from "./TemperatureGraph.vue";
import WebRTCStreamView from "./WebRTCStreamView.vue";
@@ -43,6 +44,11 @@ const props = defineProps<{
* No function means no player, rather than a player that cannot signal.
*/
signal?: (offerSdp: string) => Promise<string>;
/**
* Delivers a command to this printer. No function means no controls, so a
* read-only embedding of the card cannot offer buttons that do nothing.
*/
send?: (command: PrinterCommand) => Promise<void>;
}>();
// `status` is open-ended and null for a printer that has never reported.
@@ -98,6 +104,8 @@ const lastSeen = computed(() => {
<!-- Omitted when null: an empty bar reads as 0% complete. -->
<progress v-if="printer.progressPercent != null" :value="printer.progressPercent" max="100" />
<PrinterControls v-if="send" :printer="printer" :send="send" />
</article>
</template>
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { AppPrinterState, PrinterCommand } from "../types";
const props = defineProps<{
printer: AppPrinterState;
/**
* Delivers one command and resolves once the gateway has answered, or
* rejects with a message fit to show. Supplied by the app, like `signal`
* on the card: this package knows nothing of the API.
*/
send: (command: PrinterCommand) => Promise<void>;
}>();
// Offline and never-reported printers get no controls at all: a command
// would be refused with "unreachable" and the buttons would only suggest
// something could be done.
const reachable = computed(() => !["offline", "unknown"].includes(props.printer.status ?? "unknown"));
const printing = computed(() => props.printer.status === "printing");
const paused = computed(() => props.printer.status === "paused");
const busy = ref(false);
// `stop` ends the print for good and there is no undo, so it takes two
// deliberate clicks and is kept away from Pause in the layout.
const confirmingStop = ref(false);
const fanPercent = ref(100);
const notice = ref<{ kind: "ok" | "error"; text: string } | null>(null);
async function run(command: PrinterCommand, done: string) {
busy.value = true;
notice.value = null;
try {
await props.send(command);
// "Accepted" is all a successful reply means: a printer answers success
// to pause/resume/stop even when idle. The card's own status line,
// driven by telemetry, is what shows whether anything changed.
notice.value = { kind: "ok", text: `${done} - watch the status above for the result.` };
} catch (err) {
notice.value = { kind: "error", text: err instanceof Error ? err.message : String(err) };
} finally {
busy.value = false;
confirmingStop.value = false;
}
}
const fanValid = computed(
() => Number.isInteger(fanPercent.value) && fanPercent.value >= 0 && fanPercent.value <= 100,
);
</script>
<template>
<section v-if="reachable" class="controls" aria-label="Printer controls">
<div class="controls__row">
<button
v-if="printing"
type="button"
:disabled="busy"
@click="run({ action: 'control_print', control: 'pause' }, 'Pause sent')"
>
Pause
</button>
<button
v-if="paused"
type="button"
:disabled="busy"
@click="run({ action: 'control_print', control: 'resume' }, 'Resume sent')"
>
Resume
</button>
<template v-if="(printing || paused) && !confirmingStop">
<button type="button" class="controls__danger" :disabled="busy" @click="confirmingStop = true">
Stop print…
</button>
</template>
</div>
<div v-if="confirmingStop" class="controls__confirm" role="alertdialog">
<p>
Cancel <strong>{{ printer.currentJobName ?? "the running print" }}</strong
>? The print ends and cannot be resumed.
</p>
<button
type="button"
class="controls__danger"
:disabled="busy"
@click="run({ action: 'control_print', control: 'stop' }, 'Stop sent')"
>
Yes, cancel the print
</button>
<button type="button" :disabled="busy" @click="confirmingStop = false">Keep printing</button>
</div>
<form class="controls__row" @submit.prevent="fanValid && run({ action: 'set_fan_speed', percent: fanPercent }, `Fan set to ${fanPercent}%`)">
<label>
Fan
<input v-model.number="fanPercent" type="number" min="0" max="100" step="1" :disabled="busy" />
%
</label>
<button type="submit" :disabled="busy || !fanValid">Set fan</button>
</form>
<p v-if="notice" class="controls__notice" :class="`is-${notice.kind}`" :role="notice.kind === 'error' ? 'alert' : 'status'">
{{ notice.text }}
</p>
</section>
</template>
<style scoped>
.controls {
margin-top: 0.75rem;
display: grid;
gap: 0.5rem;
font-size: 0.85rem;
}
.controls__row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.controls input {
width: 4.5rem;
}
.controls__danger {
color: #f2604c;
}
.controls__confirm {
border: 1px solid #f2604c;
border-radius: 6px;
padding: 0.5rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.controls__confirm p {
flex-basis: 100%;
margin: 0;
}
.controls__notice {
margin: 0;
}
.controls__notice.is-error {
color: #f2604c;
}
.controls__notice.is-ok {
opacity: 0.7;
}
</style>
+2 -1
View File
@@ -1,4 +1,5 @@
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 } from "./types";
export type { AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types";
+10
View File
@@ -48,3 +48,13 @@ export interface AppPrinterState {
export type AppTelemetryMessage =
| { type: "ready"; visiblePrinters: number }
| { type: "printer.telemetry"; printer: AppPrinterState };
/**
* What `POST /printers/:id/commands` accepts: the edge schema's
* `printer_command.schema.json`, minus `start_print` (a job's dispatch sends
* that, never a person) and `send_gcode_line` (arbitrary g-code is not
* offered to a button). Hand-mirrored like the rest of this file.
*/
export type PrinterCommand =
| { action: "control_print"; control: "pause" | "resume" | "stop" }
| { action: "set_fan_speed"; percent: number };