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.
854 lines
28 KiB
TypeScript
854 lines
28 KiB
TypeScript
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>
|
||
)
|
||
}
|