Compare commits
4
Commits
571a2c0ca7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88b242d914
|
||
|
|
69f35c2a6b
|
||
|
|
6b1c7c6db3
|
||
|
|
9bd9728b21
|
@@ -28,6 +28,7 @@ onBeforeUnmount(() => {
|
|||||||
<nav class="app-shell__nav">
|
<nav class="app-shell__nav">
|
||||||
<NuxtLink to="/">Fleet</NuxtLink>
|
<NuxtLink to="/">Fleet</NuxtLink>
|
||||||
<NuxtLink to="/jobs">Jobs</NuxtLink>
|
<NuxtLink to="/jobs">Jobs</NuxtLink>
|
||||||
|
<NuxtLink to="/setup">Setup</NuxtLink>
|
||||||
</nav>
|
</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'">
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Where a printer's camera can be watched, from `GET /printers/:id/stream`.
|
* 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. */
|
/** The go2rtc stream name to negotiate for. */
|
||||||
cameraId: string;
|
cameraId: string;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default defineNuxtConfig({
|
|||||||
redirectOptions: {
|
redirectOptions: {
|
||||||
login: "/login",
|
login: "/login",
|
||||||
callback: "/confirm",
|
callback: "/confirm",
|
||||||
exclude: ["/login"],
|
exclude: ["/login", "/invite"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
|
AlertList,
|
||||||
FarmStatus,
|
FarmStatus,
|
||||||
|
type AppAlert,
|
||||||
PrinterCard,
|
PrinterCard,
|
||||||
type AppFarm,
|
type AppFarm,
|
||||||
type AppFarmDiscovery,
|
type AppFarmDiscovery,
|
||||||
@@ -54,6 +56,11 @@ async function signalOffer(printerId: string, sdp: string): Promise<string> {
|
|||||||
return answer.sdp;
|
return answer.sdp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One camera frame (P1/A1) as an image, through the backend. */
|
||||||
|
function fetchFrame(printerId: string): Promise<Blob> {
|
||||||
|
return apiFetch<Blob>(`/printers/${printerId}/snapshot`, { responseType: "blob" });
|
||||||
|
}
|
||||||
|
|
||||||
// Which farms exist, and for each whether its gateway is connected and what
|
// Which farms exist, and for each whether its gateway is connected and what
|
||||||
// discovery has found. Polled, because gateway presence has no push channel;
|
// discovery has found. Polled, because gateway presence has no push channel;
|
||||||
// ten seconds is quick enough to notice an unplugged box without hammering.
|
// 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 farmPoll: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let alertPoll: ReturnType<typeof setInterval> | undefined;
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void refreshFarmStatus();
|
void refreshFarmStatus();
|
||||||
farmPoll = setInterval(() => void refreshFarmStatus(), 10_000);
|
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.
|
* Sends one command to a printer and turns a refusal into a sentence.
|
||||||
@@ -96,7 +124,7 @@ watch(
|
|||||||
printers,
|
printers,
|
||||||
(list) => {
|
(list) => {
|
||||||
for (const printer of 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
|
// Marked before the request resolves, so a re-render in the meantime
|
||||||
// does not fire a second lookup for the same printer. `null` renders
|
// 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
|
// no player, which is also the right thing to show while this is in
|
||||||
@@ -114,7 +142,11 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<div class="fleet">
|
<div class="fleet">
|
||||||
<h1>Fleet overview</h1>
|
<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" />
|
<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">
|
<div class="fleet__grid">
|
||||||
<PrinterCard
|
<PrinterCard
|
||||||
v-for="printer in printers ?? []"
|
v-for="printer in printers ?? []"
|
||||||
@@ -122,6 +154,7 @@ watch(
|
|||||||
:printer="current(printer)"
|
:printer="current(printer)"
|
||||||
:stream="streams[printer.id]"
|
:stream="streams[printer.id]"
|
||||||
:signal="(sdp: string) => signalOffer(printer.id, sdp)"
|
:signal="(sdp: string) => signalOffer(printer.id, sdp)"
|
||||||
|
:fetch-frame="() => fetchFrame(printer.id)"
|
||||||
:send="(command: PrinterCommand) => sendCommand(printer.id, command)"
|
:send="(command: PrinterCommand) => sendCommand(printer.id, command)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 client = useSupabaseClient();
|
||||||
const passwordLogin = useRuntimeConfig().public.passwordLogin;
|
const passwordLogin = useRuntimeConfig().public.passwordLogin;
|
||||||
const password = ref("");
|
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() {
|
async function signInWithPassword() {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
@@ -17,12 +26,15 @@ async function signInWithPassword() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The Supabase module redirects away from /login once a session exists.
|
// The Supabase module redirects away from /login once a session exists.
|
||||||
await navigateTo("/");
|
await navigateTo(next.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMagicLink() {
|
async function sendMagicLink() {
|
||||||
error.value = null;
|
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) {
|
if (authError) {
|
||||||
error.value = authError.message;
|
error.value = authError.message;
|
||||||
return;
|
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 type { AppPrinterState, PrinterCommand } from "../types";
|
||||||
import PrinterControls from "./PrinterControls.vue";
|
import PrinterControls from "./PrinterControls.vue";
|
||||||
import TemperatureGraph from "./TemperatureGraph.vue";
|
import TemperatureGraph from "./TemperatureGraph.vue";
|
||||||
|
import SnapshotView from "./SnapshotView.vue";
|
||||||
import WebRTCStreamView from "./WebRTCStreamView.vue";
|
import WebRTCStreamView from "./WebRTCStreamView.vue";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,11 +18,10 @@ import WebRTCStreamView from "./WebRTCStreamView.vue";
|
|||||||
* Undefined for most printers and that is expected - see
|
* Undefined for most printers and that is expected - see
|
||||||
* `usePrinterStream`. The player is simply not rendered.
|
* `usePrinterStream`. The player is simply not rendered.
|
||||||
*/
|
*/
|
||||||
interface PrinterStream {
|
type PrinterStream =
|
||||||
cameraId: string;
|
| { kind?: "webrtc"; cameraId: string; iceServers: RTCIceServer[]; turnConfigured: boolean }
|
||||||
iceServers: RTCIceServer[];
|
// A P1/A1: no stream to negotiate, a picture to fetch again and again.
|
||||||
turnConfigured: boolean;
|
| { kind: "snapshot"; intervalMs?: number };
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +49,8 @@ const props = defineProps<{
|
|||||||
* read-only embedding of the card cannot offer buttons that do nothing.
|
* read-only embedding of the card cannot offer buttons that do nothing.
|
||||||
*/
|
*/
|
||||||
send?: (command: PrinterCommand) => Promise<void>;
|
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.
|
// `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" }}
|
{{ lastSeen ? `Last heard from ${lastSeen}` : "Never reported" }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<SnapshotView
|
||||||
|
v-if="stream && stream.kind === 'snapshot' && fetchFrame"
|
||||||
|
:fetch-frame="fetchFrame"
|
||||||
|
:interval-ms="stream.intervalMs"
|
||||||
|
/>
|
||||||
<WebRTCStreamView
|
<WebRTCStreamView
|
||||||
v-if="stream && signal"
|
v-else-if="stream && stream.kind !== 'snapshot' && signal"
|
||||||
:camera-id="stream.cameraId"
|
:camera-id="stream.cameraId"
|
||||||
:ice-servers="stream.iceServers"
|
:ice-servers="stream.iceServers"
|
||||||
:turn-configured="stream.turnConfigured"
|
: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 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 SnapshotView } from "./components/SnapshotView.vue";
|
||||||
export { default as JobList } from "./components/JobList.vue";
|
export { default as JobList } from "./components/JobList.vue";
|
||||||
export { default as JobUpload } from "./components/JobUpload.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 { 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;
|
activeAlerts?: number | null;
|
||||||
/** go2rtc stream name; null/absent means render no player. */
|
/** go2rtc stream name; null/absent means render no player. */
|
||||||
cameraStream?: string | null;
|
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. */
|
/** When a gateway last reported this printer; null means never. */
|
||||||
lastSeenAt?: string | null;
|
lastSeenAt?: string | null;
|
||||||
}
|
}
|
||||||
@@ -101,3 +103,70 @@ export interface AppFarmDiscovery {
|
|||||||
gatewayOnline: boolean;
|
gatewayOnline: boolean;
|
||||||
discovery: { scannedAt: string; printers: AppDiscoveredCandidate[] } | null;
|
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