Compare commits

...
4 Commits
Author SHA1 Message Date
octoturge 88b242d914 Add members/invitations and code-based gateway setup to the Setup page
Gateway setup shows a `continuum-proxy enrol` command with a one-time
code; the manual settings stay behind "Set it up by hand". New members
section for inviting by email, plus an /invite page to accept, and the
login page honours a safe `next` redirect.
2026-10-30 08:13:00 +00:00
octoturge 69f35c2a6b Let the add-printer form take every supported kind
A kind selector (Bambu, Klipper, PrusaLink, OctoPrint) decides which fields
appear: serial and access code for Bambu, an API key for PrusaLink and
OctoPrint, neither for Klipper. Discovered printers of any of these kinds
now fill the form in.
2026-10-30 07:49:31 +00:00
octoturge 6b1c7c6db3 Show P1/A1 cameras as a refreshing picture
SnapshotView fetches one frame at a time through the backend, pauses
while the tab is hidden and backs off after failures. PrinterCard picks
it when /stream answers kind "snapshot".
2026-10-29 16:11:10 +00:00
octoturge 9bd9728b21 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.
2026-10-29 15:15:06 +00:00
17 changed files with 1163 additions and 13 deletions
+1
View File
@@ -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'">
{{
+7 -1
View File
@@ -1,7 +1,13 @@
/**
* Where a printer's camera can be watched, from `GET /printers/:id/stream`.
*/
export interface PrinterStream {
export type PrinterStream =
| WebrtcStream
// A P1/A1: no stream to negotiate, a picture to fetch again and again.
| { kind: "snapshot"; snapshotPath: string; intervalMs: number };
export interface WebrtcStream {
kind?: "webrtc";
/** The go2rtc stream name to negotiate for. */
cameraId: string;
/**
+1 -1
View File
@@ -15,7 +15,7 @@ export default defineNuxtConfig({
redirectOptions: {
login: "/login",
callback: "/confirm",
exclude: ["/login"],
exclude: ["/login", "/invite"],
},
},
+35 -2
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import {
AlertList,
FarmStatus,
type AppAlert,
PrinterCard,
type AppFarm,
type AppFarmDiscovery,
@@ -54,6 +56,11 @@ async function signalOffer(printerId: string, sdp: string): Promise<string> {
return answer.sdp;
}
/** One camera frame (P1/A1) as an image, through the backend. */
function fetchFrame(printerId: string): Promise<Blob> {
return apiFetch<Blob>(`/printers/${printerId}/snapshot`, { responseType: "blob" });
}
// Which farms exist, and for each whether its gateway is connected and what
// discovery has found. Polled, because gateway presence has no push channel;
// ten seconds is quick enough to notice an unplugged box without hammering.
@@ -70,12 +77,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.
@@ -96,7 +124,7 @@ watch(
printers,
(list) => {
for (const printer of list ?? []) {
if (!printer.cameraStream || printer.id in streams.value) continue;
if (!(printer.cameraStream || printer.cameraSnapshot) || printer.id in streams.value) continue;
// Marked before the request resolves, so a re-render in the meantime
// does not fire a second lookup for the same printer. `null` renders
// no player, which is also the right thing to show while this is in
@@ -114,7 +142,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 ?? []"
@@ -122,6 +154,7 @@ watch(
:printer="current(printer)"
:stream="streams[printer.id]"
:signal="(sdp: string) => signalOffer(printer.id, sdp)"
:fetch-frame="() => fetchFrame(printer.id)"
:send="(command: PrinterCommand) => sendCommand(printer.id, command)"
/>
</div>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
// Reached from an invitation link. Excluded from the sign-in redirect (see
// nuxt.config.ts) so the token in the URL survives: a visitor who is not
// signed in is sent to /login and brought back here afterwards.
const route = useRoute();
const user = useSupabaseUser();
const apiFetch = useApiFetch();
const token = computed(() => (typeof route.query.token === "string" ? route.query.token : ""));
const state = ref<"waiting" | "working" | "done" | "failed">("waiting");
const message = ref("");
const joined = ref<{ orgName: string; role: string } | null>(null);
async function accept() {
state.value = "working";
try {
const result = await apiFetch<{ orgName: string; role: string }>("/invitations/accept", {
method: "POST",
body: { token: token.value },
});
joined.value = result;
state.value = "done";
} catch (err) {
message.value = apiErrorMessage(err, "the invitation could not be accepted");
state.value = "failed";
}
}
const next = computed(() => `/invite?token=${encodeURIComponent(token.value)}`);
</script>
<template>
<div class="invite">
<h1>Join an organisation</h1>
<p v-if="!token">This link has no invitation in it.</p>
<template v-else-if="!user">
<p>Sign in with the email address the invitation was sent to, then you will come back here.</p>
<NuxtLink :to="{ path: '/login', query: { next } }">Sign in</NuxtLink>
</template>
<template v-else>
<p>You are signed in as {{ user.email }}.</p>
<button v-if="state === 'waiting' || state === 'working'" type="button" :disabled="state === 'working'" @click="accept">
Accept the invitation
</button>
<p v-if="state === 'done'" role="status">
You have joined {{ joined?.orgName }} as {{ joined?.role }}. <NuxtLink to="/">Go to the dashboard</NuxtLink>
</p>
<p v-if="state === 'failed'" role="alert">{{ message }}</p>
</template>
</div>
</template>
<style scoped>
.invite {
max-width: 28rem;
margin: 4rem auto;
display: grid;
gap: 0.75rem;
}
</style>
+14 -2
View File
@@ -5,6 +5,15 @@ const error = ref<string | null>(null);
const client = useSupabaseClient();
const passwordLogin = useRuntimeConfig().public.passwordLogin;
const password = ref("");
const route = useRoute();
// Where to go after signing in. Only a path on this site: a `next` of
// `//elsewhere.example` or `https://...` would turn the sign-in page into an
// open redirect.
const next = computed(() => {
const wanted = route.query.next;
return typeof wanted === "string" && wanted.startsWith("/") && !wanted.startsWith("//") ? wanted : "/";
});
async function signInWithPassword() {
error.value = null;
@@ -17,12 +26,15 @@ async function signInWithPassword() {
return;
}
// The Supabase module redirects away from /login once a session exists.
await navigateTo("/");
await navigateTo(next.value);
}
async function sendMagicLink() {
error.value = null;
const { error: authError } = await client.auth.signInWithOtp({ email: email.value });
const { error: authError } = await client.auth.signInWithOtp({
email: email.value,
options: next.value === "/" ? undefined : { emailRedirectTo: `${window.location.origin}${next.value}` },
});
if (authError) {
error.value = authError.message;
return;
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
import {
AddPrinter,
GatewaySetup,
ManagedPrinters,
OrgMembers,
OnboardingForm,
type AppFarm,
type AppFarmDiscovery,
type AppInvitation,
type AppMember,
type AppOrg,
type CreatedInvitation,
type EnrolmentCode,
type OrgRole,
type AppPrinterState,
type NewPrinter,
} from "@continuum/ui";
const apiFetch = useApiFetch();
const { wsBase, apiBase } = 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 makeEnrolmentCode(farmId: string): Promise<EnrolmentCode> {
try {
return await apiFetch<EnrolmentCode>(`/farms/${farmId}/enrolment-codes`, { method: "POST" });
} catch (err) {
throw new Error(apiErrorMessage(err, "the code could not be created"));
}
}
// People, per organisation the caller owns (only owners can see invitations).
const members = ref<Record<string, AppMember[]>>({});
const invitations = ref<Record<string, AppInvitation[]>>({});
async function refreshPeople() {
for (const org of orgs.value ?? []) {
try {
members.value[org.id] = await apiFetch<AppMember[]>(`/orgs/${org.id}/members`);
if (org.role === "owner") invitations.value[org.id] = await apiFetch<AppInvitation[]>(`/orgs/${org.id}/invitations`);
} catch {
// Leave the last answer; the section just stays as it was.
}
}
}
onMounted(() => void refreshPeople());
async function invite(orgId: string, email: string, role: OrgRole): Promise<CreatedInvitation> {
try {
const created = await apiFetch<CreatedInvitation>(`/orgs/${orgId}/invitations`, { method: "POST", body: { email, role } });
await refreshPeople();
return created;
} catch (err) {
throw new Error(apiErrorMessage(err, "the invitation could not be created"));
}
}
async function revokeInvitation(orgId: string, invitationId: string) {
try {
await apiFetch(`/orgs/${orgId}/invitations/${invitationId}`, { method: "DELETE" });
} catch (err) {
throw new Error(apiErrorMessage(err, "the invitation could not be revoked"));
}
await refreshPeople();
}
async function addPrinter(farmId: string, printer: NewPrinter) {
try {
await apiFetch(`/farms/${farmId}/printers`, {
method: "POST",
body: printer,
});
} 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 origin = typeof window === "undefined" ? "" : window.location.origin;
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)"
:enrol="() => makeEnrolmentCode(farm.id)"
:api-url="apiBase"
/>
<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>
<section v-for="org in (orgs ?? []).filter((o) => o.role === 'owner')" :key="org.id" class="setup__farm">
<OrgMembers
:org-name="org.name"
:members="members[org.id] ?? []"
:invitations="invitations[org.id] ?? []"
:invite="(email: string, role: OrgRole) => invite(org.id, email, role)"
:revoke="(id: string) => revokeInvitation(org.id, id)"
:origin="origin"
/>
</section>
</div>
</template>
<style scoped>
.setup__farm {
border-top: 1px solid #1f232c;
margin-top: 1.5rem;
padding-top: 1rem;
}
</style>
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { AppDiscoveredCandidate, NewPrinter, PrinterKind } 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 KINDS: { value: PrinterKind; label: string }[] = [
{ value: "bambu", label: "Bambu Lab" },
{ value: "klipper", label: "Klipper (Moonraker)" },
{ value: "prusa_link", label: "Prusa (PrusaLink)" },
{ value: "octoprint", label: "OctoPrint" },
];
const vendor = ref<PrinterKind>("bambu");
const apiKey = ref("");
const name = ref("");
const host = ref("");
const serialNumber = ref("");
const accessCode = ref("");
const busy = ref(false);
const failure = ref("");
const added = ref("");
// Discovery names the vendors the same way the form does, except that a
// Bambu may be reported as `bambu_v1`/`bambu_v2`.
const kindOf = (vendorName: string): PrinterKind | undefined =>
vendorName.startsWith("bambu") ? "bambu" : KINDS.find((k) => k.value === vendorName)?.value;
const usable = computed(() => props.candidates.filter((c) => kindOf(c.vendor) && !c.matchedOn));
function fill(candidate: AppDiscoveredCandidate) {
vendor.value = kindOf(candidate.vendor) ?? vendor.value;
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 {
const printer: NewPrinter = { vendor: vendor.value, name: name.value.trim(), host: host.value.trim() };
if (vendor.value === "bambu") {
printer.serialNumber = serialNumber.value.trim();
printer.accessCode = accessCode.value.trim();
} else if (vendor.value !== "klipper") {
printer.apiKey = apiKey.value.trim();
}
await props.add(printer);
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 = "";
apiKey.value = "";
busy.value = false;
}
}
</script>
<template>
<section class="add-printer" aria-label="Add a printer">
<h3>Add a printer</h3>
<p v-if="gatewayOnline === false">
The gateway is not connected. The printer's credentials are 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>
Kind
<select v-model="vendor">
<option v-for="k in KINDS" :key="k.value" :value="k.value">{{ k.label }}</option>
</select>
</label>
<label>Name <input v-model="name" required maxlength="200" placeholder="Bench printer" /></label>
<label>
Address
<input v-model="host" required maxlength="255" pattern="[A-Za-z0-9.:_\-]+" placeholder="192.168.0.20" />
</label>
<label v-if="vendor === 'bambu'">
Serial number
<input v-model="serialNumber" required minlength="5" maxlength="64" pattern="[A-Za-z0-9]+" />
</label>
<label v-if="vendor === 'octoprint' || vendor === 'prusa_link'">
API key
<input
v-model="apiKey"
required
type="password"
autocomplete="off"
maxlength="256"
:placeholder="vendor === 'octoprint' ? 'from OctoPrint settings' : 'from PrusaLink settings'"
/>
</label>
<label v-if="vendor === 'bambu'">
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 or API key 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>
+84
View File
@@ -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>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { AppFarm, EnrolmentCode } 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>;
/** Makes a one-time enrolment code. Rejects with a readable message. */
enrol: () => Promise<EnrolmentCode>;
/** The API's address as a gateway reaches it, for the enrol command. */
apiUrl: string;
}>();
const enrolment = ref<EnrolmentCode | null>(null);
const enrolBusy = ref(false);
const enrolCopied = ref(false);
const command = computed(() => `continuum-proxy enrol ${props.apiUrl} ${enrolment.value?.code ?? ""}`);
async function makeCode() {
enrolBusy.value = true;
failure.value = "";
try {
enrolment.value = await props.enrol();
enrolCopied.value = false;
} catch (err) {
failure.value = err instanceof Error ? err.message : String(err);
} finally {
enrolBusy.value = false;
}
}
async function copyCommand() {
try {
await navigator.clipboard.writeText(command.value);
enrolCopied.value = true;
} catch {
enrolCopied.value = false;
}
}
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>
<div class="gateway-setup__enrol">
<template v-if="!enrolment">
<button type="button" :disabled="enrolBusy" @click="makeCode">Connect a gateway with a code</button>
</template>
<template v-else>
<p>
On the computer that will run the gateway, run this within 15 minutes. The code works once, and it does not
replace a gateway that is already connected.
</p>
<pre class="gateway-setup__settings">{{ command }}</pre>
<button type="button" @click="copyCommand">{{ enrolCopied ? "Copied" : "Copy" }}</button>
<button type="button" @click="enrolment = null">Done</button>
</template>
</div>
<details class="gateway-setup__manual">
<summary>Set it up by hand instead</summary>
<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>
</details>
<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>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref } from "vue";
import type { AppInvitation, AppMember, CreatedInvitation, OrgRole } from "../types";
const props = defineProps<{
orgName: string;
members: AppMember[];
invitations: AppInvitation[];
/** Creates an invitation. Rejects with a readable message. */
invite: (email: string, role: OrgRole) => Promise<CreatedInvitation>;
revoke: (invitationId: string) => Promise<void>;
/** The address the console is served from, to turn an invitation's path into a link. */
origin: string;
}>();
const email = ref("");
const role = ref<OrgRole>("operator");
const busy = ref(false);
const failure = ref("");
const link = ref("");
const invitedEmail = ref("");
const copied = ref(false);
async function submit() {
busy.value = true;
failure.value = "";
link.value = "";
try {
const created = await props.invite(email.value.trim(), role.value);
link.value = `${props.origin}${created.acceptPath}`;
invitedEmail.value = created.email;
email.value = "";
} catch (err) {
failure.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function copy() {
try {
await navigator.clipboard.writeText(link.value);
copied.value = true;
} catch {
copied.value = false;
}
}
async function revoke(id: string) {
failure.value = "";
try {
await props.revoke(id);
} catch (err) {
failure.value = err instanceof Error ? err.message : String(err);
}
}
</script>
<template>
<section class="org-members" :aria-label="`People in ${orgName}`">
<h3>People in {{ orgName }}</h3>
<ul>
<li v-for="m in members" :key="m.userId">{{ m.email ?? m.userId }} - {{ m.role }}</li>
</ul>
<h4>Invite someone</h4>
<form @submit.prevent="submit">
<input v-model="email" type="email" required maxlength="320" placeholder="colleague@farm.com" />
<select v-model="role">
<option value="viewer">Viewer - can look</option>
<option value="operator">Operator - can print and control</option>
<option value="owner">Owner - can manage people and printers</option>
</select>
<button type="submit" :disabled="busy">Create invitation</button>
</form>
<div v-if="link" class="org-members__link" role="status">
<p>
Send this link to {{ invitedEmail }}. It works once, only when they sign in with that address, and
<strong>is shown only now</strong>. Continuum does not email it for you.
</p>
<pre>{{ link }}</pre>
<button type="button" @click="copy">{{ copied ? "Copied" : "Copy" }}</button>
</div>
<template v-if="invitations.length">
<h4>Waiting to be accepted</h4>
<ul>
<li v-for="i in invitations" :key="i.id">
{{ i.email }} - {{ i.role }}
<button type="button" @click="revoke(i.id)">Revoke</button>
</li>
</ul>
</template>
<p v-if="failure" role="alert" class="org-members__error">{{ failure }}</p>
</section>
</template>
<style scoped>
.org-members form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.org-members__link pre {
background: #12151c;
padding: 0.75rem;
border-radius: 6px;
overflow-x: auto;
user-select: all;
}
.org-members__error {
color: #f2604c;
}
</style>
+13 -6
View File
@@ -3,6 +3,7 @@ import { computed } from "vue";
import type { AppPrinterState, PrinterCommand } from "../types";
import PrinterControls from "./PrinterControls.vue";
import TemperatureGraph from "./TemperatureGraph.vue";
import SnapshotView from "./SnapshotView.vue";
import WebRTCStreamView from "./WebRTCStreamView.vue";
/**
@@ -17,11 +18,10 @@ import WebRTCStreamView from "./WebRTCStreamView.vue";
* Undefined for most printers and that is expected - see
* `usePrinterStream`. The player is simply not rendered.
*/
interface PrinterStream {
cameraId: string;
iceServers: RTCIceServer[];
turnConfigured: boolean;
}
type PrinterStream =
| { kind?: "webrtc"; cameraId: string; iceServers: RTCIceServer[]; turnConfigured: boolean }
// A P1/A1: no stream to negotiate, a picture to fetch again and again.
| { kind: "snapshot"; intervalMs?: number };
const props = defineProps<{
/**
@@ -49,6 +49,8 @@ const props = defineProps<{
* read-only embedding of the card cannot offer buttons that do nothing.
*/
send?: (command: PrinterCommand) => Promise<void>;
/** Fetches one camera frame, for a `kind: "snapshot"` stream. */
fetchFrame?: () => Promise<Blob>;
}>();
// `status` is open-ended and null for a printer that has never reported.
@@ -88,8 +90,13 @@ const lastSeen = computed(() => {
{{ lastSeen ? `Last heard from ${lastSeen}` : "Never reported" }}
</p>
<SnapshotView
v-if="stream && stream.kind === 'snapshot' && fetchFrame"
:fetch-frame="fetchFrame"
:interval-ms="stream.intervalMs"
/>
<WebRTCStreamView
v-if="stream && signal"
v-else-if="stream && stream.kind !== 'snapshot' && signal"
:camera-id="stream.cameraId"
:ice-servers="stream.iceServers"
:turn-configured="stream.turnConfigured"
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps<{
/**
* Fetches the next frame as an image. Supplied by the app, which knows the
* API; this package does not.
*/
fetchFrame: () => Promise<Blob>;
/** Pause between the end of one fetch and the start of the next. */
intervalMs?: number;
}>();
const src = ref<string | null>(null);
const status = ref<"connecting" | "connected" | "failed">("connecting");
const error = ref<string | null>(null);
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
/**
* One request at a time, the next only after the last has finished: a slow
* printer then slows the picture down instead of piling requests onto a
* camera that serves one client. Skipped while the tab is hidden, and
* backed off after failures, because every attempt logs in to the printer.
*/
let failures = 0;
async function tick() {
if (stopped) return;
let delay = props.intervalMs ?? 1000;
if (document.hidden) {
timer = setTimeout(tick, delay);
return;
}
try {
const blob = await props.fetchFrame();
if (stopped) return;
const next = URL.createObjectURL(blob);
if (src.value) URL.revokeObjectURL(src.value);
src.value = next;
status.value = "connected";
error.value = null;
failures = 0;
} catch (err) {
failures += 1;
status.value = "failed";
error.value = err instanceof Error ? err.message : String(err);
delay = Math.min(30_000, delay * 2 ** Math.min(failures, 5));
}
if (!stopped) timer = setTimeout(tick, delay);
}
onMounted(tick);
onBeforeUnmount(() => {
stopped = true;
clearTimeout(timer);
if (src.value) URL.revokeObjectURL(src.value);
});
</script>
<template>
<div class="snapshot-view">
<img v-if="src" :src="src" alt="Printer camera" />
<div v-else class="snapshot-view__empty">{{ status === "failed" ? "No picture" : "Connecting…" }}</div>
<span class="snapshot-view__status" :class="`is-${status}`" :title="error ?? undefined">
{{ status }}
</span>
</div>
</template>
<style scoped>
.snapshot-view {
position: relative;
background: #000;
border-radius: 6px;
overflow: hidden;
min-height: 80px;
}
.snapshot-view img {
width: 100%;
display: block;
}
.snapshot-view__empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8rem;
color: #999;
}
.snapshot-view__status {
position: absolute;
bottom: 6px;
right: 8px;
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
}
.snapshot-view__status.is-connected {
color: #35d07f;
}
.snapshot-view__status.is-failed {
color: #f2604c;
}
</style>
+8 -1
View File
@@ -2,7 +2,14 @@ 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 { default as SnapshotView } from "./components/SnapshotView.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 OrgMembers } from "./components/OrgMembers.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 { AppMember, AppInvitation, CreatedInvitation, EnrolmentCode, OrgRole, PrinterKind, NewPrinter, AppOrg, AppAlert, AppDiscoveredCandidate, AppFarm, AppFarmDiscovery, AppJob, AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types";
+69
View File
@@ -40,6 +40,8 @@ export interface AppPrinterState {
activeAlerts?: number | null;
/** go2rtc stream name; null/absent means render no player. */
cameraStream?: string | null;
/** True for a P1/A1: the camera is a refreshing picture, not a stream. */
cameraSnapshot?: boolean;
/** When a gateway last reported this printer; null means never. */
lastSeenAt?: string | null;
}
@@ -101,3 +103,70 @@ 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;
/** The caller's role in it; only an owner can invite. */
role?: "viewer" | "operator" | "owner";
}
export type OrgRole = "viewer" | "operator" | "owner";
export interface AppMember {
userId: string;
/** Null for someone added by id rather than by invitation. */
email: string | null;
role: OrgRole;
}
export interface AppInvitation {
id: string;
email: string;
role: OrgRole;
expiresAt: string;
}
/** The answer to creating an invitation. The token is in the link, shown once. */
export interface CreatedInvitation extends AppInvitation {
acceptPath: string;
}
/** A one-time code to enrol a gateway. */
export interface EnrolmentCode {
code: string;
expiresAt: string;
}
/** The printer kinds a gateway can drive, by the name the API takes. */
export type PrinterKind = "bambu" | "klipper" | "octoprint" | "prusa_link";
/**
* What the add-printer form hands to the page. The access code and API key
* are secrets: send them, do not keep them. Which are present depends on
* `vendor` (Bambu: code + serial; OctoPrint/PrusaLink: API key; Klipper: none).
*/
export interface NewPrinter {
vendor: PrinterKind;
name: string;
host: string;
serialNumber?: string;
accessCode?: string;
apiKey?: string;
}