Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
571a2c0ca7
|
||
|
|
c7f85870db
|
||
|
|
8bf56d1325
|
||
|
|
4519c3ba50
|
||
|
|
6fab9072d4
|
||
|
|
1874bbe8da
|
||
|
|
35f39ae502
|
||
|
|
ba2eb4d379
|
||
|
|
a558156968
|
||
|
|
e928fcf658
|
||
|
|
6d6ed62836
|
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.nuxt/
|
.nuxt/
|
||||||
.output/
|
.output/
|
||||||
dist/
|
dist
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@continuum/desktop",
|
"name": "@continuum/desktop",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ version = "0.1.0"
|
|||||||
description = "Continuum operator console desktop shell"
|
description = "Continuum operator console desktop shell"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77"
|
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]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.0", features = [] }
|
tauri-build = { version = "2.0", features = [] }
|
||||||
|
|||||||
+36
-2
@@ -3,9 +3,17 @@ import { useTelemetryStore } from "~/stores/telemetry";
|
|||||||
|
|
||||||
const telemetry = useTelemetryStore();
|
const telemetry = useTelemetryStore();
|
||||||
const user = useSupabaseUser();
|
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(() => {
|
onMounted(() => {
|
||||||
telemetry.connect();
|
watch(
|
||||||
|
() => session.value?.access_token,
|
||||||
|
(token) => (token ? telemetry.connect() : telemetry.disconnect()),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -17,8 +25,18 @@ onBeforeUnmount(() => {
|
|||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="app-shell__header">
|
<header class="app-shell__header">
|
||||||
<span class="app-shell__brand">Continuum</span>
|
<span class="app-shell__brand">Continuum</span>
|
||||||
|
<nav class="app-shell__nav">
|
||||||
|
<NuxtLink to="/">Fleet</NuxtLink>
|
||||||
|
<NuxtLink to="/jobs">Jobs</NuxtLink>
|
||||||
|
</nav>
|
||||||
<span class="app-shell__status" :class="telemetry.connected ? 'is-online' : 'is-offline'">
|
<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>
|
||||||
<span v-if="user" class="app-shell__user">{{ user.email }}</span>
|
<span v-if="user" class="app-shell__user">{{ user.email }}</span>
|
||||||
</header>
|
</header>
|
||||||
@@ -70,4 +88,20 @@ body {
|
|||||||
.app-shell__main {
|
.app-shell__main {
|
||||||
padding: 1.25rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Where a printer's camera can be watched, from `GET /printers/:id/stream`.
|
||||||
|
*/
|
||||||
|
export interface PrinterStream {
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-3
@@ -21,9 +21,27 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3001",
|
// 3000, which is what continuum-backend listens on when PORT is unset
|
||||||
wsBase: process.env.NUXT_PUBLIC_WS_BASE || "ws://localhost:3001",
|
// (src/index.ts, and its .env.example). These defaulted to 3001, so a
|
||||||
go2rtcStreamBase: process.env.NUXT_PUBLIC_GO2RTC_STREAM_BASE || "http://localhost:1984",
|
// 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,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@continuum/web",
|
"name": "@continuum/web",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+116
-13
@@ -1,34 +1,137 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PrinterCard } from "@continuum/ui";
|
import {
|
||||||
|
FarmStatus,
|
||||||
|
PrinterCard,
|
||||||
|
type AppFarm,
|
||||||
|
type AppFarmDiscovery,
|
||||||
|
type AppPrinterState,
|
||||||
|
type PrinterCommand,
|
||||||
|
} from "@continuum/ui";
|
||||||
import { useTelemetryStore } from "~/stores/telemetry";
|
import { useTelemetryStore } from "~/stores/telemetry";
|
||||||
|
|
||||||
interface Printer {
|
const telemetry = useTelemetryStore();
|
||||||
id: string;
|
|
||||||
name: string;
|
const apiFetch = useApiFetch();
|
||||||
model: string;
|
|
||||||
farmId: string;
|
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();
|
// Looked up per printer, because the answer is per printer, but only for the
|
||||||
const config = useRuntimeConfig();
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let farmPoll: ReturnType<typeof setInterval> | undefined;
|
||||||
|
onMounted(() => {
|
||||||
|
void refreshFarmStatus();
|
||||||
|
farmPoll = setInterval(() => void refreshFarmStatus(), 10_000);
|
||||||
});
|
});
|
||||||
|
onBeforeUnmount(() => clearInterval(farmPoll));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="fleet">
|
<div class="fleet">
|
||||||
<h1>Fleet overview</h1>
|
<h1>Fleet overview</h1>
|
||||||
|
<FarmStatus v-for="farm in farms ?? []" :key="farm.id" :farm="farm" :status="farmStatus[farm.id] ?? null" />
|
||||||
<div class="fleet__grid">
|
<div class="fleet__grid">
|
||||||
<PrinterCard
|
<PrinterCard
|
||||||
v-for="printer in printers ?? []"
|
v-for="printer in printers ?? []"
|
||||||
:key="printer.id"
|
:key="printer.id"
|
||||||
:printer="printer"
|
:printer="current(printer)"
|
||||||
:live="telemetry.printers[printer.id]"
|
:stream="streams[printer.id]"
|
||||||
|
:signal="(sdp: string) => signalOffer(printer.id, sdp)"
|
||||||
|
:send="(command: PrinterCommand) => sendCommand(printer.id, command)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -3,6 +3,22 @@ const email = ref("");
|
|||||||
const sent = ref(false);
|
const sent = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const client = useSupabaseClient();
|
const client = useSupabaseClient();
|
||||||
|
const passwordLogin = useRuntimeConfig().public.passwordLogin;
|
||||||
|
const password = ref("");
|
||||||
|
|
||||||
|
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("/");
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMagicLink() {
|
async function sendMagicLink() {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
@@ -23,6 +39,12 @@ async function sendMagicLink() {
|
|||||||
<button type="submit">Send magic link</button>
|
<button type="submit">Send magic link</button>
|
||||||
</form>
|
</form>
|
||||||
<p v-else>Check your inbox for a sign-in link.</p>
|
<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>
|
<p v-if="error" class="login__error">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -36,6 +58,17 @@ async function sendMagicLink() {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login__password {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.login__error {
|
.login__error {
|
||||||
color: #f2604c;
|
color: #f2604c;
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-44
@@ -1,83 +1,154 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
|
import type { AppPrinterState, AppTelemetryMessage } from "@continuum/ui";
|
||||||
|
|
||||||
export interface PrinterTelemetry {
|
/**
|
||||||
printerId: string;
|
* Live printer state from `continuum-backend`'s `/ws/telemetry`.
|
||||||
farmId: string;
|
*
|
||||||
state: "idle" | "printing" | "paused" | "error" | "offline";
|
* The message shape is the backend's, mirrored in `@continuum/ui`'s types
|
||||||
nozzleTempC: number;
|
* from `continuum-schemas`. This file used to declare its own
|
||||||
bedTempC: number;
|
* (`printerId`, `progressPct`, a closed state enum, a `data` envelope) and
|
||||||
progressPct: number;
|
* no backend ever sent it.
|
||||||
updatedAt: string;
|
*/
|
||||||
}
|
|
||||||
|
export type TelemetryLink = "disconnected" | "connecting" | "connected";
|
||||||
|
|
||||||
interface TelemetryState {
|
interface TelemetryState {
|
||||||
socket: WebSocket | null;
|
link: TelemetryLink;
|
||||||
connected: boolean;
|
/**
|
||||||
printers: Record<string, PrinterTelemetry>;
|
* 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", {
|
export const useTelemetryStore = defineStore("telemetry", {
|
||||||
state: (): TelemetryState => ({
|
state: (): TelemetryState => ({
|
||||||
socket: null,
|
link: "disconnected",
|
||||||
connected: false,
|
visiblePrinters: null,
|
||||||
printers: {},
|
printers: {},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
|
connected: (state) => state.link === "connected",
|
||||||
printerList: (state) => Object.values(state.printers),
|
printerList: (state) => Object.values(state.printers),
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
connect() {
|
connect() {
|
||||||
if (this.socket) return;
|
wanted = true;
|
||||||
|
if (socket) return;
|
||||||
|
|
||||||
const config = useRuntimeConfig();
|
// Read here, at every attempt, never captured by the store: supabase-js
|
||||||
const client = useSupabaseClient();
|
// 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 }) => {
|
// No session means no connection, rather than an empty credential the
|
||||||
const token = data.session?.access_token ?? "";
|
// server can only reject. Deliberately no retry timer either: the
|
||||||
const url = `${config.public.wsBase}/ws/telemetry?token=${encodeURIComponent(token)}`;
|
// caller connects when a session appears (app.vue watches for it), and
|
||||||
this.open(url);
|
// polling for a sign-in would be a loop with nothing to wait on.
|
||||||
});
|
if (!token) {
|
||||||
},
|
this.link = "disconnected";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
open(url: string) {
|
const { wsBase } = useRuntimeConfig().public;
|
||||||
const socket = new WebSocket(url);
|
|
||||||
|
|
||||||
socket.onopen = () => {
|
// The token travels in the WebSocket subprotocol list, not the URL: a
|
||||||
this.connected = true;
|
// 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 {
|
try {
|
||||||
const payload = JSON.parse(event.data) as { type: string; data: PrinterTelemetry };
|
message = JSON.parse(event.data) as AppTelemetryMessage;
|
||||||
if (payload.type === "printer.telemetry") {
|
|
||||||
this.printers[payload.data.printerId] = payload.data;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed frames rather than crashing the UI.
|
// 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 = () => {
|
ws.onclose = () => {
|
||||||
this.connected = false;
|
// A socket we already replaced or abandoned must not clobber the
|
||||||
this.socket = null;
|
// state of its successor.
|
||||||
setTimeout(() => this.connect(), RECONNECT_DELAY_MS);
|
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 = () => {
|
ws.onerror = () => {
|
||||||
socket.close();
|
ws.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
this.socket = socket;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
this.socket?.close();
|
wanted = false;
|
||||||
this.socket = null;
|
clearTimeout(retryTimer);
|
||||||
this.connected = false;
|
// 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;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "continuum-app",
|
"name": "continuum-app",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Continuum print farm operator console - Nuxt SPA + Tauri 2 desktop shell",
|
"description": "Continuum print farm operator console - Nuxt SPA + Tauri 2 desktop shell",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@continuum/ui",
|
"name": "@continuum/ui",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1,45 +1,111 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
import type { AppPrinterState, PrinterCommand } from "../types";
|
||||||
|
import PrinterControls from "./PrinterControls.vue";
|
||||||
import TemperatureGraph from "./TemperatureGraph.vue";
|
import TemperatureGraph from "./TemperatureGraph.vue";
|
||||||
|
import WebRTCStreamView from "./WebRTCStreamView.vue";
|
||||||
|
|
||||||
interface Printer {
|
/**
|
||||||
id: string;
|
* Where this printer's camera can be watched, from
|
||||||
name: string;
|
* `GET /printers/:id/stream`.
|
||||||
model: string;
|
*
|
||||||
farmId: string;
|
* 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
|
||||||
interface LiveTelemetry {
|
* desktop shell without a server behind them.
|
||||||
state: "idle" | "printing" | "paused" | "error" | "offline";
|
*
|
||||||
nozzleTempC: number;
|
* Undefined for most printers and that is expected - see
|
||||||
bedTempC: number;
|
* `usePrinterStream`. The player is simply not rendered.
|
||||||
progressPct: number;
|
*/
|
||||||
|
interface PrinterStream {
|
||||||
|
cameraId: string;
|
||||||
|
iceServers: RTCIceServer[];
|
||||||
|
turnConfigured: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
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>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="printer-card" :class="`is-${state}`">
|
<article class="printer-card" :class="statusClass">
|
||||||
<header>
|
<header>
|
||||||
<h3>{{ printer.name }}</h3>
|
<h3>{{ printer.name }}</h3>
|
||||||
<span class="printer-card__model">{{ printer.model }}</span>
|
<span class="printer-card__model">{{ printer.vendor }}</span>
|
||||||
</header>
|
</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
|
<WebRTCStreamView
|
||||||
v-if="live"
|
v-if="stream && signal"
|
||||||
:nozzle-temp-c="live.nozzleTempC"
|
:camera-id="stream.cameraId"
|
||||||
:bed-temp-c="live.bedTempC"
|
: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>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -56,6 +122,14 @@ const state = computed(() => props.live?.state ?? "offline");
|
|||||||
font-size: 0.8rem;
|
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 {
|
.printer-card.is-printing .printer-card__state {
|
||||||
color: #35d07f;
|
color: #35d07f;
|
||||||
}
|
}
|
||||||
@@ -64,7 +138,16 @@ const state = computed(() => props.live?.state ?? "offline");
|
|||||||
color: #f2604c;
|
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;
|
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>
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
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<{
|
const props = defineProps<{
|
||||||
nozzleTempC: number;
|
nozzleTempC?: number | null;
|
||||||
bedTempC: number;
|
bedTempC?: number | null;
|
||||||
maxSamples?: number;
|
maxSamples?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -14,8 +18,8 @@ const bedHistory = ref<number[]>([]);
|
|||||||
watch(
|
watch(
|
||||||
() => [props.nozzleTempC, props.bedTempC] as const,
|
() => [props.nozzleTempC, props.bedTempC] as const,
|
||||||
([nozzle, bed]) => {
|
([nozzle, bed]) => {
|
||||||
nozzleHistory.value = [...nozzleHistory.value, nozzle].slice(-MAX_SAMPLES);
|
if (nozzle != null) nozzleHistory.value = [...nozzleHistory.value, nozzle].slice(-MAX_SAMPLES);
|
||||||
bedHistory.value = [...bedHistory.value, bed].slice(-MAX_SAMPLES);
|
if (bed != null) bedHistory.value = [...bedHistory.value, bed].slice(-MAX_SAMPLES);
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@@ -45,8 +49,8 @@ onBeforeUnmount(() => {
|
|||||||
<polyline class="temp-graph__bed" :points="bedPoints" fill="none" />
|
<polyline class="temp-graph__bed" :points="bedPoints" fill="none" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="temp-graph__labels">
|
<div class="temp-graph__labels">
|
||||||
<span>Nozzle {{ nozzleTempC.toFixed(0) }}°C</span>
|
<span>Nozzle {{ nozzleTempC == null ? "—" : `${nozzleTempC.toFixed(0)}°C` }}</span>
|
||||||
<span>Bed {{ bedTempC.toFixed(0) }}°C</span>
|
<span>Bed {{ bedTempC == null ? "—" : `${bedTempC.toFixed(0)}°C` }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,19 +2,74 @@
|
|||||||
import { onBeforeUnmount, ref, watch } from "vue";
|
import { onBeforeUnmount, ref, watch } from "vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
streamBaseUrl: string;
|
/** The go2rtc stream name to negotiate for, from `GET /printers/:id/stream`. */
|
||||||
cameraId: string;
|
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 videoEl = ref<HTMLVideoElement | null>(null);
|
||||||
const pc = ref<RTCPeerConnection | null>(null);
|
const pc = ref<RTCPeerConnection | null>(null);
|
||||||
const status = ref<"connecting" | "connected" | "failed">("connecting");
|
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() {
|
async function connect() {
|
||||||
status.value = "connecting";
|
status.value = "connecting";
|
||||||
|
error.value = null;
|
||||||
pc.value?.close();
|
pc.value?.close();
|
||||||
|
|
||||||
const connection = new RTCPeerConnection();
|
const connection = new RTCPeerConnection({
|
||||||
|
iceServers: props.iceServers,
|
||||||
|
iceTransportPolicy: props.iceServers.length > 0 ? "relay" : "all",
|
||||||
|
});
|
||||||
pc.value = connection;
|
pc.value = connection;
|
||||||
|
|
||||||
connection.addTransceiver("video", { direction: "recvonly" });
|
connection.addTransceiver("video", { direction: "recvonly" });
|
||||||
@@ -23,24 +78,79 @@ async function connect() {
|
|||||||
};
|
};
|
||||||
connection.onconnectionstatechange = () => {
|
connection.onconnectionstatechange = () => {
|
||||||
if (connection.connectionState === "connected") status.value = "connected";
|
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();
|
const offer = await connection.createOffer();
|
||||||
await connection.setLocalDescription(offer);
|
await connection.setLocalDescription(offer);
|
||||||
|
|
||||||
// go2rtc's WebRTC WHEP-style signaling endpoint.
|
// Waits for ICE gathering before signalling, rather than trickling.
|
||||||
const response = await fetch(`${props.streamBaseUrl}/api/webrtc?src=${props.cameraId}`, {
|
// go2rtc's answer comes back through one request/response relay hop, so
|
||||||
method: "POST",
|
// there is no channel to trickle candidates over afterwards - the offer
|
||||||
headers: { "Content-Type": "application/sdp" },
|
// has to carry them all.
|
||||||
body: offer.sdp,
|
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 });
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
pc.value?.close();
|
pc.value?.close();
|
||||||
@@ -51,7 +161,9 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="webrtc-view">
|
<div class="webrtc-view">
|
||||||
<video ref="videoEl" autoplay playsinline muted />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
export { default as PrinterCard } from "./components/PrinterCard.vue";
|
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 TemperatureGraph } from "./components/TemperatureGraph.vue";
|
||||||
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
|
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
|
||||||
|
export { default as JobList } from "./components/JobList.vue";
|
||||||
|
export { default as JobUpload } from "./components/JobUpload.vue";
|
||||||
|
export { default as FarmStatus } from "./components/FarmStatus.vue";
|
||||||
|
export type { AppDiscoveredCandidate, AppFarm, AppFarmDiscovery, AppJob, AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types";
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user