Compare commits

..
15 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
octoturge 571a2c0ca7 Show each farm's gateway status and what discovery found
When the fleet page is empty or a printer is "unknown", the reason is
almost always upstream: the gateway is not connected, or discovery never
reported. A strip per farm now says which - "Gateway online/offline/
checking" (no answer yet is not offline) - with a collapsible list of the
printers discovery found, each marked already added, same address as an
added printer, or not added yet, using the backend's serial/address match.
It also says plainly when discovery has not reported at all, which is the
default, as distinct from a scan that found nothing.

Read-only on purpose: adding a printer from the UI still needs the proxy's
offline-capable store and a credential pass-through that is never kept in
the cloud. Polled every 10s (gateway presence has no push channel); a
failed poll keeps the last answer instead of flipping to offline.

Checked in real Chrome against the live stack (Gateway online, discovery
not reported, no page errors); the static SPA still generates, now 6 routes.
2026-10-29 14:41:26 +00:00
octoturge c7f85870db Add a Jobs page: upload a sliced file, watch it queue, delete it
The console could watch and control printers but not give them work. The
Jobs page uploads a .3mf/.gcode: it creates the job (POST /jobs), PUTs the
file straight to the presigned storage URL with a progress bar, and lists
the farm's jobs, polling every 5s since there is no job push channel yet.
The scheduler then starts it on the next idle printer, so there is no Start
button - a queued job IS the request.

Deliberate details: the PUT carries no Authorization header (the URL is
presigned and an uncovered header makes storage reject it) and the same
Content-Type the URL was signed for; a failed upload deletes the job row
it just created, so no queued job waits for a file that never arrives;
only queued/uploading jobs offer Delete, matching what the backend allows.

JobUpload and JobList live in @continuum/ui and take the API calls as
props, like the printer card. A shared apiErrorMessage() replaces the
inline copy on the fleet page.

Checked in real Chrome against the live stack with job dispatch OFF: upload
to the real bucket (57 bytes landed intact), listed as queued, still queued
after a scheduler tick, Delete clears it, no page errors; the test object
was removed from the bucket afterwards. pnpm -r typecheck is clean.
2026-10-29 14:39:37 +00:00
octoturge 8bf56d1325 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.
2026-10-29 10:21:34 +00:00
octoturge 4519c3ba50 Let testers sign in: an opt-in password form on the login page
The login page offered only a magic link, and the devstack's Supabase
cannot send email: POST /auth/v1/otp for an existing user answers
500 "Error sending magic link email". So nobody could sign in through
the page at all; the e2e browser run only got in by calling the Supabase
client directly.

NUXT_PUBLIC_PASSWORD_LOGIN=true now shows a password form beside the
magic-link one. It is off by default and the form says it is testing
only, so a build for real users never shows it unless someone sets the
flag on purpose.

Verified against the static build (nuxt generate, 5 routes, 524 KB): in
real Chrome the SPA loads, the form signs in, the app leaves /login and
the header shows the signed-in user, with no uncaught page errors.

Also: the Nitro build leaves apps/web/dist as a symlink to the output,
and the `dist/` ignore rule matches only directories, so it showed up as
untracked. The rule is now `dist`.
2026-10-29 10:12:19 +00:00
octoturge 6fab9072d4 Play cameras through the relay instead of straight at the gateway
The player posted its SDP offer to the gateway's go2rtc directly, which
cannot work from a browser on another origin: no CORS headers on any
go2rtc response, a preflight required because application/sdp is not
CORS-safelisted, and the password checked before CORS so that preflight
gets a 401. It now hands the offer to a `signal` function the app
supplies, which relays it through the backend.

Pinned to iceTransportPolicy "relay" whenever ICE servers are present,
so media always goes through the TURN relay and never peer-to-peer. A
hosted console cannot rely on reaching a customer's gateway directly,
and a camera that works from the workshop but not from home is worse
than one that behaves the same everywhere.

With no TURN configured it falls back to "all", which still works for a
viewer on the gateway's own network. Relay-only with no relay would
gather no candidates and fail every time, so the fallback is what makes
an unconfigured deployment degrade rather than break - and a failed
connection in that state now says so, since the browser reports nothing
but "failed" and the fix is a deployment setting.

It also waits for ICE gathering before signalling rather than trickling:
the answer comes back through a single request/response relay hop, so
there is no channel to trickle over afterwards and the offer has to
carry its candidates. Bounded at 3s, because gathering can hang on a
candidate that never resolves and signalling with what we have beats
waiting forever.

`signal` is a prop rather than a fetch in the component, like `stream`
already was: @continuum/ui stays plain Vue that knows nothing of the API,
which is what lets it render from a test or the desktop shell.
2026-10-19 00:33:18 +00:00
octoturge 1874bbe8da Record why the camera player cannot reach a gateway directly
Measured against go2rtc 1.9.14: it sends no Access-Control-Allow-Origin
on any response, auth or not; the SDP POST needs a preflight anyway
because application/sdp is not a CORS-safelisted Content-Type; and
go2rtc checks basic auth before CORS, so the preflight - which browsers
always send without credentials - gets a 401.

So this component has never worked from a browser on another origin, and
no credential handed to the frontend could fix it. The gateway's API now
requires a password, which closes the hole where anything on the
workshop LAN could watch, and changes the symptom here from a CORS error
to a 401. Both are the same missing piece: the relay.

Also stops the two most misleading errors in this path. A blocked
cross-origin request rejects as an opaque TypeError ("Failed to fetch")
that reads as the gateway being down, and a 401 reads as a wrong URL or
a missing camera. Both now say what they are.
2026-10-18 22:02:02 +00:00
octoturge 35f39ae502 Point the console at the port the backend actually listens on
apiBase and wsBase defaulted to localhost:3001; continuum-backend
listens on 3000 when PORT is unset, in both src/index.ts and its
.env.example. Starting both with no configuration therefore produced a
console whose every request was refused by a port with nothing behind
it - which presents as a broken API rather than a wrong address.
2026-10-18 19:54:48 +00:00
octoturge ba2eb4d379 Make the console speak the backend's telemetry protocol
The telemetry store and the printer card described a protocol no backend
ever spoke. The store expected `{ type, data: { printerId, progressPct,
state } }` with a closed five-value state enum; `/ws/telemetry` sends
`{ type: "printer.telemetry", printer: {...} }` with `id`,
`progressPercent` and an open `status`, and prefaces it with a `ready`
frame. The card also displayed `printer.model`, which the printer
resource has never had - it has `vendor`.

Authentication was the other half: the socket now connects with the
subprotocol list `["bearer", token]`, which is what the backend gates
the upgrade on, and never a query parameter. The token is read from the
session on each attempt, because supabase-js refreshes it hourly and a
reconnect replaying the first token would work right up until it
mattered. With no session it does not connect at all, and app.vue drives
connect/disconnect from the session rather than a one-shot onMounted.

Reconnection is now backed off from 1s to 30s instead of a flat 3s. A
browser cannot see why an upgrade failed - a rejected token and an
unreachable server are both just onerror then onclose - so a token the
server will never accept previously retried every 3s for as long as the
tab stayed open.

`disconnect()` no longer reconnects the socket it just closed, via a
`wanted` flag; handlers from a replaced socket are ignored.

Null is no longer rendered as zero. A claimed printer whose gateway has
never reported has no temperature, and 0 degrees is a different claim:
null readings stay out of the graph's history, the labels show a dash,
the progress bar is omitted rather than drawn empty, and a null status
reads "unknown" rather than the old "offline", which asserted something
nobody had observed. `lastSeenAt` is shown, since it is what separates
an idle printer from one nothing has heard from in an hour.

