Files
app/frontend/src/components/Devices/DeviceDetail.tsx
T
Melvin Strobl 6a98dcd23f 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.
2026-10-17 08:37:39 +02:00

854 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery } from "@tanstack/react-query"
import { Link, useNavigate } from "@tanstack/react-router"
import {
ArrowDown,
ArrowLeft,
ArrowLeftRight,
ArrowRight,
ArrowUp,
X,
} from "lucide-react"
import { useState } from "react"
import {
type ApiError,
CloudService,
type DeviceDef,
type DevicePublic,
DevicesService,
FlowsService,
} from "@/client"
import { initials, resolveIcon } from "@/components/Dashboard/icons"
import { MediaView } from "@/components/Dashboard/MediaWidget"
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
import { flowsQueryOptions } from "@/components/Flow/queries"
import { InfoTip, PanelSection } from "@/components/Flow/SidePanel"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { ago, cn } from "@/lib/utils"
import { handleError } from "@/utils"
import {
DEVICE_TYPE,
type DeviceReport,
deviceNodeOf,
freeNodeId,
LIVE_PORT,
type ReportedPort,
reportedPorts,
reportOf,
wiresAs,
withPort,
} from "./ports"
import {
devicesQueryOptions,
pendingQueryOptions,
usePairDevice,
useSaveDevice,
useUnpairDevice,
} from "./queries"
const CARD = "rounded-lg border border-border bg-card p-4 shadow-e1"
/** As many characters as a device puts on the wall. */
const CODE_LENGTH = 6
/** "Online", or when it was last heard from. */
export function presenceText(device: DevicePublic) {
if (device.online) return "Online"
return device.last_seen ? `Seen ${ago(device.last_seen)}` : "Not seen yet"
}
/**
* One device: whether it is there, how it pairs, what it shows and its own
* hardware.
*
* Every change saves as it is made, against the version it was read at, so
* there is no form to submit and no draft to publish: a device is a thing on
* the wall rather than a document.
*/
export function DeviceDetail({ id }: { id: string }) {
const { data, isPending } = useQuery(devicesQueryOptions())
const { user } = useAuth()
const save = useSaveDevice(id)
const { showErrorToast } = useCustomToast()
// Reading devices is any account's, every change to them a superuser's.
const canEdit = Boolean(user?.is_superuser)
if (isPending) return <Skeleton className="h-96 w-full rounded-lg" />
const device = data?.data?.find((one) => one.id === id)
if (!device) {
return (
<div className="grid gap-2">
<Link
to="/devices"
className="text-muted-foreground text-sm hover:underline"
>
Devices
</Link>
<p className="text-sm text-muted-foreground">No device named {id}.</p>
</div>
)
}
const write = (changes: Partial<DeviceDef>) =>
save.mutate(changes, {
onError: (error) =>
(error as ApiError).status === 409
? showErrorToast(
"Someone else changed this device first. Their version is shown now, so make the change again.",
)
: handleError.call(showErrorToast, error as ApiError),
})
return (
<div className="flex min-w-0 flex-col gap-4">
<header className="flex flex-wrap items-center gap-3">
<Link
to="/devices"
className="text-muted-foreground text-sm hover:underline"
>
Devices
</Link>
<h1 className="min-w-0 truncate text-2xl">
{device.title || device.id}
</h1>
<Badge
variant="outline"
data-testid="device-presence"
className={cn(
device.online &&
"border-transparent bg-status-success/15 text-status-success",
)}
>
{presenceText(device)}
</Badge>
</header>
<div className="grid gap-4 lg:grid-cols-2 [&>*]:min-w-0">
<div className="grid content-start gap-4 [&>*]:min-w-0">
<section className={cn(CARD, "grid gap-4")}>
<Basics device={device} canEdit={canEdit} onChange={write} />
</section>
<Camera device={device} />
<section className={CARD}>
<Dashboards device={device} canEdit={canEdit} onChange={write} />
</section>
</div>
<div className="grid content-start gap-4 [&>*]:min-w-0">
<section className={CARD}>
<Pairing
id={device.id}
host={data?.frontend_host ?? ""}
canEdit={canEdit}
/>
</section>
<section className={CARD}>
<Capabilities device={device} canEdit={canEdit} />
</section>
</div>
</div>
</div>
)
}
function Basics({
device,
canEdit,
onChange,
}: {
device: DeviceDef
canEdit: boolean
onChange: (changes: Partial<DeviceDef>) => void
}) {
return (
<PanelSection title="Device">
<div className="flex items-center gap-2">
<Input
// Saved on blur or Enter, not per keystroke. Remounted with whatever
// was saved, so a refused title snaps back.
key={device.title}
defaultValue={device.title}
placeholder={device.id}
aria-label={`Title of ${device.id}`}
disabled={!canEdit}
data-testid="device-title"
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur()
}}
onBlur={(event) => {
const title = event.currentTarget.value.trim()
event.currentTarget.value = title
if (title !== device.title) onChange({ title })
}}
/>
<span className="shrink-0 font-mono text-sm text-muted-foreground">
{device.id}
</span>
</div>
<label
htmlFor={`touch-${device.id}`}
className="flex items-center gap-2 text-sm"
>
<span className="flex-1">Touch friendly</span>
<InfoTip label="Touch friendly">
Bigger controls and a bigger rail, and nothing that only happens on
hover. Set on the device because the same dashboard may also hang
somewhere with a mouse.
</InfoTip>
<Switch
id={`touch-${device.id}`}
checked={device.touch === true}
disabled={!canEdit}
data-testid="device-touch"
onCheckedChange={(touch) => onChange({ touch })}
/>
</label>
</PanelSection>
)
}
/**
* What the device's camera sees, once a flow wires its `camera` port.
*
* Watching is what turns the camera on: it sends frames only while this
* preview, a tile or a flow reading them wants them.
*/
function Camera({ device }: { device: DevicePublic }) {
const bound = (device.bindings ?? []).find(
(binding) => binding.port === "camera" && binding.direction === "in",
)
if (!bound) return null
return (
<section className={CARD}>
<PanelSection
title="Camera"
help="Frames while this preview is on screen, which the camera sends only while someone watches them here or on a dashboard, or a running flow reads them. Tap it for live video when the device streams live."
>
<div
className="flex aspect-video w-full overflow-hidden rounded-md bg-muted text-center text-sm"
data-testid="device-camera"
>
<MediaView
message={bound.message}
fit="contain"
label={`${device.title || device.id} camera`}
/>
</div>
</PanelSection>
</section>
)
}
/** Which dashboards the device shows, in the order its rail carries them. */
function Dashboards({
device,
canEdit,
onChange,
}: {
device: DeviceDef
canEdit: boolean
onChange: (changes: Partial<DeviceDef>) => void
}) {
const { data } = useQuery(dashboardsQueryOptions())
const known = data?.data ?? []
const assigned = device.dashboards ?? []
const byName = new Map(known.map((dashboard) => [dashboard.name, dashboard]))
const free = known.filter((dashboard) => !assigned.includes(dashboard.name))
const set = (dashboards: string[]) => onChange({ dashboards })
const move = (from: number, by: number) => {
const next = [...assigned]
const [name] = next.splice(from, 1)
next.splice(from + by, 0, name)
set(next)
}
return (
<PanelSection
title="Dashboards"
help="The first one is what the device opens with, and its rail follows this order."
>
{assigned.length === 0 ? (
<p className="text-sm text-muted-foreground">
No dashboards yet. The device shows nothing until it has one.
</p>
) : (
<ol className="grid gap-1" data-testid="device-dashboards">
{assigned.map((name, index) => {
const dashboard = byName.get(name)
const title = dashboard?.title || name
// What this dashboard draws on the rail.
const Glyph = resolveIcon(dashboard?.icon ?? "")
return (
<li key={name} className="flex items-center gap-2 text-sm">
<span className="w-4 shrink-0 text-xs text-muted-foreground">
{index + 1}
</span>
<span
aria-hidden
className="flex size-5 shrink-0 items-center justify-center text-xs font-medium text-muted-foreground"
>
{Glyph ? <Glyph className="size-4" /> : initials(title)}
</span>
{dashboard ? (
<Link
to="/dashboards/$name"
params={{ name }}
className="min-w-0 flex-1 truncate hover:underline"
>
{title}
</Link>
) : (
// Deleted since: the device skips it.
<span className="min-w-0 flex-1 truncate text-muted-foreground line-through">
{name}
</span>
)}
{canEdit ? (
<span className="flex shrink-0 items-center">
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
aria-label={`Move ${title} up`}
disabled={index === 0}
onClick={() => move(index, -1)}
>
<ArrowUp />
</Button>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
aria-label={`Move ${title} down`}
disabled={index === assigned.length - 1}
onClick={() => move(index, 1)}
>
<ArrowDown />
</Button>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
aria-label={`Remove ${title}`}
onClick={() => set(assigned.filter((n) => n !== name))}
>
<X />
</Button>
</span>
) : null}
</li>
)
})}
</ol>
)}
{canEdit && free.length > 0 ? (
<Select value="" onValueChange={(name) => set([...assigned, name])}>
<SelectTrigger
className="justify-self-start"
aria-label="Add a dashboard"
data-testid="add-dashboard"
>
<SelectValue placeholder="Add a dashboard" />
</SelectTrigger>
<SelectContent>
{free.map((dashboard) => (
<SelectItem key={dashboard.name} value={dashboard.name}>
{dashboard.title || dashboard.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</PanelSection>
)
}
/**
* How a screen gets its credential: by the code it shows, or picked from the
* devices asking on this network. Plus the addresses to point one at, and the
* way to sign the current one out.
*/
function Pairing({
id,
host,
canEdit,
}: {
id: string
/** Where this instance answers, as it knows itself. */
host: string
canEdit: boolean
}) {
const { showSuccessToast, showErrorToast } = useCustomToast()
const [code, setCode] = useState("")
// Unpairing cannot be undone from here, since the screen has to be at hand
// to read out a new code, so the button asks a second time before it fires.
const [confirmUnpair, setConfirmUnpair] = useState(false)
const pair = usePairDevice(id)
const unpair = useUnpairDevice(id)
const typed = code.trim().toUpperCase()
// Where a screen that cannot reach this instance pairs instead. The issuer
// is the portal as a browser reaches it.
const { data: cloud } = useQuery({
queryKey: ["cloud", "status"],
queryFn: async () =>
(await CloudService.readStatus()) as {
enrolled: boolean
issuer: string | null
instance_id: string | null
},
})
const remoteLink =
cloud?.enrolled && cloud.issuer && cloud.instance_id
? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.instance_id}/panel`
: ""
// The instance's own address, not this browser's: administering through
// the portal puts the page on the portal's origin, which a screen on this
// network cannot be sent to.
const link = host ? `${host}/panel/${id}` : ""
const { data: pending } = useQuery({
...pendingQueryOptions(),
enabled: canEdit,
})
// Approving a code adopts whatever holds it, so say what that is while
// there is still time to stop.
const { data: waiting } = useQuery({
queryKey: ["pending-device", typed],
queryFn: () => DevicesService.pendingDevice({ code: typed }),
enabled: canEdit && typed.length === CODE_LENGTH,
retry: false,
})
const paired = {
onSuccess: () => {
setCode("")
showSuccessToast("Paired. The screen switches over within a few seconds.")
},
onError: (error: Error) =>
handleError.call(showErrorToast, error as ApiError),
}
return (
<PanelSection
title="Pairing"
help="Open the link on the device. It shows a code, which you enter here, or it appears below when it is on this network."
>
<div className="grid gap-2">
<Input
readOnly
value={link}
placeholder="This instance has no address set"
aria-label={`Link for ${id}`}
className="text-muted-foreground"
data-testid="device-link"
onFocus={(event) => event.currentTarget.select()}
/>
{remoteLink ? (
<Input
readOnly
value={remoteLink}
aria-label={`Portal link for ${id}`}
className="text-muted-foreground"
data-testid="device-remote-link"
onFocus={(event) => event.currentTarget.select()}
/>
) : null}
</div>
{canEdit ? (
<>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault()
if (typed) pair.mutate({ code: typed }, paired)
}}
>
<Input
value={code}
placeholder="Code shown on the screen"
aria-label={`Pairing code for ${id}`}
autoComplete="off"
maxLength={CODE_LENGTH}
data-testid="pair-code"
onChange={(event) => setCode(event.target.value.toUpperCase())}
/>
<Button
type="submit"
variant="outline"
disabled={!typed || pair.isPending}
data-testid="pair-device"
>
Pair
</Button>
</form>
{typed.length === CODE_LENGTH ? (
<p
className="text-xs text-muted-foreground"
data-testid="pending-code"
>
{waiting
? `${waiting.name || waiting.device}${waiting.remote ? " · via portal" : ""}`
: "No device is waiting on that code."}
</p>
) : null}
<div className="grid gap-1" data-testid="pending-devices">
<span className="text-xs text-muted-foreground">
{pending?.length
? "Waiting to be paired"
: "No device is waiting"}
</span>
{pending?.some((entry) => !entry.needs_code) ? (
<p className="text-xs text-muted-foreground">
Compare the check beside each with the one the device shows next
to its code before choosing Pair this one.
</p>
) : null}
{(pending ?? []).map((entry) => (
<div
key={entry.request}
className="flex items-center gap-2 rounded-sm text-sm"
>
{entry.check ? (
<span
className="shrink-0 rounded-sm border border-border px-2 py-1 font-mono text-base font-medium tracking-[0.2em]"
title="The check this device shows next to its code"
data-testid="pending-check"
>
<span aria-hidden="true">{entry.check}</span>
<span className="sr-only">
Check {entry.check.split("").join(" ")}
</span>
</span>
) : null}
<span className="grid min-w-0 flex-1">
<span className="truncate">
{entry.name || entry.board || "Unnamed device"}
{entry.firmware ? (
<span className="text-muted-foreground">
{" "}
· {entry.firmware}
</span>
) : null}
</span>
<span className="truncate text-xs text-muted-foreground">
{entry.device}
</span>
</span>
{entry.needs_code ? (
<span className="shrink-0 text-xs text-muted-foreground">
{entry.remote ? "Via portal, " : ""}needs its code
</span>
) : (
<Button
variant="outline"
size="sm-touch"
className="shrink-0"
disabled={pair.isPending}
data-testid="adopt-device"
onClick={() =>
pair.mutate({ request: entry.request }, paired)
}
>
Pair this one
</Button>
)}
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant={confirmUnpair ? "destructive" : "outline"}
disabled={unpair.isPending}
data-testid="unpair-device"
onBlur={() => setConfirmUnpair(false)}
onClick={() => {
if (!confirmUnpair) {
setConfirmUnpair(true)
return
}
setConfirmUnpair(false)
unpair.mutate(undefined, {
onSuccess: () =>
showSuccessToast(
"Unpaired. The screen asks for a new code.",
),
onError: (error) =>
handleError.call(showErrorToast, error as ApiError),
})
}}
>
{confirmUnpair
? "Confirm: sign this screen out"
: "Unpair screen"}
</Button>
<InfoTip label="Unpair screen">
Sends the screen back to a pairing code and keeps the device and
its dashboards, also for a screen paired through the portal.
</InfoTip>
</div>
</>
) : null}
</PanelSection>
)
}
/** "1024×600, touch", or "360×360, round, touch". */
function displayText(display: NonNullable<DeviceReport["display"]>) {
return [
`${display.w}×${display.h}`,
display.shape === "round" ? "round" : null,
display.touch ? "touch" : null,
]
.filter(Boolean)
.join(", ")
}
/**
* What the device says it has, and its ports as the published flows wire
* them: each port it reports, with the message it publishes or follows or a
* way to wire it, and any port wired that it does not report. Wiring is done
* in a flow, so this only reads.
*/
function Capabilities({
device,
canEdit,
}: {
device: DevicePublic
canEdit: boolean
}) {
const report = reportOf(device)
const reported = reportedPorts(device)
const bindings = device.bindings ?? []
const camera = report?.camera
const features = Object.keys(report?.features ?? {}).filter(
(name) => report?.features?.[name],
)
const facts = report
? [
["Board", [report.board, report.platform].filter(Boolean).join(" · ")],
["Firmware", report.firmware],
["Display", report.display ? displayText(report.display) : "None"],
camera?.sensor
? [
"Camera",
[
camera.sensor,
camera.w ? `${camera.w}×${camera.h} at ${camera.fps} fps` : "",
]
.filter(Boolean)
.join(", "),
]
: null,
features.length ? ["Features", features.join(", ")] : null,
].filter((fact): fact is [string, string] => Boolean(fact?.[1]))
: []
// A port the device lists, then one wired that it does not, so a typo in a
// flow shows up here as well as on the canvas.
const rows = [
...(reported ?? []).map((port) => ({ port: port.port, said: port })),
...[...new Set(bindings.map((binding) => binding.port))]
.filter((port) => !reported?.some((one) => one.port === port))
.map((port) => ({ port, said: undefined })),
]
return (
<PanelSection
title="Capabilities"
help="What the device reported about itself when it last connected, and its ports. A device node in a flow wires each port to a message."
>
{report ? (
<dl
className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm"
data-testid="device-capabilities"
>
{facts.map(([term, value]) => (
<div key={term} className="contents">
<dt className="text-muted-foreground">{term}</dt>
<dd className="min-w-0 truncate">{value}</dd>
</div>
))}
</dl>
) : (
<p className="text-sm text-muted-foreground">
Not reported yet. A device says what it has once it is paired and
connected.
</p>
)}
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">
{report ? "It reports no ports." : "No ports wired yet."}
</p>
) : (
<ul className="grid gap-1" data-testid="device-bindings">
{rows.map(({ port, said }) => {
const wired = bindings.filter((binding) => binding.port === port)
const either = (said?.modes?.length ?? 0) > 1
const direction = said?.direction ?? wired[0]?.direction
const Way = either
? ArrowLeftRight
: direction === "in"
? ArrowRight
: ArrowLeft
const way = either
? "Publishes or follows"
: direction === "in"
? "Publishes"
: "Follows"
return (
<li
key={port}
className="flex min-w-0 items-center gap-2 text-sm"
data-testid="device-port"
>
<span className="w-24 shrink-0 truncate font-mono">{port}</span>
<Way
role="img"
aria-label={way}
className="size-4 shrink-0 text-muted-foreground"
/>
<span className="w-12 shrink-0 truncate text-xs text-muted-foreground">
{said?.dtype ?? ""}
</span>
<span className="flex min-w-0 flex-1 flex-col">
{wired.map((binding) => (
<Link
key={`${binding.flow}.${binding.node}.${binding.message}`}
to="/flows/$flowName"
params={{ flowName: binding.flow }}
search={{ node: binding.node }}
className="min-w-0 truncate font-mono hover:underline"
>
{binding.message}
</Link>
))}
{report && !said ? (
<span className="text-xs text-muted-foreground">
Not reported by the device
</span>
) : null}
{said &&
wired.some((binding) => !wiresAs(said, binding.direction)) ? (
<span className="text-xs text-destructive">
Wired the other way round
</span>
) : null}
</span>
{said?.port === LIVE_PORT ? (
<span className="shrink-0 text-xs text-muted-foreground">
Live view
</span>
) : canEdit && said && wired.length === 0 ? (
<UseInFlow device={device} port={said} size="sm" />
) : null}
</li>
)
})}
</ul>
)}
{device.reported_at ? (
<p className="text-xs text-muted-foreground">
Reported {ago(device.reported_at)}
</p>
) : null}
{canEdit ? <UseInFlow device={device} /> : null}
</PanelSection>
)
}
/**
* Put this device in a flow: its device node there, made when there is none,
* with `port` on it when one is given, and the flow opened on it. A draft,
* so nothing is wired until the flow is published.
*/
export function UseInFlow({
device,
port,
size,
}: {
device: DevicePublic
port?: ReportedPort
size?: "sm"
}) {
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
const { data } = useQuery(flowsQueryOptions())
const flows = data?.data ?? []
const place = useMutation({
mutationFn: async (flowName: string) => {
const { definition } = await FlowsService.readFlow({ name: flowName })
const nodes = definition.nodes ?? []
const existing = deviceNodeOf(nodes, device.id)
const node = existing ?? {
id: freeNodeId(nodes, device.id),
type: DEVICE_TYPE,
title: device.title || device.id,
params: { device: device.id, config: {} },
requires: [],
provides: [],
}
const placed = port ? withPort(node, port) : node
if (placed !== existing) {
await FlowsService.saveFlow({
name: flowName,
requestBody: {
...definition,
nodes: existing
? nodes.map((one) => (one === existing ? placed : one))
: [...nodes, placed],
},
})
}
return { flowName, node: placed.id }
},
onSuccess: ({ flowName, node }) =>
navigate({
to: "/flows/$flowName",
params: { flowName },
search: { node },
}),
onError: (error) => handleError.call(showErrorToast, error as ApiError),
})
if (flows.length === 0) return null
return (
<Select value="" onValueChange={(flowName) => place.mutate(flowName)}>
<SelectTrigger
size={size}
className="shrink-0 justify-self-start"
aria-label={
port
? `Use ${port.port} of ${device.title || device.id} in a flow`
: `Use ${device.title || device.id} in a flow`
}
disabled={place.isPending}
data-testid={port ? "use-port-in-flow" : "use-in-flow"}
>
<SelectValue placeholder="Use in flow" />
</SelectTrigger>
<SelectContent>
{flows.map((flow) => (
<SelectItem key={flow.name} value={flow.name}>
{flow.title || flow.name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}