Live view over WebRTC (openpanel L1, server and browser)

A device reporting a `live` port streams its camera to the browser's own
RTCPeerConnection. The flows socket carries the signalling as text, so it
crosses the portal: `live` messages on a person's socket, `signal` on a
device's (start, sdp, candidate, bye, busy). The server routes by session,
holds a device to two viewers, ends a session when either socket closes or
no answer comes within 10 s, and gives each socket an outbox of its own
that ends a session rather than drop a message. TURN credentials are
minted per session in coturn's REST scheme (STUN_URLS, TURN_URLS,
TURN_SECRET), the viewer's with the device's offer; GET /devices/{id}/ice
hands them out too. The tunnel keeps a stream's early frames instead of
dropping them past eight and forgets them when the stream or link goes.

A media tile on a device's camera, and the Devices tab's preview, play
live when tapped, pause frames meanwhile, and fall back to frames with a
short note on busy, failure or no picture within 10 s. coturn is an
optional compose service behind the `turn` profile.
This commit is contained in:
Melvin Strobl
2026-10-17 08:37:39 +02:00
parent efd883d092
commit 6a98dcd23f
20 changed files with 1535 additions and 24 deletions
@@ -2,7 +2,11 @@ import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import type { DevicePublic, FlowSummary, NodeTypeInfo } from "@/client"
import { type ReportedPort, reportedPorts } from "@/components/Devices/ports"
import {
LIVE_PORT,
type ReportedPort,
reportedPorts,
} from "@/components/Devices/ports"
import { devicesQueryOptions } from "@/components/Devices/queries"
import {
CommandDialog,
@@ -58,7 +62,9 @@ export function CommandPalette({
// A shortcut per port a device says it has, "Hallway camera". A device that
// has not said is added as a Device node and its ports typed in.
const capabilities = (devicesData?.data ?? []).flatMap((device) =>
(reportedPorts(device) ?? []).map((port) => ({ device, port })),
(reportedPorts(device) ?? [])
.filter((port) => port.port !== LIVE_PORT)
.map((port) => ({ device, port })),
)
const close = (action: () => void) => {
@@ -134,6 +134,23 @@ type FlowEvent =
names: string[]
ts?: number
}
| LiveSignal
/**
* A live view's signalling, both ways on this socket (`fluksio.flow.live`).
* The page sends `start`, `sdp`, `candidate` and `bye`, and hears `sdp` (the
* device's offer, with the ICE servers to use), `candidate`, `bye` and `busy`.
*/
export type LiveSignal = {
type: "live"
device: string
session: string
action: "start" | "sdp" | "candidate" | "bye" | "busy"
sdp?: string
candidate?: string
reason?: string
ice_servers?: RTCIceServer[]
}
async function socketUrl(): Promise<string> {
const base = String(OpenAPI.BASE || window.location.origin)
@@ -176,6 +193,42 @@ let client: QueryClient | null = null
const authHandlers = new Set<() => void>()
/** Floor re-reads still owed, per node, so a recovery can cancel its own. */
const healthTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** What each live session on this page does with what it hears. */
const liveListeners = new Map<string, (message: LiveSignal) => void>()
/** Send a live view's signalling. False while the socket is not open. */
export function sendLive(message: Omit<LiveSignal, "type">): boolean {
if (socket?.readyState !== WebSocket.OPEN) return false
socket.send(JSON.stringify({ type: "live", ...message }))
return true
}
/**
* Hear one live session's signalling, until the returned function is called.
* A socket that drops ends every session on it, so each hears `bye` then.
*/
export function listenLive(
session: string,
listener: (message: LiveSignal) => void,
): () => void {
liveListeners.set(session, listener)
return () => {
if (liveListeners.get(session) === listener) liveListeners.delete(session)
}
}
/** The socket went: so did every live session riding on it. */
function endLive() {
for (const [session, listener] of [...liveListeners]) {
listener({
type: "live",
device: "",
session,
action: "bye",
reason: "socket",
})
}
}
/** Everything a node's health shows up in, which is two unrelated keys. */
function invalidateHealth(flow: string | undefined) {
@@ -436,6 +489,9 @@ function open(url: string) {
// live value, so nothing here goes through the live store.
client?.invalidateQueries({ queryKey: runKeys.all })
break
case "live":
liveListeners.get(message.session)?.(message)
break
case "run_metric":
// A batch of readings was written. Only what draws them is refetched —
// the run's own detail (its metric names and curve hang below that
@@ -480,6 +536,7 @@ function open(url: string) {
socket = null
liveStore.setConnected(false)
connectionStore.setSocketOpen(false)
endLive()
if (watchers === 0) return
if (event.code === 1008 && refused()) return
// 1013 is the portal saying the instance is not attached — the one
@@ -504,6 +561,7 @@ function release(onAuthFailure?: () => void) {
const ws = socket
socket = null
ws?.close()
endLive()
liveStore.setConnected(false)
// Its `onclose` is dropped by the guard above, so say so here instead.
connectionStore.setSocketOpen(false)