The card takes one `printer` prop instead of a row plus a `live`
overlay - the push and the REST row are the same shape by design, so
pages/index.vue replaces one with the other and @continuum/ui stays
plain Vue that knows nothing of stores or the API. Status styling now
covers all six values of the backend's printer_status enum; paused and
maintenance previously drew as ordinary text.

Types are hand-mirrored in packages/ui/src/types.ts from
continuum-schemas, the convention this project already uses across
TS and Rust. @continuum/ts-types is in another repository and is not
resolvable here; a `link:` out of the repo would break on any checkout
not laid out like this machine's. Field names are verified identical to
the schema's.

Typechecks clean (nuxt typecheck, vue-tsc). Not exercised at runtime;
the protocol it now implements is the one verified live by
continuum-backend's scripts/e2e-edge.sh.
2026-10-18 19:54:28 +00:00
octoturge a558156968 Send the Supabase token the app already had
Auth is Supabase end to end and always was: the backend verifies access
tokens asymmetrically against the project's published JWKS and holds no
signing key of its own, and `requireUser` wants that token in
`Authorization: Bearer`. Nothing about that needed changing.

What was wrong is only here. The two REST calls forwarded
`useRequestHeaders(["cookie"])`, which is what you do when a Nuxt server
is proxying a browser's request - and this app sets `ssr: false` so the
same static bundle can serve the browser and the Tauri shell. There is no
server to forward anything, so the header arrived empty and every call
should have been getting a flat 401.

The token was to hand the whole time. `stores/telemetry.ts` has been
reading `session.access_token` for its socket since it was written; the
REST side simply never did.

`useApiFetch` is now the one way this app calls the backend, and it reads
the token inside the fetcher rather than capturing it at setup, because
supabase-js refreshes the session in the background about once an hour - a
captured token would work until it quietly stopped. With no session it
omits the header entirely rather than sending an empty Bearer, which the
backend rejects with a message about the header's shape and reads like a
client bug; getting the user a session is the Supabase module's redirect's
job.

The socket keeps using a `?token=` query parameter, which is not an
inconsistency: a browser WebSocket cannot set request headers.

`nuxt typecheck` clean.
2026-10-15 23:21:01 +00:00
octoturge e928fcf658 Show a printer's camera, from the backend's address not a build-time one
`WebRTCStreamView` has existed since before this commit with no consumer:
nothing rendered it, and the URL it would have used came from a
`go2rtcStreamBase` in `nuxt.config.ts` defaulting to
`http://localhost:1984`.

That default was the real problem. A camera stream lives on a gateway box
inside one farm's network, each farm has its own, and the gateway now
reports its address to the backend when it connects. A compiled-in
constant could only ever be correct for a single-farm install, and its
localhost default pointed every player at the operator's own laptop. It
is gone, replaced by `fetchPrinterStream`, which asks
`GET /printers/:id/stream`.

Absence is the normal case here, not an error: only Bambu's X1/H2 class
publishes a camera URL a gateway can restream, so a P1 or A1 has a camera
and nothing to connect to. The backend 404s for those, this resolves to
null, and the card renders no player - offering one that can never load
would be worse than offering nothing. The lookup also runs only for
printers whose `cameraStream` the fleet list already reports, so a
twenty-printer farm does not make twenty requests to learn that eighteen
of them 404.

`PrinterCard` takes the stream as a prop rather than fetching it, like
`live` above it: `packages/ui` is plain Vue that knows nothing about the
API or about Nuxt, and that is what lets these components render from a
test or the desktop shell with no server behind them.

Three things in `WebRTCStreamView` that would have bitten whoever first
pointed it at a real printer:

- The fetch status was never checked. go2rtc answers a request for a
  stream it does not have with an error page, and handing that to
  `setRemoteDescription` throws about SDP parsing - which sends you
  looking at the wrong layer entirely.
- `connect()` is async inside a watcher with nothing catching it, so a
  failure left the badge on "connecting" forever and showed up only as an
  unhandled rejection in the console.
- Two prop changes in quick succession raced: the slower request could
  finish last and apply its answer to a connection already replaced and
  closed, leaving a black video with a "connected" badge.

No `iceServers`, deliberately and now commented: both ends are on the
same LAN, so host candidates are enough. The same fact is the MVP's
limitation - there is no relay, so this plays for someone on the
printers' network and for nobody else.

`nuxt typecheck` and `vue-tsc --noEmit` both clean.
2026-10-15 23:09:54 +00:00
octoturge 6d6ed62836 Declare proprietary licensing and enforce permissive-only dependencies
Continuum is intended to be sold, so every dependency has to permit
distribution in closed-source commercial software. Our own crate and npm
packages carried no licence field, which made `cargo deny` report the
Tauri crate as `unlicensed`.

