Add Setup: first farm, gateway settings, add and remove printers; show alerts

A new user could sign in and see an empty page with no way forward. Setup
walks them through it: create a farm (reusing their organisation if they
already have one); get the gateway settings with a freshly minted token,
shown once and removed from the page when dismissed, with a confirmation
first if a gateway is already connected, since minting replaces the old
token; add a Bambu printer by address, serial and access code (a printer
the gateway discovered fills the form in); remove a printer behind a named
confirmation that says it also deletes the stored credential.

The access code field is cleared after every submit, success or failure, and
the page says plainly that the code goes to the gateway and nowhere else.
Add is disabled while no gateway is connected, because the code has nowhere
to go.

The fleet page now lists open alerts above the printers (polled every 5s)
with a Dismiss button, and points an empty account at Setup.

Verified in real Chrome by scripts/e2e-setup-browser.sh in continuum-backend:
nothing -> farm -> gateway -> printer -> failed-print alert -> dismissed ->
removed, with no page errors.
This commit is contained in:
2026-10-29 15:15:06 +00:00
parent 571a2c0ca7
commit 9bd9728b21
10 changed files with 650 additions and 2 deletions
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from "vue";
import type { AppPrinterState } from "../types";
const props = defineProps<{
printers: AppPrinterState[];
/** Removes the printer. Rejects with a readable message. */
remove: (id: string) => Promise<void>;
}>();
// Named confirmation, like Stop: removing also deletes the credential on the
// gateway, which cannot be undone from here.
const confirming = ref<string | null>(null);
const busy = ref<string | null>(null);
const failure = ref("");
async function confirm(printer: AppPrinterState) {
busy.value = printer.id;
failure.value = "";
try {
await props.remove(printer.id);
} catch (err) {
failure.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = null;
confirming.value = null;
}
}
</script>
<template>
<section v-if="printers.length" class="managed" aria-label="Printers">
<h3>Printers</h3>
<ul>
<li v-for="printer in printers" :key="printer.id">
<span class="managed__name">{{ printer.name }}</span>
<button v-if="confirming !== printer.id" type="button" @click="confirming = printer.id">Remove</button>
<template v-else>
<span>Remove {{ printer.name }} and forget its access code?</span>
<button type="button" :disabled="busy === printer.id" @click="confirm(printer)">Yes, remove</button>
<button type="button" @click="confirming = null">Cancel</button>
</template>
</li>
</ul>
<p v-if="failure" role="alert" class="managed__error">{{ failure }}</p>
</section>
</template>
<style scoped>
.managed ul {
list-style: none;
padding: 0;
}
.managed li {
display: flex;
gap: 0.75rem;
align-items: baseline;
padding: 0.25rem 0;
}
.managed__name {
min-width: 10rem;
font-weight: 600;
}
.managed__error {
color: #f2604c;
}
</style>