Compare commits
4
Commits
571a2c0ca7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88b242d914
|
||
|
|
69f35c2a6b
|
||
|
|
6b1c7c6db3
|
||
|
|
9bd9728b21
|
@@ -28,6 +28,7 @@ onBeforeUnmount(() => {
|
||||
<nav class="app-shell__nav">
|
||||
<NuxtLink to="/">Fleet</NuxtLink>
|
||||
<NuxtLink to="/jobs">Jobs</NuxtLink>
|
||||
<NuxtLink to="/setup">Setup</NuxtLink>
|
||||
</nav>
|
||||
<span class="app-shell__status" :class="telemetry.connected ? 'is-online' : 'is-offline'">
|
||||
{{
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/**
|
||||
* Where a printer's camera can be watched, from `GET /printers/:id/stream`.
|
||||
*/
|
||||
export interface PrinterStream {
|
||||
export type PrinterStream =
|
||||
| WebrtcStream
|
||||
// A P1/A1: no stream to negotiate, a picture to fetch again and again.
|
||||
| { kind: "snapshot"; snapshotPath: string; intervalMs: number };
|
||||
|
||||
export interface WebrtcStream {
|
||||
kind?: "webrtc";
|
||||
/** The go2rtc stream name to negotiate for. */
|
||||
cameraId: string;
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineNuxtConfig({
|
||||
redirectOptions: {
|
||||
login: "/login",
|
||||
callback: "/confirm",
|
||||
exclude: ["/login"],
|
||||
exclude: ["/login", "/invite"],
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AlertList,
|
||||
FarmStatus,
|
||||
type AppAlert,
|
||||
PrinterCard,
|
||||
type AppFarm,
|
||||
type AppFarmDiscovery,
|
||||
@@ -54,6 +56,11 @@ async function signalOffer(printerId: string, sdp: string): Promise<string> {
|
||||
return answer.sdp;
|
||||
}
|
||||
|
||||
/** One camera frame (P1/A1) as an image, through the backend. */
|
||||
function fetchFrame(printerId: string): Promise<Blob> {
|
||||
return apiFetch<Blob>(`/printers/${printerId}/snapshot`, { responseType: "blob" });
|
||||
}
|
||||
|
||||
// Which farms exist, and for each whether its gateway is connected and what
|
||||
// discovery has found. Polled, because gateway presence has no push channel;
|
||||
// ten seconds is quick enough to notice an unplugged box without hammering.
|
||||
@@ -70,12 +77,33 @@ async function refreshFarmStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// What still needs a person. Polled on the same 5s beat as jobs: an alert is
|
||||
// useful for minutes, not milliseconds, and a push channel for it would be a
|
||||
// second socket to keep honest.
|
||||
const { data: alerts, refresh: refreshAlerts } = await useAsyncData("alerts", () =>
|
||||
apiFetch<AppAlert[]>("/alerts", { query: { open: true } }),
|
||||
);
|
||||
|
||||
async function acknowledge(id: string): Promise<void> {
|
||||
try {
|
||||
await apiFetch(`/alerts/${id}/acknowledge`, { method: "POST" });
|
||||
} catch (err) {
|
||||
throw new Error(apiErrorMessage(err, "could not dismiss the alert"));
|
||||
}
|
||||
await refreshAlerts();
|
||||
}
|
||||
|
||||
let farmPoll: ReturnType<typeof setInterval> | undefined;
|
||||
let alertPoll: ReturnType<typeof setInterval> | undefined;
|
||||
onMounted(() => {
|
||||
void refreshFarmStatus();
|
||||
farmPoll = setInterval(() => void refreshFarmStatus(), 10_000);
|
||||
alertPoll = setInterval(() => void refreshAlerts(), 5_000);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(farmPoll);
|
||||
clearInterval(alertPoll);
|
||||
});
|
||||
onBeforeUnmount(() => clearInterval(farmPoll));
|
||||
|
||||
/**
|
||||
* Sends one command to a printer and turns a refusal into a sentence.
|
||||
@@ -96,7 +124,7 @@ watch(
|
||||
printers,
|
||||
(list) => {
|
||||
for (const printer of list ?? []) {
|
||||
if (!printer.cameraStream || printer.id in streams.value) continue;
|
||||
if (!(printer.cameraStream || printer.cameraSnapshot) || printer.id in streams.value) continue;
|
||||
// Marked before the request resolves, so a re-render in the meantime
|
||||
// does not fire a second lookup for the same printer. `null` renders
|
||||
// no player, which is also the right thing to show while this is in
|
||||
@@ -114,7 +142,11 @@ watch(
|
||||
<template>
|
||||
<div class="fleet">
|
||||
<h1>Fleet overview</h1>
|
||||
<p v-if="farms && !farms.length">
|
||||
You have no farm yet. <NuxtLink to="/setup">Set one up</NuxtLink> to connect your printers.
|
||||
</p>
|
||||
<FarmStatus v-for="farm in farms ?? []" :key="farm.id" :farm="farm" :status="farmStatus[farm.id] ?? null" />
|
||||
<AlertList :alerts="alerts ?? []" :acknowledge="acknowledge" />
|
||||
<div class="fleet__grid">
|
||||
<PrinterCard
|
||||
v-for="printer in printers ?? []"
|
||||
@@ -122,6 +154,7 @@ watch(
|
||||
:printer="current(printer)"
|
||||
:stream="streams[printer.id]"
|
||||
:signal="(sdp: string) => signalOffer(printer.id, sdp)"
|
||||
:fetch-frame="() => fetchFrame(printer.id)"
|
||||
:send="(command: PrinterCommand) => sendCommand(printer.id, command)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -5,6 +5,15 @@ const error = ref<string | null>(null);
|
||||
const client = useSupabaseClient();
|
||||
const passwordLogin = useRuntimeConfig().public.passwordLogin;
|
||||
const password = ref("");
|
||||
const route = useRoute();
|
||||
|
||||
// Where to go after signing in. Only a path on this site: a `next` of
|
||||
// `//elsewhere.example` or `https://...` would turn the sign-in page into an
|
||||
// open redirect.
|
||||
const next = computed(() => {
|
||||
const wanted = route.query.next;
|
||||
return typeof wanted === "string" && wanted.startsWith("/") && !wanted.startsWith("//") ? wanted : "/";
|
||||
});
|
||||
|
||||
async function signInWithPassword() {
|
||||
error.value = null;
|
||||
@@ -17,12 +26,15 @@ async function signInWithPassword() {
|
||||
return;
|
||||
}
|
||||
// The Supabase module redirects away from /login once a session exists.
|
||||
await navigateTo("/");
|
||||
await navigateTo(next.value);
|
||||
}
|
||||
|
||||
async function sendMagicLink() {
|
||||
error.value = null;
|
||||
const { error: authError } = await client.auth.signInWithOtp({ email: email.value });
|
||||
const { error: authError } = await client.auth.signInWithOtp({
|
||||
email: email.value,
|
||||
options: next.value === "/" ? undefined : { emailRedirectTo: `${window.location.origin}${next.value}` },
|
||||
});
|
||||
if (authError) {
|
||||
error.value = authError.message;
|
||||
return;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { AppAlert } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
alerts: AppAlert[];
|
||||
/** Resolves when the alert is acknowledged; rejects with a readable message. */
|
||||
acknowledge: (id: string) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const busy = ref<string | null>(null);
|
||||
const failure = ref("");
|
||||
|
||||
async function done(alert: AppAlert) {
|
||||
busy.value = alert.id;
|
||||
failure.value = "";
|
||||
try {
|
||||
await props.acknowledge(alert.id);
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
const when = (iso: string) => new Date(iso).toLocaleString();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="alerts.length" class="alert-list" aria-label="Alerts">
|
||||
<h2>Needs attention ({{ alerts.length }})</h2>
|
||||
<ul>
|
||||
<li v-for="alert in alerts" :key="alert.id" :class="`is-${alert.kind}`">
|
||||
<span class="alert-list__message">{{ alert.message }}</span>
|
||||
<time :datetime="alert.createdAt">{{ when(alert.createdAt) }}</time>
|
||||
<button type="button" :disabled="busy === alert.id" @click="done(alert)">
|
||||
{{ busy === alert.id ? "..." : "Dismiss" }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="failure" role="alert" class="alert-list__error">{{ failure }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.alert-list {
|
||||
border: 1px solid #f2604c;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.alert-list h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
color: #f2604c;
|
||||
}
|
||||
|
||||
.alert-list ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.alert-list li {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: baseline;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.alert-list__message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.alert-list time {
|
||||
opacity: 0.6;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.alert-list__error {
|
||||
color: #f2604c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import type { AppFarm, EnrolmentCode } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
farm: AppFarm;
|
||||
/** null until the first answer arrives. */
|
||||
gatewayOnline: boolean | null;
|
||||
/** Where the gateway connects; shown in its settings so there is one place to copy from. */
|
||||
uplinkUrl: string;
|
||||
/** Mints a new proxy token. Rejects with a readable message. */
|
||||
mint: () => Promise<string>;
|
||||
/** Makes a one-time enrolment code. Rejects with a readable message. */
|
||||
enrol: () => Promise<EnrolmentCode>;
|
||||
/** The API's address as a gateway reaches it, for the enrol command. */
|
||||
apiUrl: string;
|
||||
}>();
|
||||
|
||||
const enrolment = ref<EnrolmentCode | null>(null);
|
||||
const enrolBusy = ref(false);
|
||||
const enrolCopied = ref(false);
|
||||
const command = computed(() => `continuum-proxy enrol ${props.apiUrl} ${enrolment.value?.code ?? ""}`);
|
||||
|
||||
async function makeCode() {
|
||||
enrolBusy.value = true;
|
||||
failure.value = "";
|
||||
try {
|
||||
enrolment.value = await props.enrol();
|
||||
enrolCopied.value = false;
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
enrolBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCommand() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command.value);
|
||||
enrolCopied.value = true;
|
||||
} catch {
|
||||
enrolCopied.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const token = ref("");
|
||||
const confirming = ref(false);
|
||||
const busy = ref(false);
|
||||
const failure = ref("");
|
||||
const copied = ref(false);
|
||||
|
||||
// Minting replaces the old token, which disconnects a gateway that already
|
||||
// has it at its next reconnect - so a farm that has a gateway online asks
|
||||
// first, and a farm that has never had one does not.
|
||||
async function run() {
|
||||
busy.value = true;
|
||||
failure.value = "";
|
||||
try {
|
||||
token.value = await props.mint();
|
||||
confirming.value = false;
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (props.gatewayOnline) confirming.value = true;
|
||||
else void run();
|
||||
}
|
||||
|
||||
const settings = computed(() =>
|
||||
[
|
||||
`gateway_id = "gateway-1"`,
|
||||
`farm_id = "${props.farm.id}"`,
|
||||
`gateway_token = "${token.value}"`,
|
||||
`uplink_url = "${props.uplinkUrl}"`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(settings.value);
|
||||
copied.value = true;
|
||||
} catch {
|
||||
// Clipboard access can be refused; the text is selectable on screen.
|
||||
copied.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="gateway-setup" :aria-label="`Gateway for ${farm.name}`">
|
||||
<h3>Gateway</h3>
|
||||
<p v-if="gatewayOnline" class="gateway-setup__ok">A gateway is connected.</p>
|
||||
<p v-else>
|
||||
No gateway is connected. Install the Continuum gateway on a computer on the printers' network and give it the
|
||||
settings below.
|
||||
</p>
|
||||
|
||||
<div class="gateway-setup__enrol">
|
||||
<template v-if="!enrolment">
|
||||
<button type="button" :disabled="enrolBusy" @click="makeCode">Connect a gateway with a code</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>
|
||||
On the computer that will run the gateway, run this within 15 minutes. The code works once, and it does not
|
||||
replace a gateway that is already connected.
|
||||
</p>
|
||||
<pre class="gateway-setup__settings">{{ command }}</pre>
|
||||
<button type="button" @click="copyCommand">{{ enrolCopied ? "Copied" : "Copy" }}</button>
|
||||
<button type="button" @click="enrolment = null">Done</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<details class="gateway-setup__manual">
|
||||
<summary>Set it up by hand instead</summary>
|
||||
<template v-if="!token">
|
||||
<button v-if="!confirming" type="button" :disabled="busy" @click="start">
|
||||
{{ gatewayOnline ? "Create a new gateway token" : "Create gateway settings" }}
|
||||
</button>
|
||||
<div v-else class="gateway-setup__confirm" role="alertdialog">
|
||||
<p>
|
||||
This replaces the current token. The connected gateway will be refused the next time it reconnects, until
|
||||
you give it the new one.
|
||||
</p>
|
||||
<button type="button" :disabled="busy" @click="run">Replace token</button>
|
||||
<button type="button" :disabled="busy" @click="confirming = false">Keep the current one</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p>
|
||||
Put this in the gateway's <code>continuum.toml</code>. <strong>The token is shown only now</strong> - it is not
|
||||
kept anywhere you can read it back.
|
||||
</p>
|
||||
<pre class="gateway-setup__settings">{{ settings }}</pre>
|
||||
<button type="button" @click="copy">{{ copied ? "Copied" : "Copy" }}</button>
|
||||
<button type="button" @click="token = ''">Done</button>
|
||||
</template>
|
||||
</details>
|
||||
|
||||
<p v-if="failure" role="alert" class="gateway-setup__error">{{ failure }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gateway-setup__ok {
|
||||
color: #35d07f;
|
||||
}
|
||||
|
||||
.gateway-setup__settings {
|
||||
background: #12151c;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.gateway-setup__error {
|
||||
color: #f2604c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { AppPrinterState } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
printers: AppPrinterState[];
|
||||
/** Removes the printer. Rejects with a readable message. */
|
||||
remove: (id: string) => Promise<void>;
|
||||
}>();
|
||||
|
||||
// Named confirmation, like Stop: removing also deletes the credential on the
|
||||
// gateway, which cannot be undone from here.
|
||||
const confirming = ref<string | null>(null);
|
||||
const busy = ref<string | null>(null);
|
||||
const failure = ref("");
|
||||
|
||||
async function confirm(printer: AppPrinterState) {
|
||||
busy.value = printer.id;
|
||||
failure.value = "";
|
||||
try {
|
||||
await props.remove(printer.id);
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = null;
|
||||
confirming.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="printers.length" class="managed" aria-label="Printers">
|
||||
<h3>Printers</h3>
|
||||
<ul>
|
||||
<li v-for="printer in printers" :key="printer.id">
|
||||
<span class="managed__name">{{ printer.name }}</span>
|
||||
<button v-if="confirming !== printer.id" type="button" @click="confirming = printer.id">Remove</button>
|
||||
<template v-else>
|
||||
<span>Remove {{ printer.name }} and forget its access code?</span>
|
||||
<button type="button" :disabled="busy === printer.id" @click="confirm(printer)">Yes, remove</button>
|
||||
<button type="button" @click="confirming = null">Cancel</button>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="failure" role="alert" class="managed__error">{{ failure }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.managed ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.managed li {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: baseline;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.managed__name {
|
||||
min-width: 10rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.managed__error {
|
||||
color: #f2604c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Creates the organisation (if needed) and the farm. Rejects with a readable message. */
|
||||
create: (farmName: string) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const farmName = ref("");
|
||||
const busy = ref(false);
|
||||
const failure = ref("");
|
||||
|
||||
async function submit() {
|
||||
busy.value = true;
|
||||
failure.value = "";
|
||||
try {
|
||||
await props.create(farmName.value.trim());
|
||||
} catch (err) {
|
||||
failure.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="onboarding" @submit.prevent="submit">
|
||||
<h2>Set up your first farm</h2>
|
||||
<p>A farm is one site - a workshop or a room - with one gateway and its printers.</p>
|
||||
<label>
|
||||
Farm name
|
||||
<input v-model="farmName" required maxlength="200" placeholder="Workshop" />
|
||||
</label>
|
||||
<button type="submit" :disabled="busy || !farmName.trim()">{{ busy ? "Creating..." : "Create farm" }}</button>
|
||||
<p v-if="failure" role="alert" class="onboarding__error">{{ failure }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.onboarding {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.onboarding label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.onboarding__error {
|
||||
color: #f2604c;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -3,6 +3,7 @@ import { computed } from "vue";
|
||||
import type { AppPrinterState, PrinterCommand } from "../types";
|
||||
import PrinterControls from "./PrinterControls.vue";
|
||||
import TemperatureGraph from "./TemperatureGraph.vue";
|
||||
import SnapshotView from "./SnapshotView.vue";
|
||||
import WebRTCStreamView from "./WebRTCStreamView.vue";
|
||||
|
||||
/**
|
||||
@@ -17,11 +18,10 @@ import WebRTCStreamView from "./WebRTCStreamView.vue";
|
||||
* Undefined for most printers and that is expected - see
|
||||
* `usePrinterStream`. The player is simply not rendered.
|
||||
*/
|
||||
interface PrinterStream {
|
||||
cameraId: string;
|
||||
iceServers: RTCIceServer[];
|
||||
turnConfigured: boolean;
|
||||
}
|
||||
type PrinterStream =
|
||||
| { kind?: "webrtc"; cameraId: string; iceServers: RTCIceServer[]; turnConfigured: boolean }
|
||||
// A P1/A1: no stream to negotiate, a picture to fetch again and again.
|
||||
| { kind: "snapshot"; intervalMs?: number };
|
||||
|
||||
const props = defineProps<{
|
||||
/**
|
||||
@@ -49,6 +49,8 @@ const props = defineProps<{
|
||||
* read-only embedding of the card cannot offer buttons that do nothing.
|
||||
*/
|
||||
send?: (command: PrinterCommand) => Promise<void>;
|
||||
/** Fetches one camera frame, for a `kind: "snapshot"` stream. */
|
||||
fetchFrame?: () => Promise<Blob>;
|
||||
}>();
|
||||
|
||||
// `status` is open-ended and null for a printer that has never reported.
|
||||
@@ -88,8 +90,13 @@ const lastSeen = computed(() => {
|
||||
{{ lastSeen ? `Last heard from ${lastSeen}` : "Never reported" }}
|
||||
</p>
|
||||
|
||||
<SnapshotView
|
||||
v-if="stream && stream.kind === 'snapshot' && fetchFrame"
|
||||
:fetch-frame="fetchFrame"
|
||||
:interval-ms="stream.intervalMs"
|
||||
/>
|
||||
<WebRTCStreamView
|
||||
v-if="stream && signal"
|
||||
v-else-if="stream && stream.kind !== 'snapshot' && signal"
|
||||
:camera-id="stream.cameraId"
|
||||
:ice-servers="stream.iceServers"
|
||||
:turn-configured="stream.turnConfigured"
|
||||
|
||||
@@ -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>
|
||||
@@ -2,7 +2,14 @@ export { default as PrinterCard } from "./components/PrinterCard.vue";
|
||||
export { default as PrinterControls } from "./components/PrinterControls.vue";
|
||||
export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
|
||||
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
|
||||
export { default as SnapshotView } from "./components/SnapshotView.vue";
|
||||
export { default as JobList } from "./components/JobList.vue";
|
||||
export { default as JobUpload } from "./components/JobUpload.vue";
|
||||
export { default as AlertList } from "./components/AlertList.vue";
|
||||
export { default as OnboardingForm } from "./components/OnboardingForm.vue";
|
||||
export { default as GatewaySetup } from "./components/GatewaySetup.vue";
|
||||
export { default as OrgMembers } from "./components/OrgMembers.vue";
|
||||
export { default as AddPrinter } from "./components/AddPrinter.vue";
|
||||
export { default as ManagedPrinters } from "./components/ManagedPrinters.vue";
|
||||
export { default as FarmStatus } from "./components/FarmStatus.vue";
|
||||
export type { AppDiscoveredCandidate, AppFarm, AppFarmDiscovery, AppJob, AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types";
|
||||
export type { AppMember, AppInvitation, CreatedInvitation, EnrolmentCode, OrgRole, PrinterKind, NewPrinter, AppOrg, AppAlert, AppDiscoveredCandidate, AppFarm, AppFarmDiscovery, AppJob, AppPrinterState, AppTelemetryMessage, PrinterCommand } from "./types";
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface AppPrinterState {
|
||||
activeAlerts?: number | null;
|
||||
/** go2rtc stream name; null/absent means render no player. */
|
||||
cameraStream?: string | null;
|
||||
/** True for a P1/A1: the camera is a refreshing picture, not a stream. */
|
||||
cameraSnapshot?: boolean;
|
||||
/** When a gateway last reported this printer; null means never. */
|
||||
lastSeenAt?: string | null;
|
||||
}
|
||||
@@ -101,3 +103,70 @@ export interface AppFarmDiscovery {
|
||||
gatewayOnline: boolean;
|
||||
discovery: { scannedAt: string; printers: AppDiscoveredCandidate[] } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /alerts`. `kind` is a plain string for the same reason `AppJob.status`
|
||||
* is: an unknown one should render, not break the page.
|
||||
*/
|
||||
export interface AppAlert {
|
||||
id: string;
|
||||
farmId: string;
|
||||
printerId?: string | null;
|
||||
kind: string;
|
||||
message: string;
|
||||
confidence?: number | null;
|
||||
acknowledgedAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** `GET /orgs`: the organisations the caller belongs to. */
|
||||
export interface AppOrg {
|
||||
id: string;
|
||||
name: string;
|
||||
/** The caller's role in it; only an owner can invite. */
|
||||
role?: "viewer" | "operator" | "owner";
|
||||
}
|
||||
|
||||
export type OrgRole = "viewer" | "operator" | "owner";
|
||||
|
||||
export interface AppMember {
|
||||
userId: string;
|
||||
/** Null for someone added by id rather than by invitation. */
|
||||
email: string | null;
|
||||
role: OrgRole;
|
||||
}
|
||||
|
||||
export interface AppInvitation {
|
||||
id: string;
|
||||
email: string;
|
||||
role: OrgRole;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/** The answer to creating an invitation. The token is in the link, shown once. */
|
||||
export interface CreatedInvitation extends AppInvitation {
|
||||
acceptPath: string;
|
||||
}
|
||||
|
||||
/** A one-time code to enrol a gateway. */
|
||||
export interface EnrolmentCode {
|
||||
code: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/** The printer kinds a gateway can drive, by the name the API takes. */
|
||||
export type PrinterKind = "bambu" | "klipper" | "octoprint" | "prusa_link";
|
||||
|
||||
/**
|
||||
* What the add-printer form hands to the page. The access code and API key
|
||||
* are secrets: send them, do not keep them. Which are present depends on
|
||||
* `vendor` (Bambu: code + serial; OctoPrint/PrusaLink: API key; Klipper: none).
|
||||
*/
|
||||
export interface NewPrinter {
|
||||
vendor: PrinterKind;
|
||||
name: string;
|
||||
host: string;
|
||||
serialNumber?: string;
|
||||
accessCode?: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user