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:
@@ -28,6 +28,7 @@ onBeforeUnmount(() => {
|
||||
<nav class="app-shell__nav">
|
||||
<NuxtLink to="/">Fleet</NuxtLink>
|
||||
<NuxtLink to="/jobs">Jobs</NuxtLink>
|
||||
<NuxtLink to="/setup">Setup</NuxtLink>
|
||||
</nav>
|
||||
<span class="app-shell__status" :class="telemetry.connected ? 'is-online' : 'is-offline'">
|
||||
{{
|
||||
|
||||
@@ -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 ?? []"
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import type { AppDiscoveredCandidate, NewPrinter } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
gatewayOnline: boolean | null;
|
||||
/** Printers the gateway found that are not added yet; picking one fills the form in. */
|
||||
candidates: AppDiscoveredCandidate[];
|
||||
/** Adds the printer. Rejects with a readable message. */
|
||||
add: (printer: NewPrinter) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const name = ref("");
|
||||
const host = ref("");
|
||||
const serialNumber = ref("");
|
||||
const accessCode = ref("");
|
||||
const busy = ref(false);
|
||||
const failure = ref("");
|
||||
const added = ref("");
|
||||
|
||||
// Only Bambu has a working driver; the others are listed by discovery but
|
||||
// cannot be driven yet, so offering them would add a printer that does nothing.
|
||||
const usable = computed(() => props.candidates.filter((c) => c.vendor.startsWith("bambu") && !c.matchedOn));
|
||||
|
||||
function fill(candidate: AppDiscoveredCandidate) {
|
||||
host.value = candidate.address;
|
||||
serialNumber.value = candidate.serial ?? "";
|
||||
name.value = candidate.name || candidate.model || name.value;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
busy.value = true;
|
||||
failure.value = "";
|
||||
added.value = "";
|
||||
try {
|
||||
await props.add({
|
||||
name: name.value.trim(),
|
||||
host: host.value.trim(),
|
||||
serialNumber: serialNumber.value.trim(),
|
||||
accessCode: accessCode.value.trim(),
|
||||
});
|
||||
added.value = name.value;
|
||||
name.value = host.value = serialNumber.value = "";
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
// Cleared on success AND failure: the code should not sit in a form
|
||||
// after it has been sent, and retyping eight digits is cheap.
|
||||
accessCode.value = "";
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="add-printer" aria-label="Add a printer">
|
||||
<h3>Add a Bambu printer</h3>
|
||||
<p v-if="gatewayOnline === false">
|
||||
The gateway is not connected. The printer's access code is handed to the gateway and kept only there, so it
|
||||
has to be online to add a printer.
|
||||
</p>
|
||||
|
||||
<div v-if="usable.length" class="add-printer__found">
|
||||
Found on the network:
|
||||
<button v-for="c in usable" :key="c.address" type="button" @click="fill(c)">
|
||||
{{ c.name || c.model || c.vendor }} ({{ c.address }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<label>Name <input v-model="name" required maxlength="200" placeholder="Bench P1S" /></label>
|
||||
<label>
|
||||
Address
|
||||
<input v-model="host" required maxlength="255" pattern="[A-Za-z0-9.:_\-]+" placeholder="192.168.0.20" />
|
||||
</label>
|
||||
<label>
|
||||
Serial number
|
||||
<input v-model="serialNumber" required minlength="5" maxlength="64" pattern="[A-Za-z0-9]+" />
|
||||
</label>
|
||||
<label>
|
||||
Access code
|
||||
<input
|
||||
v-model="accessCode"
|
||||
required
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
maxlength="128"
|
||||
placeholder="from the printer's screen"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" :disabled="busy || gatewayOnline === false">
|
||||
{{ busy ? "Connecting to the printer..." : "Add printer" }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="add-printer__note">
|
||||
The access code goes to your gateway and nowhere else; Continuum's servers do not keep it.
|
||||
</p>
|
||||
<p v-if="added" role="status" class="add-printer__ok">Added {{ added }}.</p>
|
||||
<p v-if="failure" role="alert" class="add-printer__error">{{ failure }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.add-printer form {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.add-printer label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.add-printer__found {
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-printer__note {
|
||||
opacity: 0.7;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.add-printer__ok {
|
||||
color: #35d07f;
|
||||
}
|
||||
|
||||
.add-printer__error {
|
||||
color: #f2604c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { AppAlert } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
alerts: AppAlert[];
|
||||
/** Resolves when the alert is acknowledged; rejects with a readable message. */
|
||||
acknowledge: (id: string) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const busy = ref<string | null>(null);
|
||||
const failure = ref("");
|
||||
|
||||
async function done(alert: AppAlert) {
|
||||
busy.value = alert.id;
|
||||
failure.value = "";
|
||||
try {
|
||||
await props.acknowledge(alert.id);
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
const when = (iso: string) => new Date(iso).toLocaleString();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="alerts.length" class="alert-list" aria-label="Alerts">
|
||||
<h2>Needs attention ({{ alerts.length }})</h2>
|
||||
<ul>
|
||||
<li v-for="alert in alerts" :key="alert.id" :class="`is-${alert.kind}`">
|
||||
<span class="alert-list__message">{{ alert.message }}</span>
|
||||
<time :datetime="alert.createdAt">{{ when(alert.createdAt) }}</time>
|
||||
<button type="button" :disabled="busy === alert.id" @click="done(alert)">
|
||||
{{ busy === alert.id ? "..." : "Dismiss" }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="failure" role="alert" class="alert-list__error">{{ failure }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.alert-list {
|
||||
border: 1px solid #f2604c;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.alert-list h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
color: #f2604c;
|
||||
}
|
||||
|
||||
.alert-list ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.alert-list li {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: baseline;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.alert-list__message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.alert-list time {
|
||||
opacity: 0.6;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.alert-list__error {
|
||||
color: #f2604c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import type { AppFarm } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
farm: AppFarm;
|
||||
/** null until the first answer arrives. */
|
||||
gatewayOnline: boolean | null;
|
||||
/** Where the gateway connects; shown in its settings so there is one place to copy from. */
|
||||
uplinkUrl: string;
|
||||
/** Mints a new proxy token. Rejects with a readable message. */
|
||||
mint: () => Promise<string>;
|
||||
}>();
|
||||
|
||||
const token = ref("");
|
||||
const confirming = ref(false);
|
||||
const busy = ref(false);
|
||||
const failure = ref("");
|
||||
const copied = ref(false);
|
||||
|
||||
// Minting replaces the old token, which disconnects a gateway that already
|
||||
// has it at its next reconnect - so a farm that has a gateway online asks
|
||||
// first, and a farm that has never had one does not.
|
||||
async function run() {
|
||||
busy.value = true;
|
||||
failure.value = "";
|
||||
try {
|
||||
token.value = await props.mint();
|
||||
confirming.value = false;
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (props.gatewayOnline) confirming.value = true;
|
||||
else void run();
|
||||
}
|
||||
|
||||
const settings = computed(() =>
|
||||
[
|
||||
`gateway_id = "gateway-1"`,
|
||||
`farm_id = "${props.farm.id}"`,
|
||||
`gateway_token = "${token.value}"`,
|
||||
`uplink_url = "${props.uplinkUrl}"`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(settings.value);
|
||||
copied.value = true;
|
||||
} catch {
|
||||
// Clipboard access can be refused; the text is selectable on screen.
|
||||
copied.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="gateway-setup" :aria-label="`Gateway for ${farm.name}`">
|
||||
<h3>Gateway</h3>
|
||||
<p v-if="gatewayOnline" class="gateway-setup__ok">A gateway is connected.</p>
|
||||
<p v-else>
|
||||
No gateway is connected. Install the Continuum gateway on a computer on the printers' network and give it the
|
||||
settings below.
|
||||
</p>
|
||||
|
||||
<template v-if="!token">
|
||||
<button v-if="!confirming" type="button" :disabled="busy" @click="start">
|
||||
{{ gatewayOnline ? "Create a new gateway token" : "Create gateway settings" }}
|
||||
</button>
|
||||
<div v-else class="gateway-setup__confirm" role="alertdialog">
|
||||
<p>
|
||||
This replaces the current token. The connected gateway will be refused the next time it reconnects, until
|
||||
you give it the new one.
|
||||
</p>
|
||||
<button type="button" :disabled="busy" @click="run">Replace token</button>
|
||||
<button type="button" :disabled="busy" @click="confirming = false">Keep the current one</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p>
|
||||
Put this in the gateway's <code>continuum.toml</code>. <strong>The token is shown only now</strong> - it is not
|
||||
kept anywhere you can read it back.
|
||||
</p>
|
||||
<pre class="gateway-setup__settings">{{ settings }}</pre>
|
||||
<button type="button" @click="copy">{{ copied ? "Copied" : "Copy" }}</button>
|
||||
<button type="button" @click="token = ''">Done</button>
|
||||
</template>
|
||||
|
||||
<p v-if="failure" role="alert" class="gateway-setup__error">{{ failure }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gateway-setup__ok {
|
||||
color: #35d07f;
|
||||
}
|
||||
|
||||
.gateway-setup__settings {
|
||||
background: #12151c;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.gateway-setup__error {
|
||||
color: #f2604c;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Creates the organisation (if needed) and the farm. Rejects with a readable message. */
|
||||
create: (farmName: string) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const farmName = ref("");
|
||||
const busy = ref(false);
|
||||
const failure = ref("");
|
||||
|
||||
async function submit() {
|
||||
busy.value = true;
|
||||
failure.value = "";
|
||||
try {
|
||||
await props.create(farmName.value.trim());
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="onboarding" @submit.prevent="submit">
|
||||
<h2>Set up your first farm</h2>
|
||||
<p>A farm is one site - a workshop or a room - with one gateway and its printers.</p>
|
||||
<label>
|
||||
Farm name
|
||||
<input v-model="farmName" required maxlength="200" placeholder="Workshop" />
|
||||
</label>
|
||||
<button type="submit" :disabled="busy || !farmName.trim()">{{ busy ? "Creating..." : "Create farm" }}</button>
|
||||
<p v-if="failure" role="alert" class="onboarding__error">{{ failure }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.onboarding {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.onboarding label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.onboarding__error {
|
||||
color: #f2604c;
|
||||
}
|
||||
</style>
|
||||
@@ -4,5 +4,10 @@ export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
|
||||
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
|
||||
export { default as JobList } from "./components/JobList.vue";
|
||||
export { default as JobUpload } from "./components/JobUpload.vue";
|
||||
export { default as AlertList } from "./components/AlertList.vue";
|
||||
export { default as OnboardingForm } from "./components/OnboardingForm.vue";
|
||||
export { default as GatewaySetup } from "./components/GatewaySetup.vue";
|
||||
export { default as AddPrinter } from "./components/AddPrinter.vue";
|
||||
export { default as ManagedPrinters } from "./components/ManagedPrinters.vue";
|
||||
export { default as FarmStatus } from "./components/FarmStatus.vue";
|
||||
export type { AppDiscoveredCandidate, AppFarm, AppFarmDiscovery, AppJob, AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types";
|
||||
export type { NewPrinter, AppOrg, AppAlert, AppDiscoveredCandidate, AppFarm, AppFarmDiscovery, AppJob, AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types";
|
||||
|
||||
@@ -101,3 +101,32 @@ export interface AppFarmDiscovery {
|
||||
gatewayOnline: boolean;
|
||||
discovery: { scannedAt: string; printers: AppDiscoveredCandidate[] } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /alerts`. `kind` is a plain string for the same reason `AppJob.status`
|
||||
* is: an unknown one should render, not break the page.
|
||||
*/
|
||||
export interface AppAlert {
|
||||
id: string;
|
||||
farmId: string;
|
||||
printerId?: string | null;
|
||||
kind: string;
|
||||
message: string;
|
||||
confidence?: number | null;
|
||||
acknowledgedAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** `GET /orgs`: the organisations the caller belongs to. */
|
||||
export interface AppOrg {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** What the add-printer form hands to the page. The access code is a secret: send it, do not keep it. */
|
||||
export interface NewPrinter {
|
||||
name: string;
|
||||
host: string;
|
||||
serialNumber: string;
|
||||
accessCode: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user