The Nuxt/Tauri dependency graph audited clean (MIT/Apache/ISC/BSD, plus
BlueOak-1.0.0 - which is why this allow list carries BlueOak where
continuum-cad's did not). Two entries worth knowing, both recorded in
continuum-common's docs/LICENSES.md: `node-forge` is
`(BSD-3-Clause OR GPL-2.0)` and we elect BSD-3-Clause, and
`caniuse-lite` is CC-BY-4.0 build-time data that is not bundled into the
built SPA, so no attribution obligation today.
2026-10-03 10:10:15 +00:00
33 changed files with 2518 additions and 108 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
node_modules/
.nuxt/
.output/
dist/
dist
.env
.env.local
*.log
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@continuum/desktop",
"license": "UNLICENSED",
"private": true,
"version": "0.1.0",
"type": "module",
+4
View File
@@ -4,6 +4,10 @@ version = "0.1.0"
description = "Continuum operator console desktop shell"
edition = "2021"
rust-version = "1.77"
# Proprietary: deliberately not an SPDX id, so cargo-deny treats it as
# first-party. See docs/LICENSES.md in continuum-common.
license = "LicenseRef-Proprietary"
publish = false
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
+37 -2
View File
@@ -3,9 +3,17 @@ import { useTelemetryStore } from "~/stores/telemetry";
const telemetry = useTelemetryStore();
const user = useSupabaseUser();
const session = useSupabaseSession();
// Driven by the session rather than a one-shot onMounted: the session can
// arrive after mount, and sign-out should drop the socket instead of leaving
// it running on a token that is about to stop being valid.
onMounted(() => {
telemetry.connect();
watch(
() => session.value?.access_token,
(token) => (token ? telemetry.connect() : telemetry.disconnect()),
{ immediate: true },
);
});
onBeforeUnmount(() => {
@@ -17,8 +25,19 @@ onBeforeUnmount(() => {
<div class="app-shell">
<header class="app-shell__header">
<span class="app-shell__brand">Continuum</span>
<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'">
{{ telemetry.connected ? "Live" : "Reconnecting…" }}
{{
telemetry.link === "connected"
? "Live"
: telemetry.link === "connecting"
? "Connecting…"
: "Offline"
}}
</span>
<span v-if="user" class="app-shell__user">{{ user.email }}</span>
</header>
@@ -70,4 +89,20 @@ body {
.app-shell__main {
padding: 1.25rem;
}
.app-shell__nav {
display: flex;
gap: 0.75rem;
}
.app-shell__nav a {
color: inherit;
opacity: 0.7;
text-decoration: none;
}
.app-shell__nav a.router-link-exact-active {
opacity: 1;
text-decoration: underline;
}
</style>
+53
View File
@@ -0,0 +1,53 @@
/**
* The one way this app calls `continuum-backend`.
*
* Auth is Supabase throughout: the backend verifies access tokens
* asymmetrically against the project's published JWKS and holds no signing
* key of its own (`continuum-backend/src/auth/verifier.ts`), and
* `requireUser` wants that token in `Authorization: Bearer`. So this attaches
* it, and that is the whole of the client's side of authentication.
*
* What this replaces: `useRequestHeaders(["cookie"])` on each call site.
* Forwarding a cookie is what you do when a Nuxt *server* is proxying a
* browser's request, and this app sets `ssr: false` so that it can ship the
* same static bundle to the browser and to the Tauri shell - there is no
* server to forward anything, so the header arrived empty and every call got
* a 401. The token was already to hand the whole time; the telemetry store
* has been reading it for its socket.
*
* The token is read inside the fetcher rather than captured when this is
* called, because supabase-js refreshes the session in the background about
* once an hour. A token captured at setup would work until it quietly
* stopped.
*/
export function useApiFetch() {
const config = useRuntimeConfig();
const session = useSupabaseSession();
return <T>(path: string, options: Parameters<typeof $fetch>[1] = {}) => {
const token = session.value?.access_token;
return $fetch<T>(`${config.public.apiBase}${path}`, {
...options,
headers: {
// Omitted rather than sent empty when there is no session. An empty
// Bearer is a malformed credential the backend rejects with a
// message about the header's shape, which reads like a client bug;
// no header at all is the honest "not signed in". The Supabase
// module's own redirect is what handles getting the user a session.
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
};
}
/**
* A sentence for a failed API call. The backend's `{ error: { message } }`
* body is what explains a 403 (a viewer cannot do this), a 409 or a 502, so
* it is surfaced instead of a bare "fetch failed".
*/
export function apiErrorMessage(err: unknown, fallback: string): string {
const body = (err as { data?: { error?: { message?: string } } }).data;
return body?.error?.message ?? fallback;
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Where a printer's camera can be watched, from `GET /printers/:id/stream`.
*/
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;
/**
* ICE servers for the player's `RTCPeerConnection`, minted per request and
* short-lived. Empty when the backend has no TURN configured.
*/
iceServers: RTCIceServer[];
/**
* Whether TURN is configured at all, which is the difference between "no
* relay is needed here" and "the relay is missing". Empty `iceServers`
* with this true would mean minting silently failed.
*/
turnConfigured: boolean;
}
/**
* Asks the backend where a printer's camera can be watched, or `null` if it
* cannot.
*
* It returns no URL, and that is the point. A browser cannot reach a gateway
* directly: go2rtc sends no CORS headers and checks its API password before
* CORS, so an unauthenticated preflight gets a 401 - measured against
* 1.9.14 - and a gateway sits behind a workshop NAT anyway. This used to
* return the gateway's LAN address, which could only have worked for an
* operator standing on that same network.
*
* So what comes back is a camera name and the ICE servers to negotiate with.
* The offer goes to the backend, which relays it to the gateway over the
* socket the gateway already holds open; the media then travels
* browser-to-gateway through the TURN relay, DTLS-SRTP encrypted, so neither
* the backend nor the relay can read it.
*
* A plain function and not a `useAsyncData` wrapper despite the file it lives
* in: the caller needs one of these per printer, discovered from a list that
* itself arrives asynchronously, and Nuxt's data composables may only be
* called while a component is setting up. A loop over a resolved list is not
* that. It takes the fetcher as an argument for the same reason - the caller
* builds it in setup, where `useApiFetch` is valid.
*
* **Absence is the normal case, not an error.** Only Bambu's X1/H2 class
* publishes a camera URL a gateway can restream; a P1 or A1 reports that it
* has a camera and gives nothing to connect to. The backend answers 404 for
* those, so this answers `null` and the caller shows no player at all -
* rendering one that can never load would be worse than rendering nothing.
*
* The credentials are deliberately not cached: they are short-lived, so a
* player that reconnects should ask again rather than retry a session with a
* credential that has since expired.
*/
export async function fetchPrinterStream(
apiFetch: ReturnType<typeof useApiFetch>,
printerId: string,
): Promise<PrinterStream | null> {
try {
return await apiFetch<PrinterStream>(`/printers/${printerId}/stream`);
} catch (err: unknown) {
// A 404 means "this printer has no watchable camera", which is expected
// often enough that treating it as a failure would put an error state on
// most cards in a mixed fleet. Anything else is a real problem: logged
// and then treated as "no stream", because a broken camera lookup should
// not take the fleet overview down with it.
const status = (err as { statusCode?: number }).statusCode;
if (status !== 404) {
console.warn(`could not look up the camera stream for printer ${printerId}`, err);
}
return null;
}
}
+22 -4
View File
@@ -15,15 +15,33 @@ export default defineNuxtConfig({
redirectOptions: {
login: "/login",
callback: "/confirm",
exclude: ["/login"],
exclude: ["/login", "/invite"],
},
},
runtimeConfig: {
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3001",
wsBase: process.env.NUXT_PUBLIC_WS_BASE || "ws://localhost:3001",
go2rtcStreamBase: process.env.NUXT_PUBLIC_GO2RTC_STREAM_BASE || "http://localhost:1984",
// 3000, which is what continuum-backend listens on when PORT is unset
// (src/index.ts, and its .env.example). These defaulted to 3001, so a
// developer who started both with no configuration got a console that
// could not reach its own backend - every request refused by a port
// with nothing behind it, which reads like a broken API rather than a
// wrong address. Keep these in step with that default.
apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3000",
wsBase: process.env.NUXT_PUBLIC_WS_BASE || "ws://localhost:3000",
// Shows a password form on the login page. Off unless asked for: the
// real sign-in is a magic link, but that needs the Supabase project to
// send email, and the devstack's does not (`POST /auth/v1/otp` answers
// 500 "Error sending magic link email"), which left no way to sign in
// at all. A build meant for real users must not set this.
passwordLogin: process.env.NUXT_PUBLIC_PASSWORD_LOGIN === "true",
// There is deliberately no go2rtcStreamBase here any more. A camera
// stream lives on a gateway inside a particular farm's network, each
// farm has its own, and the gateway tells the backend its address when
// it connects - so a build-time constant could only ever be right for a
// single-farm install, and its localhost:1984 default meant the UI
// quietly pointed every player at the operator's own machine. Asked for
// per printer instead: see `usePrinterStream`.
},
},
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@continuum/web",
"license": "UNLICENSED",
"private": true,
"version": "0.1.0",
"type": "module",
+149 -13
View File
@@ -1,34 +1,170 @@
<script setup lang="ts">
import { PrinterCard } from "@continuum/ui";
import {
AlertList,
FarmStatus,
type AppAlert,
PrinterCard,
type AppFarm,
type AppFarmDiscovery,
type AppPrinterState,
type PrinterCommand,
} from "@continuum/ui";
import { useTelemetryStore } from "~/stores/telemetry";
interface Printer {
id: string;
name: string;
model: string;
farmId: string;
const telemetry = useTelemetryStore();
const apiFetch = useApiFetch();
const { data: printers } = await useAsyncData("printers", () =>
apiFetch<AppPrinterState[]>("/printers"),
);
// Which row to draw: the live push if there is one, else the fetched row.
//
// Merged here rather than passing `PrinterCard` a second `live` prop. A push
// is the same shape as a `GET /printers` row (that is the contract's whole
// point), so the pushed object simply *replaces* the fetched one - replaces,
// not spreads, because a push is the complete resource and a spread would let
// a field the push omitted survive from the older row. The page does this
// rather than the component because `@continuum/ui` is plain Vue that knows
// nothing of stores or the API, so it can render in a test or the Tauri shell
// with no server. Known edge: a push that predates a refetch can briefly
// shadow the fresher row; the next push corrects it.
// The REST list still decides *which* printers exist and in what order.
function current(printer: AppPrinterState): AppPrinterState {
return telemetry.printers[printer.id] ?? printer;
}
const telemetry = useTelemetryStore();
const config = useRuntimeConfig();
// Looked up per printer, because the answer is per printer, but only for the
// printers the list already says have a camera. Asking for all of them would
// mean one request per machine in the fleet, most of them 404 by design.
const streams = ref<Record<string, PrinterStream | null>>({});
const { data: printers } = await useFetch<Printer[]>(`${config.public.apiBase}/printers`, {
headers: useRequestHeaders(["cookie"]),
/**
* Relays one printer's WebRTC offer through the backend and returns the
* gateway's answer.
*
* Lives here rather than in `@continuum/ui` for the same reason the stream
* lookup does: that package is plain Vue that knows nothing of the API, so
* the app supplies the one function that talks to it.
*/
async function signalOffer(printerId: string, sdp: string): Promise<string> {
const answer = await apiFetch<{ sdp: string }>(`/printers/${printerId}/webrtc`, {
method: "POST",
body: { sdp },
});
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.
const { data: farms } = await useAsyncData("farms", () => apiFetch<AppFarm[]>("/farms"));
const farmStatus = ref<Record<string, AppFarmDiscovery>>({});
async function refreshFarmStatus() {
for (const farm of farms.value ?? []) {
try {
farmStatus.value[farm.id] = await apiFetch<AppFarmDiscovery>(`/farms/${farm.id}/discovered-printers`);
} catch {
// Keep the last answer rather than flip to "offline" over a failed poll.
}
}
}
// 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);
});
/**
* 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) {
throw new Error(apiErrorMessage(err, "the command could not be sent"));
}
}
watch(
printers,
(list) => {
for (const printer of list ?? []) {
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
// flight.
streams.value[printer.id] = null;
void fetchPrinterStream(apiFetch, printer.id).then((stream) => {
streams.value[printer.id] = stream;
});
}
},
{ immediate: true },
);
</script>
<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 ?? []"
:key="printer.id"
:printer="printer"
:live="telemetry.printers[printer.id]"
: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>
<p v-if="!printers?.length">No printers registered yet.</p>
<p v-if="!printers?.length">
<template v-if="telemetry.link !== 'connected' || telemetry.visiblePrinters === null">
No printers loaded yet.
</template>
<template v-else-if="telemetry.visiblePrinters === 0">No printers registered yet.</template>
<template v-else>Printers exist but none has been listed - try refreshing.</template>
</p>
</div>
</template>
+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>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { JobList, JobUpload, type AppFarm, type AppJob, type AppPrinterState } from "@continuum/ui";
const apiFetch = useApiFetch();
const { data: farms } = await useAsyncData("farms", () => apiFetch<AppFarm[]>("/farms"));
const { data: printers } = await useAsyncData("job-printers", () => apiFetch<AppPrinterState[]>("/printers"));
const { data: jobs, refresh } = await useAsyncData("jobs", () => apiFetch<AppJob[]>("/jobs"));
const printerNames = computed(() =>
Object.fromEntries((printers.value ?? []).map((printer) => [printer.id, printer.name])),
);
// There is no job push channel yet (the telemetry socket carries printers
// only), so a job moving from queued to printing shows up by polling. Five
// seconds matches the scheduler's own tick, which is the fastest it can move.
let poll: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
poll = setInterval(() => void refresh(), 5_000);
});
onBeforeUnmount(() => clearInterval(poll));
/**
* PUT with progress. `XMLHttpRequest` because `fetch` cannot report upload
* progress, and a sliced project is tens of megabytes. Plain request with no
* Authorization header on purpose: the URL is presigned, and an extra header
* the signature did not cover makes the storage server reject it.
*
* The Content-Type must be the one the URL was signed for.
*/
function putFile(url: string, file: File, contentType: string, onProgress: (percent: number) => void) {
return new Promise<void>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("PUT", url);
request.setRequestHeader("Content-Type", contentType);
request.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress((event.loaded / event.total) * 100);
};
request.onload = () =>
request.status >= 200 && request.status < 300
? resolve()
: reject(new Error(`storage refused the upload (HTTP ${request.status})`));
request.onerror = () => reject(new Error("could not reach storage - blocked, or offline"));
request.send(file);
});
}
async function uploadJob(farmId: string, file: File, onProgress: (percent: number) => void) {
const contentType = file.type || "application/octet-stream";
let created: { job: AppJob; uploadUrl: string };
try {
created = await apiFetch("/jobs", { method: "POST", body: { farmId, fileName: file.name, contentType } });
} catch (err) {
throw new Error(apiErrorMessage(err, "the job could not be created"));
}
try {
await putFile(created.uploadUrl, file, contentType, onProgress);
} catch (err) {
// The row exists before the file does, so a failed upload would leave a
// queued job whose file never arrives. Removed rather than left to sit.
await apiFetch(`/jobs/${created.job.id}`, { method: "DELETE" }).catch(() => undefined);
await refresh();
throw err;
}
await refresh();
}
async function removeJob(jobId: string) {
try {
await apiFetch(`/jobs/${jobId}`, { method: "DELETE" });
} catch (err) {
throw new Error(apiErrorMessage(err, "the job could not be deleted"));
}
await refresh();
}
</script>
<template>
<div class="jobs">
<h1>Jobs</h1>
<JobUpload :farms="farms ?? []" :upload="uploadJob" />
<JobList :jobs="jobs ?? []" :printer-names="printerNames" :remove="removeJob" />
</div>
</template>
<style scoped>
.jobs {
display: grid;
gap: 1.25rem;
}
</style>
+46 -1
View File
@@ -3,10 +3,38 @@ const email = ref("");
const sent = ref(false);
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;
const { error: authError } = await client.auth.signInWithPassword({
email: email.value,
password: password.value,
});
if (authError) {
error.value = authError.message;
return;
}
// The Supabase module redirects away from /login once a session exists.
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;
@@ -23,6 +51,12 @@ async function sendMagicLink() {
<button type="submit">Send magic link</button>
</form>
<p v-else>Check your inbox for a sign-in link.</p>
<form v-if="passwordLogin" class="login__password" @submit.prevent="signInWithPassword">
<p class="login__hint">Password sign-in is enabled for this build (testing only).</p>
<input v-model="email" type="email" placeholder="you@farm.com" required />
<input v-model="password" type="password" placeholder="password" required autocomplete="current-password" />
<button type="submit">Sign in</button>
</form>
<p v-if="error" class="login__error">{{ error }}</p>
</div>
</template>
@@ -36,6 +70,17 @@ async function sendMagicLink() {
gap: 0.75rem;
}
.login__password {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.login__hint {
font-size: 0.75rem;
opacity: 0.7;
}
.login__error {
color: #f2604c;
}
+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>
+115 -44
View File
@@ -1,83 +1,154 @@
import { defineStore } from "pinia";
import type { AppPrinterState, AppTelemetryMessage } from "@continuum/ui";
export interface PrinterTelemetry {
printerId: string;
farmId: string;
state: "idle" | "printing" | "paused" | "error" | "offline";
nozzleTempC: number;
bedTempC: number;
progressPct: number;
updatedAt: string;
}
/**
* Live printer state from `continuum-backend`'s `/ws/telemetry`.
*
* The message shape is the backend's, mirrored in `@continuum/ui`'s types
* from `continuum-schemas`. This file used to declare its own
* (`printerId`, `progressPct`, a closed state enum, a `data` envelope) and
* no backend ever sent it.
*/
export type TelemetryLink = "disconnected" | "connecting" | "connected";
interface TelemetryState {
socket: WebSocket | null;
connected: boolean;
printers: Record<string, PrinterTelemetry>;
link: TelemetryLink;
/**
* How many printers the server says this user can see, from its `ready`
* message; null until that arrives. This is what lets a view tell "not
* connected yet" (null) from "connected and there is genuinely nothing"
* (0) from "connected, printers exist, none has reported" (> 0 with an
* empty `printers`).
*/
visiblePrinters: number | null;
/** Latest push per printer, keyed by `printer.id`. */
printers: Record<string, AppPrinterState>;
}
const RECONNECT_DELAY_MS = 3_000;
/**
* Backoff, not a fixed interval, and the reason is that a browser cannot see
* why an upgrade failed. A rejected credential and an unreachable server look
* identical from here - no status code, just `onerror` then `onclose` - so a
* socket whose token the server will never accept would otherwise reconnect
* every 3s for as long as the tab is open. Capped, because the other failure
* is a server that is merely restarting and should be picked up promptly.
*/
const RECONNECT_DELAY_MS = 1_000;
const RECONNECT_MAX_MS = 30_000;
let retryDelay = RECONNECT_DELAY_MS;
// Held outside the reactive state: a WebSocket and a timer handle have no
// business being wrapped in proxies or serialised by devtools.
let socket: WebSocket | null = null;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
// Whether anyone wants a connection. Without it `disconnect()` would close
// the socket and the close handler would cheerfully reconnect it.
let wanted = false;
export const useTelemetryStore = defineStore("telemetry", {
state: (): TelemetryState => ({
socket: null,
connected: false,
link: "disconnected",
visiblePrinters: null,
printers: {},
}),
getters: {
connected: (state) => state.link === "connected",
printerList: (state) => Object.values(state.printers),
},
actions: {
connect() {
if (this.socket) return;
wanted = true;
if (socket) return;
const config = useRuntimeConfig();
const client = useSupabaseClient();
// Read here, at every attempt, never captured by the store: supabase-js
// refreshes the session about hourly (see `useApiFetch`), so a
// reconnect that replayed the first connection's token would work
// until the first time it was needed.
const token = useSupabaseSession().value?.access_token;
client.auth.getSession().then(({ data }) => {
const token = data.session?.access_token ?? "";
const url = `${config.public.wsBase}/ws/telemetry?token=${encodeURIComponent(token)}`;
this.open(url);
});
},
// No session means no connection, rather than an empty credential the
// server can only reject. Deliberately no retry timer either: the
// caller connects when a session appears (app.vue watches for it), and
// polling for a sign-in would be a loop with nothing to wait on.
if (!token) {
this.link = "disconnected";
return;
}
open(url: string) {
const socket = new WebSocket(url);
const { wsBase } = useRuntimeConfig().public;
socket.onopen = () => {
this.connected = true;
// The token travels in the WebSocket subprotocol list, not the URL: a
// bearer credential in a query string lands in access logs, proxy
// logs and error breadcrumbs. "bearer" must be first because the
// server echoes back the first offered protocol, and echoing the token
// would put it in a response header.
const ws = new WebSocket(`${wsBase}/ws/telemetry`, ["bearer", token]);
socket = ws;
this.link = "connecting";
ws.onopen = () => {
if (socket !== ws) return;
this.link = "connected";
// Reset only on a socket that actually opened. Resetting in
// `connect()` would make every backoff step start over, and the
// delay would never grow past the first one.
retryDelay = RECONNECT_DELAY_MS;
};
socket.onmessage = (event) => {
ws.onmessage = (event) => {
if (socket !== ws) return;
let message: AppTelemetryMessage;
try {
const payload = JSON.parse(event.data) as { type: string; data: PrinterTelemetry };
if (payload.type === "printer.telemetry") {
this.printers[payload.data.printerId] = payload.data;
}
message = JSON.parse(event.data) as AppTelemetryMessage;
} catch {
// Ignore malformed frames rather than crashing the UI.
return;
}
// Unknown `type`s fall through and are ignored, so the backend can
// add a message kind without breaking consoles that predate it.
if (message.type === "ready") {
this.visiblePrinters = message.visiblePrinters;
} else if (message.type === "printer.telemetry") {
this.printers[message.printer.id] = message.printer;
}
};
socket.onclose = () => {
this.connected = false;
this.socket = null;
setTimeout(() => this.connect(), RECONNECT_DELAY_MS);
ws.onclose = () => {
// A socket we already replaced or abandoned must not clobber the
// state of its successor.
if (socket !== ws) return;
socket = null;
this.link = "disconnected";
// `printers` is kept across the gap: a stale reading beats a blank
// card, and `lastSeenAt` on the row says how stale. `visiblePrinters`
// is reset because the next `ready` re-states it.
this.visiblePrinters = null;
if (wanted) {
retryTimer = setTimeout(() => this.connect(), retryDelay);
retryDelay = Math.min(retryDelay * 2, RECONNECT_MAX_MS);
}
};
socket.onerror = () => {
socket.close();
ws.onerror = () => {
ws.close();
};
this.socket = socket;
},
disconnect() {
this.socket?.close();
this.socket = null;
this.connected = false;
wanted = false;
clearTimeout(retryTimer);
// A deliberate disconnect is not a failure: signing back in should
// connect at once rather than inheriting the previous session's
// accumulated delay.
retryDelay = RECONNECT_DELAY_MS;
const ws = socket;
socket = null;
ws?.close();
this.link = "disconnected";
this.visiblePrinters = null;
},
},
});
+36
View File
@@ -0,0 +1,36 @@
# Licence policy for a closed-source commercial product.
# See docs/LICENSES.md in continuum-common for the rationale and the
# non-cargo dependencies (external binaries, ML models) this file cannot see.
#
# Run: cargo deny check licenses
[licenses]
# Permissive only. Anything copyleft reaching a shipped binary would make
# the product undistributable without releasing our source.
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"Unicode-3.0",
"Unlicense",
"MPL-2.0",
"CC0-1.0",
"BlueOak-1.0.0",
]
confidence-threshold = 0.9
# Our own crates declare `LicenseRef-Proprietary`, which is deliberately not
# an SPDX identifier and must never appear in the allow list above - a
# third-party crate claiming it would be a real finding. They are recognised
# as first-party by `publish = false` in their manifests.
private = { ignore = true }
[bans]
deny = []
[advisories]
yanked = "deny"
+1
View File
@@ -1,5 +1,6 @@
{
"name": "continuum-app",
"license": "UNLICENSED",
"private": true,
"version": "0.1.0",
"description": "Continuum print farm operator console - Nuxt SPA + Tauri 2 desktop shell",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "@continuum/ui",
"license": "UNLICENSED",
"private": true,
"version": "0.1.0",
"type": "module",
+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>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from "vue";
import type { AppFarm, AppFarmDiscovery } from "../types";
const props = defineProps<{ farm: AppFarm; status: AppFarmDiscovery | null }>();
// Three honest answers, not two: no answer yet is not "offline".
const gateway = computed(() =>
props.status === null ? "checking" : props.status.gatewayOnline ? "online" : "offline",
);
const candidates = computed(() => props.status?.discovery?.printers ?? []);
const discoveryNote = computed(() => {
if (!props.status) return "";
if (props.status.discovery === null)
return "Discovery has not reported - it is off by default on a gateway.";
if (!candidates.value.length)
return `Scanned ${new Date(props.status.discovery.scannedAt).toLocaleString()}: nothing found.`;
return `Scanned ${new Date(props.status.discovery.scannedAt).toLocaleString()}.`;
});
const label = (c: { name?: string | null; model?: string | null; vendor: string }) =>
[c.name, c.model].filter(Boolean).join(" / ") || c.vendor;
</script>
<template>
<section class="farm-status" :aria-label="`Farm ${farm.name}`">
<span class="farm-status__name">{{ farm.name }}</span>
<span class="farm-status__gateway" :class="`is-${gateway}`">Gateway {{ gateway }}</span>
<details v-if="status" class="farm-status__discovery">
<summary>Discovered printers ({{ candidates.length }})</summary>
<p>{{ discoveryNote }}</p>
<ul v-if="candidates.length">
<li v-for="c in candidates" :key="c.address + (c.serial ?? '')">
<strong>{{ label(c) }}</strong> - {{ c.vendor }} at {{ c.address }}
<template v-if="c.matchedOn === 'serial'"> - already added</template>
<template v-else-if="c.matchedOn === 'address'"> - same address as an added printer (check)</template>
<template v-else> - not added yet</template>
</li>
</ul>
</details>
</section>
</template>
<style scoped>
.farm-status {
display: flex;
gap: 1rem;
align-items: baseline;
flex-wrap: wrap;
font-size: 0.9rem;
}
.farm-status__name {
font-weight: 600;
}
.farm-status__gateway.is-online {
color: #35d07f;
}
.farm-status__gateway.is-offline {
color: #f2604c;
}
.farm-status__gateway.is-checking {
opacity: 0.6;
}
.farm-status__discovery ul {
margin: 0.25rem 0 0;
padding-left: 1.25rem;
}
</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>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { ref } from "vue";
import type { AppJob } from "../types";
const props = defineProps<{
jobs: AppJob[];
/** printer id -> display name, for the jobs already placed on one. */
printerNames: Record<string, string>;
/** Deletes a job that has not started; rejects with a message fit to show. */
remove: (jobId: string) => Promise<void>;
}>();
const error = ref<string | null>(null);
const removing = ref<string | null>(null);
// Only these can be deleted: the backend refuses anything that has reached a
// printer, because stopping a running print is a command, not a row delete.
const deletable = (job: AppJob) => job.status === "queued" || job.status === "uploading";
async function del(job: AppJob) {
removing.value = job.id;
error.value = null;
try {
await props.remove(job.id);
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
removing.value = null;
}
}
const statusClass = (status: string) => `is-${status.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
const when = (iso: string) => new Date(iso).toLocaleString();
</script>
<template>
<div class="job-list">
<p v-if="error" class="job-list__error" role="alert">{{ error }}</p>
<table v-if="jobs.length">
<thead>
<tr>
<th>File</th>
<th>Status</th>
<th>Printer</th>
<th>Queued</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="job in jobs" :key="job.id">
<td>{{ job.fileName }}</td>
<td :class="['job-list__status', statusClass(job.status)]">
{{ job.status }}
<template v-if="job.status === 'printing' && job.progressPct != null"> ({{ Math.round(job.progressPct) }}%)</template>
</td>
<td>{{ job.printerId ? (printerNames[job.printerId] ?? "unknown printer") : "-" }}</td>
<td>{{ when(job.createdAt) }}</td>
<td>
<button v-if="deletable(job)" type="button" :disabled="removing === job.id" @click="del(job)">
Delete
</button>
</td>
</tr>
</tbody>
</table>
<p v-else>No jobs yet.</p>
</div>
</template>
<style scoped>
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid #1f232c;
}
.job-list__error {
color: #f2604c;
}
.job-list__status.is-printing,
.job-list__status.is-completed {
color: #35d07f;
}
.job-list__status.is-paused {
color: #f0b429;
}
.job-list__status.is-failed {
color: #f2604c;
}
.job-list__status.is-cancelled {
color: #6b7280;
}
</style>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { AppFarm } from "../types";
const props = defineProps<{
farms: AppFarm[];
/**
* Creates the job, uploads the file and resolves when both are done, or
* rejects with a message fit to show. `onProgress` is 0-100 for the upload.
* Supplied by the app, like `send` on the printer card: this package knows
* nothing of the API.
*/
upload: (farmId: string, file: File, onProgress: (percent: number) => void) => Promise<void>;
}>();
const farmId = ref<string>("");
const file = ref<File | null>(null);
const busy = ref(false);
const percent = ref(0);
const notice = ref<{ kind: "ok" | "error"; text: string } | null>(null);
const input = ref<HTMLInputElement | null>(null);
// One farm is the common case; picking it for the user is the right default,
// and a select with a single option is noise.
const chosenFarm = computed(() => farmId.value || props.farms[0]?.id || "");
function onPick(event: Event) {
file.value = (event.target as HTMLInputElement).files?.[0] ?? null;
notice.value = null;
}
async function submit() {
if (!file.value || !chosenFarm.value) return;
const sending = file.value;
busy.value = true;
percent.value = 0;
notice.value = null;
try {
await props.upload(chosenFarm.value, sending, (p) => (percent.value = p));
notice.value = {
kind: "ok",
text: `${sending.name} is queued. It starts on the next idle printer.`,
};
file.value = null;
if (input.value) input.value.value = "";
} catch (err) {
notice.value = { kind: "error", text: err instanceof Error ? err.message : String(err) };
} finally {
busy.value = false;
}
}
</script>
<template>
<form class="job-upload" @submit.prevent="submit">
<label v-if="farms.length > 1">
Farm
<select v-model="farmId" :disabled="busy">
<option v-for="farm in farms" :key="farm.id" :value="farm.id">{{ farm.name }}</option>
</select>
</label>
<input
ref="input"
type="file"
accept=".3mf,.gcode,.gcode.3mf"
:disabled="busy"
@change="onPick"
/>
<button type="submit" :disabled="busy || !file || !chosenFarm">Upload and queue</button>
<progress v-if="busy" :value="percent" max="100" />
<p v-if="notice" class="job-upload__notice" :class="`is-${notice.kind}`" :role="notice.kind === 'error' ? 'alert' : 'status'">
{{ notice.text }}
</p>
<p v-if="!farms.length" class="job-upload__notice">No farm to queue jobs on yet.</p>
</form>
</template>
<style scoped>
.job-upload {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.job-upload progress {
width: 12rem;
}
.job-upload__notice {
flex-basis: 100%;
margin: 0;
}
.job-upload__notice.is-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>
+115 -25
View File
@@ -1,45 +1,118 @@
<script setup lang="ts">
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";
interface Printer {
id: string;
name: string;
model: string;
farmId: string;
}
interface LiveTelemetry {
state: "idle" | "printing" | "paused" | "error" | "offline";
nozzleTempC: number;
bedTempC: number;
progressPct: number;
}
/**
* Where this printer's camera can be watched, from
* `GET /printers/:id/stream`.
*
* Passed in rather than fetched here, matching `printer`: this package is
* plain Vue with no knowledge of the API or of Nuxt, and keeping the fetching
* in the app is what lets these components be rendered from a test or the
* desktop shell without a server behind them.
*
* Undefined for most printers and that is expected - see
* `usePrinterStream`. The player is simply not rendered.
*/
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<{
printer: Printer;
live?: LiveTelemetry;
/**
* The printer as it should be drawn right now. One prop, not a fetched row
* plus a `live` overlay: the REST row and the websocket push are the same
* shape by design, so the page replaces the one with the other before
* handing it down. That keeps this package ignorant of stores and the API,
* which is what lets it render from a test or the Tauri shell with no
* server - and means there is no second place to decide which of two
* values wins.
*/
printer: AppPrinterState;
stream?: PrinterStream | null;
/**
* Exchanges an offer SDP for an answer SDP, via the backend's relay.
*
* Passed in rather than called here, like `stream` itself: this package is
* plain Vue with no knowledge of the API or of Nuxt, which is what lets
* these components render from a test or the desktop shell with no server.
* 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>;
/** Fetches one camera frame, for a `kind: "snapshot"` stream. */
fetchFrame?: () => Promise<Blob>;
}>();
const state = computed(() => props.live?.state ?? "offline");
// `status` is open-ended and null for a printer that has never reported.
// "unknown" is the honest word for that; the old fallback of "offline"
// asserted something nobody had observed.
const status = computed(() => props.printer.status ?? "unknown");
// Class-safe form of an arbitrary backend string.
const statusClass = computed(() => `is-${status.value.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`);
const hasTemperature = computed(
() => props.printer.nozzleTempC != null || props.printer.bedTempC != null,
);
// `lastSeenAt` is what separates "idle" from "nobody has heard from it in an
// hour", which `status` alone cannot say. Shown as an absolute time rather
// than "5 minutes ago": a relative label needs a ticking clock to stay true,
// and a card that says "just now" for an hour is worse than no label.
const lastSeen = computed(() => {
const at = props.printer.lastSeenAt;
return at ? new Date(at).toLocaleString() : null;
});
</script>
<template>
<article class="printer-card" :class="`is-${state}`">
<article class="printer-card" :class="statusClass">
<header>
<h3>{{ printer.name }}</h3>
<span class="printer-card__model">{{ printer.model }}</span>
<span class="printer-card__model">{{ printer.vendor }}</span>
</header>
<p class="printer-card__state">{{ state }}</p>
<p class="printer-card__state">
{{ status }}
<span v-if="printer.vendorState" class="printer-card__model">({{ printer.vendorState }})</span>
</p>
<p class="printer-card__model">
{{ lastSeen ? `Last heard from ${lastSeen}` : "Never reported" }}
</p>
<TemperatureGraph
v-if="live"
:nozzle-temp-c="live.nozzleTempC"
:bed-temp-c="live.bedTempC"
<SnapshotView
v-if="stream && stream.kind === 'snapshot' && fetchFrame"
:fetch-frame="fetchFrame"
:interval-ms="stream.intervalMs"
/>
<WebRTCStreamView
v-else-if="stream && stream.kind !== 'snapshot' && signal"
:camera-id="stream.cameraId"
:ice-servers="stream.iceServers"
:turn-configured="stream.turnConfigured"
:signal="signal"
/>
<progress v-if="live" :value="live.progressPct" max="100" />
<TemperatureGraph
v-if="hasTemperature"
:nozzle-temp-c="printer.nozzleTempC"
:bed-temp-c="printer.bedTempC"
/>
<!-- 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>
@@ -56,6 +129,14 @@ const state = computed(() => props.live?.state ?? "offline");
font-size: 0.8rem;
}
/*
* One rule per value of the backend's `printer_status` enum - offline, idle,
* printing, paused, error, maintenance - plus the "unknown" this card
* substitutes for a null status. The enum was only two thirds covered, so a
* paused printer and one in maintenance drew in the default colour and read
* as ordinary. `status` is not a closed enum on the wire, so anything new
* still falls back to that default rather than breaking.
*/
.printer-card.is-printing .printer-card__state {
color: #35d07f;
}
@@ -64,7 +145,16 @@ const state = computed(() => props.live?.state ?? "offline");
color: #f2604c;
}
.printer-card.is-offline .printer-card__state {
.printer-card.is-paused .printer-card__state {
color: #f0b429;
}
.printer-card.is-maintenance .printer-card__state {
color: #9f7aea;
}
.printer-card.is-offline .printer-card__state,
.printer-card.is-unknown .printer-card__state {
color: #6b7280;
}
@@ -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>
+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>
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
// Null or undefined means "no reading", which is not 0: a printer whose
// gateway has never reported has no temperature. Such a value is never
// pushed into the history (it would draw as a real cold sample) and its
// label shows a dash.
const props = defineProps<{
nozzleTempC: number;
bedTempC: number;
nozzleTempC?: number | null;
bedTempC?: number | null;
maxSamples?: number;
}>();
@@ -14,8 +18,8 @@ const bedHistory = ref<number[]>([]);
watch(
() => [props.nozzleTempC, props.bedTempC] as const,
([nozzle, bed]) => {
nozzleHistory.value = [...nozzleHistory.value, nozzle].slice(-MAX_SAMPLES);
bedHistory.value = [...bedHistory.value, bed].slice(-MAX_SAMPLES);
if (nozzle != null) nozzleHistory.value = [...nozzleHistory.value, nozzle].slice(-MAX_SAMPLES);
if (bed != null) bedHistory.value = [...bedHistory.value, bed].slice(-MAX_SAMPLES);
},
{ immediate: true },
);
@@ -45,8 +49,8 @@ onBeforeUnmount(() => {
<polyline class="temp-graph__bed" :points="bedPoints" fill="none" />
</svg>
<div class="temp-graph__labels">
<span>Nozzle {{ nozzleTempC.toFixed(0) }}°C</span>
<span>Bed {{ bedTempC.toFixed(0) }}°C</span>
<span>Nozzle {{ nozzleTempC == null ? "—" : `${nozzleTempC.toFixed(0)}°C` }}</span>
<span>Bed {{ bedTempC == null ? "—" : `${bedTempC.toFixed(0)}°C` }}</span>
</div>
</div>
</template>
+124 -12
View File
@@ -2,19 +2,74 @@
import { onBeforeUnmount, ref, watch } from "vue";
const props = defineProps<{
streamBaseUrl: string;
/** The go2rtc stream name to negotiate for, from `GET /printers/:id/stream`. */
cameraId: string;
/**
* ICE servers for this session, minted per request by the backend and
* short-lived. Empty means TURN is not configured.
*/
iceServers: RTCIceServer[];
/**
* Whether the backend has TURN configured at all. Empty `iceServers` with
* this true means minting failed, which is a fault; empty with this false
* is a deployment that has not set it up, which is merely a limit.
*/
turnConfigured: boolean;
/**
* Exchanges an offer SDP for an answer SDP. Supplied by the app, because
* this package knows nothing of the API or of Nuxt - which is what lets it
* render from a test or the desktop shell with no server behind it.
*/
signal: (offerSdp: string) => Promise<string>;
}>();
const videoEl = ref<HTMLVideoElement | null>(null);
const pc = ref<RTCPeerConnection | null>(null);
const status = ref<"connecting" | "connected" | "failed">("connecting");
const error = ref<string | null>(null);
/**
* Opens a WebRTC session with the printer's gateway, signalling through the
* backend.
*
* ## Why the backend is in the middle
*
* A browser cannot signal to a gateway's go2rtc directly, and that is
* measured against go2rtc 1.9.14 rather than assumed: it sends no
* `Access-Control-Allow-Origin` on any response, the SDP `POST` needs a
* preflight anyway because `application/sdp` is not a CORS-safelisted
* content type, and it checks its API password *before* CORS - so the
* preflight, which a browser always sends without credentials, gets a 401.
* Handing the gateway's password to the frontend could not fix that. A
* gateway is also behind a workshop NAT, so nothing can dial in to it.
*
* The backend relays only the SDP exchange, over the socket the gateway
* already holds open. Media never passes through it.
*
* ## Why relay-only when TURN is configured
*
* Pinned to `iceTransportPolicy: "relay"`, so media always travels through
* the TURN relay and never peer-to-peer. That is deliberate: a hosted
* console cannot rely on reaching a customer's gateway directly, and a
* feature that works from the workshop but not from home is worse than one
* that behaves the same everywhere. It is DTLS-SRTP encrypted end to end, so
* the relay forwards packets it cannot decrypt.
*
* With no TURN configured it falls back to `"all"`, which still works for a
* viewer on the gateway's own LAN via host candidates. Relay-only without a
* relay would gather no candidates at all and fail every time, so the
* fallback is what makes an unconfigured deployment degrade instead of
* break.
*/
async function connect() {
status.value = "connecting";
error.value = null;
pc.value?.close();
const connection = new RTCPeerConnection();
const connection = new RTCPeerConnection({
iceServers: props.iceServers,
iceTransportPolicy: props.iceServers.length > 0 ? "relay" : "all",
});
pc.value = connection;
connection.addTransceiver("video", { direction: "recvonly" });
@@ -23,24 +78,79 @@ async function connect() {
};
connection.onconnectionstatechange = () => {
if (connection.connectionState === "connected") status.value = "connected";
if (connection.connectionState === "failed") status.value = "failed";
if (connection.connectionState === "failed") {
status.value = "failed";
// ICE failure has one overwhelmingly likely cause when there is no
// relay: the viewer is not on the gateway's network, and host
// candidates were the only ones available. Worth saying, because the
// browser reports nothing more than "failed" and the fix is a
// deployment setting rather than anything the viewer can do.
error.value = props.turnConfigured
? "could not establish a media connection to the gateway"
: "no TURN relay is configured, so this camera can only be watched from the " +
"printers' own network";
}
};
const offer = await connection.createOffer();
await connection.setLocalDescription(offer);
// go2rtc's WebRTC WHEP-style signaling endpoint.
const response = await fetch(`${props.streamBaseUrl}/api/webrtc?src=${props.cameraId}`, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: offer.sdp,
});
// Waits for ICE gathering before signalling, rather than trickling.
// go2rtc's answer comes back through one request/response relay hop, so
// there is no channel to trickle candidates over afterwards - the offer
// has to carry them all.
await gathered(connection);
if (pc.value !== connection) return;
const answerSdp = await props.signal(connection.localDescription?.sdp ?? offer.sdp ?? "");
// The props can change again while the exchange above is in flight, which
// starts a second connect(). Without this check the slower of the two
// finishes last and applies its answer to a connection that has been
// replaced and closed, leaving a black video element and a "connected"
// badge.
if (pc.value !== connection) return;
const answerSdp = await response.text();
await connection.setRemoteDescription({ type: "answer", sdp: answerSdp });
}
watch(() => [props.streamBaseUrl, props.cameraId], connect, { immediate: true });
/**
* Resolves once ICE gathering finishes, or after a short grace period.
*
* Bounded because gathering can hang on a candidate that never resolves, and
* a player that waits forever is worse than one that signals with the
* candidates it already has - which, relay-only, is usually the one that
* matters.
*/
function gathered(connection: RTCPeerConnection): Promise<void> {
if (connection.iceGatheringState === "complete") return Promise.resolve();
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer);
connection.removeEventListener("icegatheringstatechange", onChange);
resolve();
};
const onChange = () => {
if (connection.iceGatheringState === "complete") done();
};
const timer = setTimeout(done, 3_000);
connection.addEventListener("icegatheringstatechange", onChange);
});
}
// `connect` is async inside a watcher, so nothing would otherwise catch a
// rejection: the status would sit on "connecting" forever and the only sign
// of the failure would be an unhandled rejection in the console.
watch(
() => [props.cameraId, props.iceServers],
() => {
connect().catch((err: unknown) => {
status.value = "failed";
error.value = err instanceof Error ? err.message : String(err);
});
},
{ immediate: true },
);
onBeforeUnmount(() => {
pc.value?.close();
@@ -51,7 +161,9 @@ onBeforeUnmount(() => {
<template>
<div class="webrtc-view">
<video ref="videoEl" autoplay playsinline muted />
<span class="webrtc-view__status" :class="`is-${status}`">{{ status }}</span>
<span class="webrtc-view__status" :class="`is-${status}`" :title="error ?? undefined">
{{ status }}
</span>
</div>
</template>
+12
View File
@@ -1,3 +1,15 @@
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 { AppMember, AppInvitation, CreatedInvitation, EnrolmentCode, OrgRole, PrinterKind, NewPrinter, AppOrg, AppAlert, AppDiscoveredCandidate, AppFarm, AppFarmDiscovery, AppJob, AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types";
+172
View File
@@ -0,0 +1,172 @@
/**
* A printer as the console displays it - the shape `GET /printers/:id`
* returns and the shape `/ws/telemetry` pushes, which are deliberately the
* same so a push can replace a fetched row outright.
*
* Hand-mirrored from `continuum-schemas`
* (`schemas/continuum/v1/app/telemetry_message.schema.json`, typed in
* `packages/ts-types/src/schemas/app.ts`). The schema is canonical: change
* it first and then this. It is not imported from `@continuum/ts-types`
* because that package lives in another repository and is not published
* anywhere this workspace can resolve it; a relative `link:` out of the repo
* would break on any checkout that is not laid out exactly like this
* machine's. Types only, so drift is a copy-paste away from fixed.
*
* Every field after `vendor` is optional AND nullable, and that is
* load-bearing: a claimed printer whose gateway has never reported has no
* temperature, and "no data yet" is not 0 degrees. Never render these with
* `?? 0`.
*/
export interface AppPrinterState {
id: string;
farmId: string;
name: string;
vendor: string;
/** The backend's own narrowed status, which the UI filters and groups on. */
status?: string | null;
/** The printer's own word (`RUNNING`, `PREPARE`...). Not an enum on purpose. */
vendorState?: string | null;
vendorErrorCode?: number | null;
nozzleTempC?: number | null;
nozzleTargetTempC?: number | null;
bedTempC?: number | null;
bedTargetTempC?: number | null;
progressPercent?: number | null;
currentLayer?: number | null;
totalLayers?: number | null;
remainingMinutes?: number | null;
currentJobName?: string | null;
/** A count of active HMS entries, not messages. */
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;
}
/** What `/ws/telemetry` sends, discriminated on `type`. */
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 };
/** A farm as `GET /farms` returns it (the fields the console uses). */
export interface AppFarm {
id: string;
name: string;
}
/**
* A print job as `GET /jobs` returns it. `status` is the backend's
* `print_job_status` enum but typed open, like `AppPrinterState.status`: an
* unknown value renders in the default colour rather than breaking.
*/
export interface AppJob {
id: string;
farmId: string;
printerId?: string | null;
fileName: string;
status: string;
progressPct?: number | null;
createdAt: string;
}
/**
* `GET /farms/:id/discovered-printers`. `discovery` is null when the gateway
* has never reported a scan - almost always because discovery is off, which
* is its default - and that is not the same as a report with no printers.
*/
export interface AppDiscoveredCandidate {
address: string;
vendor: string;
name?: string | null;
model?: string | null;
serial?: string | null;
/** The printer row this candidate looks like, and how sure that is. */
existingPrinterId?: string | null;
matchedOn?: "serial" | "address" | null;
}
export interface AppFarmDiscovery {
farmId: string;
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;
}