Files
continuum-app/apps/web/pages/index.vue
T
octoturge 6fab9072d4 Play cameras through the relay instead of straight at the gateway
The player posted its SDP offer to the gateway's go2rtc directly, which
cannot work from a browser on another origin: no CORS headers on any
go2rtc response, a preflight required because application/sdp is not
CORS-safelisted, and the password checked before CORS so that preflight
gets a 401. It now hands the offer to a `signal` function the app
supplies, which relays it through the backend.

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

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

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

`signal` is a prop rather than a fetch in the component, like `stream`
already was: @continuum/ui stays plain Vue that knows nothing of the API,
which is what lets it render from a test or the desktop shell.
2026-10-19 00:33:18 +00:00

99 lines
3.5 KiB
Vue

<script setup lang="ts">
import { PrinterCard, type AppPrinterState } from "@continuum/ui";
import { useTelemetryStore } from "~/stores/telemetry";
const telemetry = useTelemetryStore();
const apiFetch = useApiFetch();
const { data: printers } = await useAsyncData("printers", () =>
apiFetch<AppPrinterState[]>("/printers"),
);
// Which row to draw: the live push if there is one, else the fetched row.
//
// Merged here rather than passing `PrinterCard` a second `live` prop. A push
// is the same shape as a `GET /printers` row (that is the contract's whole
// point), so the pushed object simply *replaces* the fetched one - replaces,
// not spreads, because a push is the complete resource and a spread would let
// a field the push omitted survive from the older row. The page does this
// rather than the component because `@continuum/ui` is plain Vue that knows
// nothing of stores or the API, so it can render in a test or the Tauri shell
// with no server. Known edge: a push that predates a refetch can briefly
// shadow the fresher row; the next push corrects it.
// The REST list still decides *which* printers exist and in what order.
function current(printer: AppPrinterState): AppPrinterState {
return telemetry.printers[printer.id] ?? printer;
}
// Looked up per printer, because the answer is per printer, but only for the
// printers the list already says have a camera. Asking for all of them would
// mean one request per machine in the fleet, most of them 404 by design.
const streams = ref<Record<string, PrinterStream | null>>({});
/**
* Relays one printer's WebRTC offer through the backend and returns the
* gateway's answer.
*
* Lives here rather than in `@continuum/ui` for the same reason the stream
* lookup does: that package is plain Vue that knows nothing of the API, so
* the app supplies the one function that talks to it.
*/
async function signalOffer(printerId: string, sdp: string): Promise<string> {
const answer = await apiFetch<{ sdp: string }>(`/printers/${printerId}/webrtc`, {
method: "POST",
body: { sdp },
});
return answer.sdp;
}
watch(
printers,
(list) => {
for (const printer of list ?? []) {
if (!printer.cameraStream || printer.id in streams.value) continue;
// Marked before the request resolves, so a re-render in the meantime
// does not fire a second lookup for the same printer. `null` renders
// no player, which is also the right thing to show while this is in
// flight.
streams.value[printer.id] = null;
void fetchPrinterStream(apiFetch, printer.id).then((stream) => {
streams.value[printer.id] = stream;
});
}
},
{ immediate: true },
);
</script>
<template>
<div class="fleet">
<h1>Fleet overview</h1>
<div class="fleet__grid">
<PrinterCard
v-for="printer in printers ?? []"
:key="printer.id"
:printer="current(printer)"
:stream="streams[printer.id]"
:signal="(sdp: string) => signalOffer(printer.id, sdp)"
/>
</div>
<p v-if="!printers?.length">
<template v-if="telemetry.link !== 'connected' || telemetry.visiblePrinters === null">
No printers loaded yet.
</template>
<template v-else-if="telemetry.visiblePrinters === 0">No printers registered yet.</template>
<template v-else>Printers exist but none has been listed - try refreshing.</template>
</p>
</div>
</template>
<style scoped>
.fleet__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
</style>