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
+28 -1
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import {
AlertList,
FarmStatus,
type AppAlert,
PrinterCard,
type AppFarm,
type AppFarmDiscovery,
@@ -70,12 +72,33 @@ async function refreshFarmStatus() {
}
}
// What still needs a person. Polled on the same 5s beat as jobs: an alert is
// useful for minutes, not milliseconds, and a push channel for it would be a
// second socket to keep honest.
const { data: alerts, refresh: refreshAlerts } = await useAsyncData("alerts", () =>
apiFetch<AppAlert[]>("/alerts", { query: { open: true } }),
);
async function acknowledge(id: string): Promise<void> {
try {
await apiFetch(`/alerts/${id}/acknowledge`, { method: "POST" });
} catch (err) {
throw new Error(apiErrorMessage(err, "could not dismiss the alert"));
}
await refreshAlerts();
}
let farmPoll: ReturnType<typeof setInterval> | undefined;
let alertPoll: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
void refreshFarmStatus();
farmPoll = setInterval(() => void refreshFarmStatus(), 10_000);
alertPoll = setInterval(() => void refreshAlerts(), 5_000);
});
onBeforeUnmount(() => {
clearInterval(farmPoll);
clearInterval(alertPoll);
});
onBeforeUnmount(() => clearInterval(farmPoll));
/**
* Sends one command to a printer and turns a refusal into a sentence.
@@ -114,7 +137,11 @@ watch(
<template>
<div class="fleet">
<h1>Fleet overview</h1>
<p v-if="farms && !farms.length">
You have no farm yet. <NuxtLink to="/setup">Set one up</NuxtLink> to connect your printers.
</p>
<FarmStatus v-for="farm in farms ?? []" :key="farm.id" :farm="farm" :status="farmStatus[farm.id] ?? null" />
<AlertList :alerts="alerts ?? []" :acknowledge="acknowledge" />
<div class="fleet__grid">
<PrinterCard
v-for="printer in printers ?? []"
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
import {
AddPrinter,
GatewaySetup,
ManagedPrinters,
OnboardingForm,
type AppFarm,
type AppFarmDiscovery,
type AppOrg,
type AppPrinterState,
type NewPrinter,
} from "@continuum/ui";
const apiFetch = useApiFetch();
const { wsBase } = useRuntimeConfig().public;
const { data: orgs, refresh: refreshOrgs } = await useAsyncData("setup-orgs", () => apiFetch<AppOrg[]>("/orgs"));
const { data: farms, refresh: refreshFarms } = await useAsyncData("setup-farms", () => apiFetch<AppFarm[]>("/farms"));
const { data: printers, refresh: refreshPrinters } = await useAsyncData("setup-printers", () =>
apiFetch<AppPrinterState[]>("/printers"),
);
// The gateway's presence and what it found, per farm - the same endpoint the
// fleet page reads. Polled while this page is open so "gateway connected"
// turns true as soon as someone finishes configuring it.
const status = ref<Record<string, AppFarmDiscovery>>({});
async function refreshStatus() {
for (const farm of farms.value ?? []) {
try {
status.value[farm.id] = await apiFetch<AppFarmDiscovery>(`/farms/${farm.id}/discovered-printers`);
} catch {
// Keep the last answer; a failed poll is not "offline".
}
}
}
let poll: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
void refreshStatus();
poll = setInterval(() => void refreshStatus(), 5_000);
});
onBeforeUnmount(() => clearInterval(poll));
/**
* Creates the first farm. Reuses the caller's organisation if they already
* have one (a person who was invited has an org and no farm), and creates one
* named after the farm otherwise.
*/
async function createFarm(farmName: string) {
try {
let orgId = orgs.value?.[0]?.id;
if (!orgId) {
const org = await apiFetch<AppOrg>("/orgs", { method: "POST", body: { name: farmName } });
orgId = org.id;
}
await apiFetch("/farms", { method: "POST", body: { name: farmName, orgId } });
} catch (err) {
throw new Error(apiErrorMessage(err, "the farm could not be created"));
}
await Promise.all([refreshOrgs(), refreshFarms()]);
await refreshStatus();
}
async function mintToken(farmId: string): Promise<string> {
try {
const result = await apiFetch<{ edgeProxyToken: string }>(`/farms/${farmId}/edge-proxy-token`, {
method: "POST",
});
return result.edgeProxyToken;
} catch (err) {
throw new Error(apiErrorMessage(err, "the token could not be created"));
}
}
async function addPrinter(farmId: string, printer: NewPrinter) {
try {
await apiFetch(`/farms/${farmId}/printers`, {
method: "POST",
body: { ...printer, vendor: "bambu" },
});
} catch (err) {
throw new Error(apiErrorMessage(err, "the printer could not be added"));
}
await refreshPrinters();
}
async function removePrinter(printerId: string) {
try {
await apiFetch(`/printers/${printerId}`, { method: "DELETE" });
} catch (err) {
throw new Error(apiErrorMessage(err, "the printer could not be removed"));
}
await refreshPrinters();
}
const printersOf = (farmId: string) => (printers.value ?? []).filter((p) => p.farmId === farmId);
</script>
<template>
<div class="setup">
<h1>Setup</h1>
<OnboardingForm v-if="!farms?.length" :create="createFarm" />
<section v-for="farm in farms ?? []" :key="farm.id" class="setup__farm">
<h2>{{ farm.name }}</h2>
<GatewaySetup
:farm="farm"
:gateway-online="status[farm.id]?.gatewayOnline ?? null"
:uplink-url="`${wsBase}/ws/edge/v1`"
:mint="() => mintToken(farm.id)"
/>
<AddPrinter
:gateway-online="status[farm.id]?.gatewayOnline ?? null"
:candidates="status[farm.id]?.discovery?.printers ?? []"
:add="(printer: NewPrinter) => addPrinter(farm.id, printer)"
/>
<ManagedPrinters :printers="printersOf(farm.id)" :remove="removePrinter" />
</section>
</div>
</template>
<style scoped>
.setup__farm {
border-top: 1px solid #1f232c;
margin-top: 1.5rem;
padding-top: 1rem;
}
</style>