Panels become devices: per-device documents in the flow repo, a Devices tab

- flow/devices.py: DeviceDef in FLOWS_DIR/_devices/<id>.json, committed under
  the flow store's write lock, 409 on a stale version; nonces in DEVICES_FILE
  outside git; in-memory presence announced as device_status
- api/routes/devices.py: CRUD, pairing with an optional {name, board,
  firmware}, a pending list adopted without a code from the LAN only, unpair;
  /panels/pair, /panels/pair/{code} and GET /panels/{id} kept as aliases,
  PUT /panels/ gone
- _device_may replaces _panel_may, JWT claims unchanged
- panels.json moves into devices once at startup, then .migrated
- MCP list_devices, get_device; search category device
- Devices tab (list, detail with presence, pairing, ordered dashboards,
  touch, I/O), PanelsDialog removed, serialised optimistic saves
This commit is contained in:
Melvin Strobl
2026-10-16 15:48:36 +02:00
parent 3c0912a3fe
commit 28a565e501
56 changed files with 3539 additions and 2404 deletions
@@ -51,7 +51,7 @@ const GROUPS: {
{ category: "output", label: "Outputs", icon: LogOut },
{ category: "dashboard", label: "Dashboards", icon: LayoutDashboard },
{ category: "widget", label: "Widgets", icon: LayoutGrid },
{ category: "panel", label: "Panels", icon: MonitorSmartphone },
{ category: "device", label: "Devices", icon: MonitorSmartphone },
{ category: "secret", label: "Secrets", icon: KeyRound },
{ category: "module", label: "Modules", icon: Package },
{ category: "worker", label: "Workers", icon: Server },
@@ -179,13 +179,8 @@ export function GlobalSearch({
to: "/dashboards/$name",
params: { name: entry.parent ?? "" },
})
// Panels are managed in a dialog on the dashboards screen, which opens
// itself when the address says so.
case "panel":
return navigate({
to: "/dashboards",
search: { panels: true, panel: entry.name },
})
case "device":
return navigate({ to: "/devices/$id", params: { id: entry.name } })
case "secret":
return navigate({ to: "/secrets" })
case "module":
@@ -47,7 +47,7 @@ export function useSelection(names: string[]) {
}
/**
* One entry of the flows or dashboards overview.
* One entry of the flows, dashboards or devices overview.
*
* Stays a link in both modes rather than swapping element: while selecting it
* carries `role="checkbox"` instead, which is what it now behaves as. Ctrl or
@@ -60,6 +60,7 @@ export function OverviewCard({
title,
detail,
draft,
online,
selecting,
selected,
onToggle,
@@ -71,6 +72,8 @@ export function OverviewCard({
detail: ReactNode
/** Has unpublished changes. */
draft?: boolean
/** Connected right now, for a device. */
online?: boolean
selecting: boolean
selected: boolean
onToggle: () => void
@@ -137,6 +140,11 @@ export function OverviewCard({
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
{online ? (
<span className="size-1.5 shrink-0 rounded-full bg-status-success">
<span className="sr-only">Online</span>
</span>
) : null}
</span>
<span className="truncate text-sm text-muted-foreground">{detail}</span>
@@ -43,8 +43,8 @@ export function OverviewToolbar({
searchTestId,
createLabel,
createTestId,
draftCount,
publishing,
draftCount = 0,
publishing = false,
onPublishAll,
selectedCount = 0,
onDeleteSelected,
@@ -59,9 +59,10 @@ export function OverviewToolbar({
createLabel: string
createTestId: string
/** How many of the listed documents have unpublished changes. */
draftCount: number
publishing: boolean
onPublishAll: () => void
draftCount?: number
publishing?: boolean
/** Left out for a list of things that have no drafts, such as devices. */
onPublishAll?: () => void
/** How many entries are picked; above zero the list is in selection mode. */
selectedCount?: number
onDeleteSelected?: () => void
@@ -125,7 +126,7 @@ export function OverviewToolbar({
</Tooltip>
)}
{selectedCount > 0 ? (
{selectedCount > 0 && onDuplicateSelected ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -176,30 +177,32 @@ export function OverviewToolbar({
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
{/* A disabled button gets no pointer events, so the tooltip that
{onPublishAll ? (
<Tooltip>
<TooltipTrigger asChild>
{/* A disabled button gets no pointer events, so the tooltip that
explains why it is disabled needs a wrapper to hang on. */}
<span>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
disabled={draftCount === 0 || publishing}
onClick={onPublishAll}
aria-label="Publish all changes"
data-testid="publish-all"
>
{publishing ? <FluksioLoader /> : <Check />}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{draftCount === 0
? "Nothing unpublished"
: `Publish ${draftCount} unpublished change${draftCount === 1 ? "" : "s"}`}
</TooltipContent>
</Tooltip>
<span>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
disabled={draftCount === 0 || publishing}
onClick={onPublishAll}
aria-label="Publish all changes"
data-testid="publish-all"
>
{publishing ? <FluksioLoader /> : <Check />}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{draftCount === 0
? "Nothing unpublished"
: `Publish ${draftCount} unpublished change${draftCount === 1 ? "" : "s"}`}
</TooltipContent>
</Tooltip>
) : null}
</div>
)
}
@@ -31,6 +31,7 @@ import {
DockSeparator,
PublishButton,
} from "@/components/Common/Dock"
import { devicesQueryOptions } from "@/components/Devices/queries"
import { CanvasTitle } from "@/components/Flow/CanvasTitle"
import { Button } from "@/components/ui/button"
import {
@@ -68,7 +69,6 @@ import { LockNotice } from "./PanelSurface"
import { DashboardPanel, WidgetPanel } from "./panels"
import {
dashboardKeys,
panelsQueryOptions,
useDiscardDashboardDraft,
usePublishDashboard,
useSaveDashboard,
@@ -580,16 +580,16 @@ export function DashboardEditor({
),
)
// A dashboard hanging on a panel beside others is drawn with a rail on it,
// A dashboard hanging on a device beside others is drawn with a rail on it,
// which takes room off the canvas. Show that here rather than letting someone
// arrange against a width the wall does not have.
// ponytail: the first such panel wins — the others differ only in scale.
const { data: panels } = useQuery(panelsQueryOptions())
// ponytail: the first such device wins — the others differ only in scale.
const { data: devices } = useQuery(devicesQueryOptions())
const railDashboards =
(panels?.panels ?? []).find(
(panel) =>
(panel.dashboards ?? []).length > 1 &&
(panel.dashboards ?? []).includes(draft.name),
(devices?.data ?? []).find(
(device) =>
(device.dashboards ?? []).length > 1 &&
(device.dashboards ?? []).includes(draft.name),
)?.dashboards ?? null
// A round face has no room for one: a panel swipes between those instead.
const hasRail = Boolean(railDashboards) && !stacked && !round
@@ -1,783 +0,0 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { Plus, Trash2, X } from "lucide-react"
import { useEffect, useState } from "react"
import {
type ApiError,
CloudService,
type PanelDef,
type PanelPort,
type PanelsConfig,
PanelsService,
} from "@/client"
import { initials, resolveIcon } from "@/components/Dashboard/icons"
import { MessagePicker } from "@/components/Dashboard/panels"
import {
dashboardsQueryOptions,
panelsQueryOptions,
useSavePanels,
useUnpairPanel,
} from "@/components/Dashboard/queries"
import { qualifiedProblem } from "@/components/Flow/deriveEdges"
import { JsonField } from "@/components/Flow/JsonField"
import { InfoTip, PanelSection } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Segmented } from "@/components/ui/segmented"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
/** As many characters as a device puts on the wall. */
const CODE_LENGTH = 6
/** The store only accepts this shape, so say so before the request does. */
const slugify = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
/** Port names the panel firmware knows; `gpio.` takes a pin number. */
// ponytail: free-text ports, no device capability report; add PUT /panels/{id}/device when a second device kind exists
const PORT_SUGGESTIONS = ["gpio.", "backlight", "touch", "mic", "speaker"]
const DIRECTIONS = [
["in", "In"],
["out", "Out"],
] as const
/** What the server accepts as a port name. */
const portOf = (value: string) =>
value.toLowerCase().replace(/[^a-z0-9_.]/g, "")
/** A new port the server will take: a name, and a message named with its flow. */
const ready = (port: PanelPort) =>
Boolean(port.port) && qualifiedProblem(port.message) === null
const objectOnly = (value: unknown) =>
value && typeof value === "object" && !Array.isArray(value)
? null
: "Config is a JSON object"
/**
* Which dashboards hang on which screen, and adopting the screens themselves.
*
* A panel is a device rather than a document: it has no draft and nothing to
* publish, so it lives in a dialog over the dashboards list instead of a page
* of its own. Every change saves as it is made — there is no form to submit.
*/
export function PanelsDialog({ focus }: { focus?: string } = {}) {
const { data: config } = useQuery(panelsQueryOptions())
const { data: dashboards } = useQuery(dashboardsQueryOptions())
const { user } = useAuth()
// Where a screen that cannot reach this instance pairs instead. The
// issuer is the portal as a browser reaches it, which is not always the
// address this machine dialled — enrolment may have named a container.
const { data: cloud } = useQuery({
queryKey: ["cloud", "status"],
queryFn: async () =>
(await CloudService.readStatus()) as {
enrolled: boolean
issuer: string | null
instance_id: string | null
},
})
const remoteHost =
cloud?.enrolled && cloud.issuer && cloud.instance_id
? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.instance_id}`
: ""
const save = useSavePanels()
const { showErrorToast } = useCustomToast()
// Reading panels is any account's; every change to them is a superuser's.
const canEdit = Boolean(user?.is_superuser)
const [name, setName] = useState("")
// A port being added, per panel, until it is ready to save. Held here rather
// than in its row: the rows unmount when the dialog closes and this does
// not, so a port still missing its message is there again on reopening.
const [drafts, setDrafts] = useState<Record<string, PanelPort>>({})
const setDraft = (id: string, draft: PanelPort | null) =>
setDrafts((all) => {
const next = { ...all }
if (draft) next[id] = draft
else delete next[id]
return next
})
const panels = config?.panels ?? []
const known = dashboards?.data ?? []
// The instance's own address, not this browser's: administering through
// the portal puts the page on the portal's origin, and a screen cannot be
// sent there — it has no portal session and could not hold a panel
// credential if it had one.
const host = config?.frontend_host ?? ""
const write = (next: PanelsConfig) =>
save.mutate(
{ ...next, version: config?.version },
{
onError: (error) =>
(error as ApiError).status === 409
? showErrorToast(
"Someone else changed the panels first. Their version is shown now, so make the change again.",
)
: handleError.call(showErrorToast, error as ApiError),
},
)
const replace = (id: string, panel: PanelDef) =>
write({ panels: panels.map((p) => (p.id === id ? panel : p)) })
// Closing saves every new port that is ready, in one write. Its port field
// only commits on blur, and closing does not always blur it.
const saveDrafts = () => {
const done = Object.keys(drafts).filter((id) => ready(drafts[id]))
if (!done.length) return
for (const id of done) setDraft(id, null)
write({
panels: panels.map((panel) =>
done.includes(panel.id)
? { ...panel, io: [...(panel.io ?? []), drafts[panel.id]] }
: panel,
),
})
}
const newId = slugify(name)
const taken = panels.some((panel) => panel.id === newId)
// Arriving about one panel, from its port on a flow canvas, lands on it.
const focusLoaded = Boolean(config) && Boolean(focus)
useEffect(() => {
if (!focusLoaded) return
document
.querySelector(`[data-testid="panel-${focus}"]`)
?.scrollIntoView({ block: "start" })
}, [focusLoaded, focus])
return (
<DialogContent
className="max-h-[80vh] overflow-y-auto outline-none sm:max-w-lg"
// Open on the dialog itself: its first field is a panel's title, which
// would open selected as if to be retyped. Focus still lands inside,
// so Tab starts in the dialog rather than the page behind it.
onOpenAutoFocus={(event) => {
event.preventDefault()
const content = event.currentTarget as HTMLElement
content.focus()
}}
// Escape closes without a blur, and the fields here save on blur.
onEscapeKeyDown={() => (document.activeElement as HTMLElement).blur()}
onCloseAutoFocus={saveDrafts}
>
<DialogHeader>
<DialogTitle>Panels</DialogTitle>
<DialogDescription>
A panel is one screen and the dashboards it shows. Point the device at
a link and it asks for a code you enter here.
</DialogDescription>
</DialogHeader>
<div className="grid gap-6">
{panels.length === 0 ? (
<p className="text-sm text-muted-foreground">
No panels yet. Add one below.
</p>
) : null}
{panels.map((panel) => (
<PanelRow
key={panel.id}
panel={panel}
focused={panel.id === focus}
draft={drafts[panel.id] ?? null}
onDraft={(draft) => setDraft(panel.id, draft)}
host={host}
remoteHost={remoteHost}
canEdit={canEdit}
dashboards={known.map((dashboard) => ({
name: dashboard.name,
title: dashboard.title || dashboard.name,
icon: dashboard.icon ?? "",
}))}
onChange={(next) => replace(panel.id, next)}
onRemove={() =>
write({ panels: panels.filter((p) => p.id !== panel.id) })
}
/>
))}
{canEdit ? (
<>
<Separator />
<div className="grid gap-2">
<form
className="flex items-end gap-2"
onSubmit={(event) => {
event.preventDefault()
if (!newId || taken) return
write({
panels: [...panels, { id: newId, title: name.trim() }],
})
setName("")
}}
>
<div className="grid flex-1 gap-1">
<label className="text-sm" htmlFor="new-panel">
New panel
</label>
<Input
id="new-panel"
value={name}
placeholder="hallway"
autoComplete="off"
data-testid="new-panel-name"
onChange={(event) => setName(event.target.value)}
/>
</div>
<Button
type="submit"
disabled={!newId || taken}
data-testid="add-panel"
>
Add panel
</Button>
</form>
{taken ? (
<p className="text-sm text-destructive">
There is already a panel called {newId}.
</p>
) : null}
{/* The name becomes the panel's address and stays its address,
while the title can change any time. */}
{newId && host && !taken ? (
<p
className="break-all text-xs text-muted-foreground"
data-testid="new-panel-url"
>
Opens at {host}/panel/{newId}
</p>
) : null}
</div>
</>
) : null}
</div>
</DialogContent>
)
}
function PanelRow({
panel,
focused,
draft,
onDraft,
host,
remoteHost,
canEdit,
dashboards,
onChange,
onRemove,
}: {
panel: PanelDef
/** The one a link arrived about, marked so it is found at a glance. */
focused: boolean
/** A port being added to it, held by the dialog. */
draft: PanelPort | null
onDraft: (draft: PanelPort | null) => void
/** Where this instance answers, as it knows itself. */
host: string
/** Where the portal serves this instance, when it is enrolled. */
remoteHost: string
/**
* Whether this account may change anything here. Every control below saves
* through the same superuser-only PUT, so a reader gets the panel and its
* links — worth seeing — with the writes turned off rather than a 403.
*/
canEdit: boolean
dashboards: { name: string; title: string; icon: string }[]
onChange: (next: PanelDef) => void
onRemove: () => void
}) {
const { showSuccessToast, showErrorToast } = useCustomToast()
const [code, setCode] = useState("")
// Unpairing cannot be undone from here — the screen has to be at hand to
// read out a new code — so the button asks a second time before it fires.
// ponytail: a two-step button rather than a dialog, since this one already
// lives inside a dialog.
const [confirmUnpair, setConfirmUnpair] = useState(false)
// Removing the panel throws away more than unpairing does, so it asks the
// same way — the icon turns destructive and only the second press fires.
const [confirmRemove, setConfirmRemove] = useState(false)
const unpair = useUnpairPanel(panel.id)
const assigned = panel.dashboards ?? []
const typed = code.trim().toUpperCase()
// Approving a code adopts whatever is holding it, so say what that is while
// there is still time to stop.
const { data: waiting } = useQuery({
queryKey: ["pending-device", typed],
queryFn: () => PanelsService.pendingDevice({ code: typed }),
enabled: canEdit && typed.length === CODE_LENGTH,
retry: false,
})
const pair = useMutation({
mutationFn: () =>
PanelsService.approvePairing({
panelId: panel.id,
requestBody: { code: typed },
}),
onSuccess: () => {
setCode("")
showSuccessToast("Paired. The screen switches over within a few seconds.")
},
onError: handleError.bind(showErrorToast),
})
const toggle = (dashboard: string) =>
onChange({
...panel,
// ponytail: order follows the order they were ticked. Arrows if anyone
// asks for them.
dashboards: assigned.includes(dashboard)
? assigned.filter((name) => name !== dashboard)
: [...assigned, dashboard],
})
const link = host ? `${host}/panel/${panel.id}` : ""
const remoteLink = remoteHost ? `${remoteHost}/panel` : ""
// The trash icon carries no text of its own, so the confirm step says what
// the next press does through the label a reader or a hover gets.
const removeLabel = confirmRemove
? `Confirm: remove ${panel.id}`
: `Remove ${panel.id}`
return (
<div
className={cn(
"grid scroll-mt-2 gap-3",
// An outline rather than a border, so the row does not shift.
focused && "rounded-md outline-2 outline-offset-4 outline-primary/40",
)}
data-testid={`panel-${panel.id}`}
>
<div className="flex items-center gap-2">
<Input
// Saved on blur or Enter, not per keystroke: each save is a PUT of
// every panel, and the field would snap back to the stored title
// until it lands. Remounted with whatever was saved.
key={panel.title}
defaultValue={panel.title}
placeholder={panel.id}
aria-label={`Title of ${panel.id}`}
disabled={!canEdit}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur()
}}
onBlur={(event) => {
const title = event.currentTarget.value.trim()
event.currentTarget.value = title
if (title !== panel.title) onChange({ ...panel, title })
}}
/>
<span className="shrink-0 text-sm text-muted-foreground">
{panel.id}
</span>
<Button
variant={confirmRemove ? "destructive" : "ghost"}
size="icon-touch"
className={cn("shrink-0", !confirmRemove && "text-muted-foreground")}
aria-label={removeLabel}
title={removeLabel}
data-testid={`remove-panel-${panel.id}`}
disabled={!canEdit}
onBlur={() => setConfirmRemove(false)}
onClick={() => {
if (!confirmRemove) {
setConfirmRemove(true)
return
}
setConfirmRemove(false)
onRemove()
}}
>
<Trash2 />
</Button>
</div>
<label
htmlFor={`touch-${panel.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 here rather than on a dashboard because it describes the
screen: the same dashboard may also hang somewhere with a mouse.
</InfoTip>
<Switch
id={`touch-${panel.id}`}
checked={panel.touch === true}
disabled={!canEdit}
data-testid={`panel-touch-${panel.id}`}
onCheckedChange={(touch) => onChange({ ...panel, touch })}
/>
</label>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
No dashboards to assign yet.
</p>
) : (
<div className="grid gap-2">
{dashboards.map((dashboard) => {
const position = assigned.indexOf(dashboard.name)
const id = `assign-${panel.id}-${dashboard.name}`
// What this dashboard draws on the rail. Shown rather than set: the
// icon belongs to the document, and this dialog has no draft to put
// a change into.
const Glyph = resolveIcon(dashboard.icon)
return (
<label
key={dashboard.name}
htmlFor={id}
className="flex items-center gap-2 text-sm"
>
<Checkbox
id={id}
checked={position >= 0}
data-testid={id}
disabled={!canEdit}
onCheckedChange={() => toggle(dashboard.name)}
/>
<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(dashboard.title)
)}
</span>
<span className="flex-1 truncate">{dashboard.title}</span>
{position >= 0 ? (
<span className="text-xs text-muted-foreground">
{position + 1}
</span>
) : null}
</label>
)
})}
</div>
)}
<PanelIo
panel={panel}
canEdit={canEdit}
draft={draft}
onDraft={onDraft}
onChange={onChange}
/>
<div className="grid gap-2">
<Input
readOnly
value={link}
placeholder="This instance has no address set"
aria-label={`Link for ${panel.id}`}
className="text-muted-foreground"
onFocus={(event) => event.currentTarget.select()}
/>
{remoteLink ? (
<div className="flex items-center gap-2">
<Input
readOnly
value={remoteLink}
aria-label={`Portal link for ${panel.id}`}
className="text-muted-foreground"
data-testid={`remote-link-${panel.id}`}
onFocus={(event) => event.currentTarget.select()}
/>
</div>
) : null}
{canEdit ? (
<>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault()
if (typed) pair.mutate()
}}
>
<Input
value={code}
placeholder="Code shown on the screen"
aria-label={`Pairing code for ${panel.id}`}
autoComplete="off"
maxLength={CODE_LENGTH}
data-testid={`pair-code-${panel.id}`}
onChange={(event) => setCode(event.target.value.toUpperCase())}
/>
<Button
type="submit"
variant="outline"
disabled={!typed || pair.isPending}
data-testid={`pair-${panel.id}`}
>
Pair device
</Button>
</form>
{typed.length === CODE_LENGTH ? (
<p
className="text-xs text-muted-foreground"
data-testid={`pending-${panel.id}`}
>
{waiting
? `${waiting.device}${waiting.remote ? " · via portal" : ""}`
: "No device is waiting on that code."}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
variant={confirmUnpair ? "destructive" : "outline"}
disabled={unpair.isPending}
data-testid={`unpair-${panel.id}`}
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 hanging here back to a pairing code, keeping
the panel, its dashboards and their arrangement. Removing the
panel throws all three away instead. A screen paired through the
portal holds a credential this does not reach; revoke that one
there.
</InfoTip>
</div>
</>
) : null}
</div>
</div>
)
}
/**
* The device's own inputs and outputs, each wired to a message.
*
* Every change saves as it is made, like the rest of the dialog. A new port
* is held by the dialog until it has a name and a qualified message, since
* the server refuses a port without either, and closing the dialog saves one
* that has both.
*/
function PanelIo({
panel,
canEdit,
draft,
onDraft,
onChange,
}: {
panel: PanelDef
canEdit: boolean
draft: PanelPort | null
onDraft: (draft: PanelPort | null) => void
onChange: (next: PanelDef) => void
}) {
const io = panel.io ?? []
const listId = `ports-${panel.id}`
const write = (next: PanelPort[]) => onChange({ ...panel, io: next })
const keepDraft = (next: PanelPort) => {
if (!ready(next)) return onDraft(next)
onDraft(null)
write([...io, next])
}
return (
<PanelSection
title="I/O"
help="Hardware on the device itself, such as a GPIO pin or its microphone. In publishes to the message, Out follows it, and Config goes to the device as it is."
>
<datalist id={listId}>
{PORT_SUGGESTIONS.map((port) => (
<option key={port} value={port} />
))}
</datalist>
{/* `disabled` on a fieldset reaches every control inside it. */}
<fieldset disabled={!canEdit} className="grid min-w-0 gap-4">
{io.length === 0 && !draft ? (
<p className="text-sm text-muted-foreground">No ports yet.</p>
) : null}
{io.map((row, index) => (
<IoRow
// Ports have no id, and two may share a name until one is renamed.
key={index}
row={row}
listId={listId}
testId={`io-${panel.id}-${index}`}
onChange={(next) =>
write(io.map((one, at) => (at === index ? next : one)))
}
onRemove={() => write(io.filter((_, at) => at !== index))}
/>
))}
{draft ? (
<IoRow
row={draft}
listId={listId}
testId={`io-${panel.id}-new`}
onChange={keepDraft}
onType={(port) => onDraft({ ...draft, port })}
onRemove={() => onDraft(null)}
/>
) : null}
{canEdit && !draft ? (
<Button
variant="outline"
size="sm-touch"
className="justify-self-start"
data-testid={`add-port-${panel.id}`}
onClick={() => onDraft({ port: "", message: "", direction: "in" })}
>
<Plus />
Add port
</Button>
) : null}
</fieldset>
</PanelSection>
)
}
function IoRow({
row,
listId,
testId,
onChange,
onType,
onRemove,
}: {
row: PanelPort
/** The datalist of port names to suggest. */
listId: string
testId: string
onChange: (next: PanelPort) => void
/**
* A new port's name as it is typed, so closing the dialog before the field
* blurs still finds it. A saved port commits on blur instead.
*/
onType?: (port: string) => void
onRemove: () => void
}) {
const name = row.port || "new port"
const problem = row.message ? qualifiedProblem(row.message) : null
return (
<div className="grid gap-2" data-testid={testId}>
<div className="flex items-center gap-2">
{onType ? (
<Input
value={row.port}
placeholder="Port, e.g. gpio.4"
aria-label={`Port of ${name}`}
autoComplete="off"
list={listId}
onChange={(event) => onType(portOf(event.currentTarget.value))}
onBlur={() => onChange(row)}
/>
) : (
<Input
// Remounted with whatever was saved, so a refused name snaps back.
key={row.port}
defaultValue={row.port}
placeholder="Port, e.g. gpio.4"
aria-label={`Port of ${name}`}
autoComplete="off"
list={listId}
onBlur={(event) => {
const port = portOf(event.currentTarget.value)
event.currentTarget.value = port || row.port
if (port && port !== row.port) onChange({ ...row, port })
}}
/>
)}
<div className="shrink-0">
<Segmented
value={row.direction}
options={DIRECTIONS}
label={`Direction of ${name}`}
onChange={(direction) =>
onChange({
...row,
direction: direction as PanelPort["direction"],
})
}
/>
</div>
<Button
variant="ghost"
size="icon-touch"
className="shrink-0 text-muted-foreground"
aria-label={`Remove ${name}`}
title={`Remove ${name}`}
onClick={onRemove}
>
<X />
</Button>
</div>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<MessagePicker
value={row.message}
label={`Message of ${name}`}
hideLabel
// A port the device publishes from needs a message a flow takes in.
filter={
row.direction === "in"
? (message) => message.writable !== false
: undefined
}
onPick={(message) => onChange({ ...row, message })}
/>
</div>
<JsonField
value={row.config ?? {}}
label={`Config of ${name}`}
check={objectOnly}
className="h-9 w-28 shrink-0"
onChange={(config) =>
onChange({ ...row, config: config as PanelPort["config"] })
}
/>
</div>
{problem ? <p className="text-xs text-destructive">{problem}</p> : null}
</div>
)
}
@@ -1,12 +1,9 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import {
ApiError,
type DashboardDef_Input,
DashboardsService,
MessagesService,
type PanelsConfig,
PanelsService,
} from "@/client"
export const dashboardKeys = {
@@ -34,66 +31,6 @@ export const dashboardQueryOptions = (name: string, draft = false) => ({
queryFn: () => DashboardsService.readDashboard({ name, draft }),
})
export const panelKeys = {
all: ["panels"] as const,
detail: (id: string) => ["panels", id] as const,
}
/** Every panel and what it shows. Read by the editor to know if a rail is due. */
export const panelsQueryOptions = () => ({
queryKey: panelKeys.all,
queryFn: () => PanelsService.readPanels(),
})
/**
* One panel, as the device hanging on the wall reads it.
*
* A panel credential may read exactly this and the dashboards it names, so
* this is the query the panel route is built on rather than the list above.
*/
export const panelQueryOptions = (id: string) => ({
queryKey: panelKeys.detail(id),
queryFn: () => PanelsService.readPanel({ panelId: id }),
})
/**
* Replace the panels, carrying the version they were read at. Superuser-only
* on the server.
*
* A 409 means someone else saved first, so the list is read again and the
* next change is based on theirs.
*/
export function useSavePanels() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: PanelsConfig) =>
PanelsService.savePanels({ requestBody: body }),
onSuccess: (saved) => {
queryClient.setQueryData(panelKeys.all, saved)
queryClient.invalidateQueries({ queryKey: panelKeys.all })
},
onError: (error) => {
if (error instanceof ApiError && error.status === 409)
queryClient.invalidateQueries({ queryKey: panelKeys.all })
},
})
}
/**
* Stop honouring one panel's credential, and keep the panel.
*
* The screen goes back to showing a pairing code while its dashboards and their
* arrangement stay — deleting the panel is what throws all three away.
* Superuser-only on the server.
*/
export function useUnpairPanel(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => PanelsService.unpairPanel({ panelId: id }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: panelKeys.all }),
})
}
/** Every message any flow declares — what a widget can be pointed at. */
export const messageCatalogQueryOptions = () => ({
queryKey: dashboardKeys.messages,
@@ -0,0 +1,107 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useState } from "react"
import { DevicesService } from "@/client"
import { Button } from "@/components/ui/button"
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
import { deviceKeys } from "./queries"
/** The store only accepts this shape, so say so before the request does. */
const slugify = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
/**
* Name a new device and open it. The name becomes its address
* (`/panel/{name}`) and stays that, while the title can change any time.
*/
export function AddDeviceDialog({
taken,
host,
}: {
/** Ids already in use, refused before the request is. */
taken: string[]
/** Where this instance answers, to show the address the name gives. */
host: string
}) {
const queryClient = useQueryClient()
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
const [name, setName] = useState("")
const id = slugify(name)
const exists = taken.includes(id)
const create = useMutation({
mutationFn: () =>
DevicesService.createDevice({
deviceId: id,
requestBody: { title: name.trim() },
}),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: deviceKeys.all })
setName("")
navigate({ to: "/devices/$id", params: { id: created.id } })
},
onError: handleError.bind(showErrorToast),
})
return (
<DialogContent>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
if (id && !exists) create.mutate()
}}
>
<DialogHeader>
<DialogTitle>Add device</DialogTitle>
<DialogDescription>
Name it after where it hangs. You pair the device itself next.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Input
value={name}
placeholder="Hallway"
aria-label="New device name"
autoComplete="off"
data-testid="new-device-name"
onChange={(event) => setName(event.target.value)}
/>
{exists ? (
<p className="text-sm text-destructive">
There is already a device called {id}.
</p>
) : id && host ? (
<p className="break-all text-xs text-muted-foreground">
Opens at {host}/panel/{id}
</p>
) : null}
</div>
<DialogFooter>
<Button
type="submit"
disabled={!id || exists || create.isPending}
data-testid="create-device"
>
Add device
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}
@@ -0,0 +1,747 @@
import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { ArrowDown, ArrowUp, Plus, X } from "lucide-react"
import { useState } from "react"
import {
type ApiError,
CloudService,
type DeviceDef,
type DevicePort,
type DevicePublic,
DevicesService,
} from "@/client"
import { initials, resolveIcon } from "@/components/Dashboard/icons"
import { MessagePicker } from "@/components/Dashboard/panels"
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
import { qualifiedProblem } from "@/components/Flow/deriveEdges"
import { JsonField } from "@/components/Flow/JsonField"
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 { Segmented } from "@/components/ui/segmented"
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 {
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
/** Port names the panel firmware knows; `gpio.` takes a pin number. */
// ponytail: free-text ports until a device reports what it has (PUT /devices/{id}/report)
const PORT_SUGGESTIONS = ["gpio.", "backlight", "touch", "mic", "speaker"]
const DIRECTIONS = [
["in", "In"],
["out", "Out"],
] as const
/** What the server accepts as a port name. */
const portOf = (value: string) =>
value.toLowerCase().replace(/[^a-z0-9_.]/g, "")
/** A new port the server will take: a name, and a message named with its flow. */
const ready = (port: DevicePort) =>
Boolean(port.port) && qualifiedProblem(port.message) === null
const objectOnly = (value: unknown) =>
value && typeof value === "object" && !Array.isArray(value)
? null
: "Config is a JSON object"
/** "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>
<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}>
<DeviceIo device={device} canEdit={canEdit} onChange={write} />
</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>
)
}
/** 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 ?? []).map((entry) => (
<div
key={entry.request}
className="flex items-center gap-2 rounded-sm text-sm"
>
<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. A screen paired through the portal is revoked
there instead.
</InfoTip>
</div>
</>
) : null}
</PanelSection>
)
}
/**
* The device's own inputs and outputs, each wired to a message.
*
* A new port is held here until it has a name and a qualified message, since
* the server refuses a port without either.
*/
function DeviceIo({
device,
canEdit,
onChange,
}: {
device: DeviceDef
canEdit: boolean
onChange: (changes: Partial<DeviceDef>) => void
}) {
const [draft, setDraft] = useState<DevicePort | null>(null)
const io = device.io ?? []
const listId = `ports-${device.id}`
const write = (io: DevicePort[]) => onChange({ io })
const keepDraft = (next: DevicePort) => {
if (!ready(next)) return setDraft(next)
setDraft(null)
write([...io, next])
}
return (
<PanelSection
title="I/O"
help="Hardware on the device itself, such as a GPIO pin or its microphone. In publishes to the message, Out follows it, and Config goes to the device as it is."
>
<datalist id={listId}>
{PORT_SUGGESTIONS.map((port) => (
<option key={port} value={port} />
))}
</datalist>
{/* `disabled` on a fieldset reaches every control inside it. */}
<fieldset disabled={!canEdit} className="grid min-w-0 gap-4">
{io.length === 0 && !draft ? (
<p className="text-sm text-muted-foreground">No ports yet.</p>
) : null}
{io.map((row, index) => (
<IoRow
// Ports have no id, and two may share a name until one is renamed.
key={index}
row={row}
listId={listId}
testId={`io-${device.id}-${index}`}
onChange={(next) =>
write(io.map((one, at) => (at === index ? next : one)))
}
onRemove={() => write(io.filter((_, at) => at !== index))}
/>
))}
{draft ? (
<IoRow
row={draft}
listId={listId}
testId={`io-${device.id}-new`}
onChange={keepDraft}
onType={(port) => setDraft({ ...draft, port })}
onRemove={() => setDraft(null)}
/>
) : null}
{canEdit && !draft ? (
<Button
variant="outline"
size="sm-touch"
className="justify-self-start"
data-testid={`add-port-${device.id}`}
onClick={() => setDraft({ port: "", message: "", direction: "in" })}
>
<Plus />
Add port
</Button>
) : null}
</fieldset>
</PanelSection>
)
}
function IoRow({
row,
listId,
testId,
onChange,
onType,
onRemove,
}: {
row: DevicePort
/** The datalist of port names to suggest. */
listId: string
testId: string
onChange: (next: DevicePort) => void
/** A new port's name as it is typed. A saved port commits on blur instead. */
onType?: (port: string) => void
onRemove: () => void
}) {
const name = row.port || "new port"
const problem = row.message ? qualifiedProblem(row.message) : null
return (
<div className="grid gap-2" data-testid={testId}>
<div className="flex items-center gap-2">
{onType ? (
<Input
value={row.port}
placeholder="Port, e.g. gpio.4"
aria-label={`Port of ${name}`}
autoComplete="off"
list={listId}
onChange={(event) => onType(portOf(event.currentTarget.value))}
onBlur={() => onChange(row)}
/>
) : (
<Input
// Remounted with whatever was saved, so a refused name snaps back.
key={row.port}
defaultValue={row.port}
placeholder="Port, e.g. gpio.4"
aria-label={`Port of ${name}`}
autoComplete="off"
list={listId}
onBlur={(event) => {
const port = portOf(event.currentTarget.value)
event.currentTarget.value = port || row.port
if (port && port !== row.port) onChange({ ...row, port })
}}
/>
)}
<div className="shrink-0">
<Segmented
value={row.direction}
options={DIRECTIONS}
label={`Direction of ${name}`}
onChange={(direction) =>
onChange({
...row,
direction: direction as DevicePort["direction"],
})
}
/>
</div>
<Button
variant="ghost"
size="icon-touch"
className="shrink-0 text-muted-foreground"
aria-label={`Remove ${name}`}
title={`Remove ${name}`}
onClick={onRemove}
>
<X />
</Button>
</div>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<MessagePicker
value={row.message}
label={`Message of ${name}`}
hideLabel
// A port the device publishes from needs a message a flow takes in.
filter={
row.direction === "in"
? (message) => message.writable !== false
: undefined
}
onPick={(message) => onChange({ ...row, message })}
/>
</div>
<JsonField
value={row.config ?? {}}
label={`Config of ${name}`}
check={objectOnly}
className="h-9 w-28 shrink-0"
onChange={(config) =>
onChange({ ...row, config: config as DevicePort["config"] })
}
/>
</div>
{problem ? <p className="text-xs text-destructive">{problem}</p> : null}
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import {
type DeviceDef,
type DevicePublic,
type DevicesPublic,
DevicesService,
} from "@/client"
export const deviceKeys = {
all: ["devices"] as const,
detail: (id: string) => ["devices", id] as const,
pending: ["devices-pending"] as const,
}
/** Every device, what it shows and whether it is connected. */
export const devicesQueryOptions = () => ({
queryKey: deviceKeys.all,
queryFn: () => DevicesService.readDevices(),
})
/**
* One device, as the screen hanging on the wall reads it.
*
* A device credential may read exactly this and the dashboards it names, so
* this is the query the `/panel/{id}` route is built on rather than the list.
*/
export const deviceQueryOptions = (id: string) => ({
queryKey: deviceKeys.detail(id),
queryFn: () => DevicesService.readDevice({ deviceId: id }),
})
/** Devices asking for a code right now. Polled: nothing announces a new one. */
export const pendingQueryOptions = () => ({
queryKey: deviceKeys.pending,
queryFn: () => DevicesService.readPending(),
refetchInterval: 3000,
})
/**
* Save changes to one device. Superuser-only.
*
* A change is shown at once, and the saves of one device go one at a time,
* each laid over the device as the cache holds it when it is sent and
* against the version the save before it left. So a title and a switch
* changed in one gesture neither refuse nor undo each other. Any failure, a
* 409 from someone else's save included, reads the list again.
*/
export function useSaveDevice(id: string) {
const queryClient = useQueryClient()
const stored = () =>
queryClient
.getQueryData<DevicesPublic>(deviceKeys.all)
?.data?.find((one) => one.id === id)
const patch = (changes: Partial<DevicePublic>) =>
queryClient.setQueryData<DevicesPublic>(deviceKeys.all, (all) =>
all
? {
...all,
data: all.data?.map((one) =>
one.id === id ? { ...one, ...changes } : one,
),
}
: all,
)
return useMutation({
scope: { id: `device:${id}` },
onMutate: (changes: Partial<DeviceDef>) => patch(changes),
mutationFn: (changes: Partial<DeviceDef>) => {
const current = stored()
if (!current) throw new Error(`No device named ${id}`)
// Presence is the list's, not part of what is saved.
const { online: _online, last_seen: _seen, ...device } = current
return DevicesService.saveDevice({
deviceId: id,
requestBody: { ...device, ...changes },
})
},
// The version alone: the cache already shows this change, and a save
// queued behind it may have shown another since.
onSuccess: (saved) => patch({ version: saved.version }),
onError: () => queryClient.invalidateQueries({ queryKey: deviceKeys.all }),
})
}
/**
* Adopt the device showing `code`, or the pending `request`. The device
* collects its credential on its next poll.
*/
export function usePairDevice(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: { code?: string; request?: string }) =>
DevicesService.approvePairing({ deviceId: id, requestBody: body }),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: deviceKeys.pending }),
})
}
/** Send the screen hanging here back to a pairing code, keeping the device. */
export function useUnpairDevice(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => DevicesService.unpairDevice({ deviceId: id }),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: deviceKeys.all }),
})
}
+3 -3
View File
@@ -512,10 +512,10 @@ function FlowEditorInner({
params: { name: (rest ?? "").split(":")[0] },
})
} else if (kind === "panel") {
// A panel has no page of its own, only its row in the Panels dialog.
// A device's own port: its page in the Devices tab.
navigate({
to: "/dashboards",
search: { panels: true, panel: (rest ?? "").split(":")[0] },
to: "/devices/$id",
params: { id: (rest ?? "").split(":")[0] },
})
} else if (kind === "flow") {
navigate({
+17 -4
View File
@@ -3,7 +3,8 @@ import { useQueryClient } from "@tanstack/react-query"
import { useEffect } from "react"
import { ApiError, FlowsService, OpenAPI } from "@/client"
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
import { dashboardKeys } from "@/components/Dashboard/queries"
import { deviceKeys } from "@/components/Devices/queries"
import { healthKeys } from "@/components/Health/queries"
import { runKeys } from "@/components/Runs/queries"
import { connectionStore } from "@/lib/connectionStore"
@@ -111,6 +112,13 @@ type FlowEvent =
paused?: string[]
}
| { type: "dashboard_changed"; dashboard?: string; ts?: number }
| {
type: "device_status"
device: string
online: boolean
last_seen?: string | null
ts?: number
}
| {
type: "run_started" | "run_finished"
flow: string
@@ -258,7 +266,7 @@ function open(url: string) {
for (const queryKey of [
flowKeys.all,
dashboardKeys.all,
panelKeys.all,
deviceKeys.all,
runKeys.all,
healthKeys.all,
]) {
@@ -399,7 +407,7 @@ function open(url: string) {
client?.invalidateQueries({ queryKey: flowKeys.all })
break
case "dashboard_changed":
// Someone published, or changed which dashboards a panel shows. Refetch
// Someone published, or changed which dashboards a device shows. Refetch
// rather than reload: a wall screen must not blank or sign in again.
// `exact` matters — the list key is a prefix of every detail key,
// including the draft an editor may have open.
@@ -407,9 +415,14 @@ function open(url: string) {
client?.invalidateQueries({
queryKey: message.dashboard
? dashboardKeys.detail(message.dashboard)
: panelKeys.all,
: deviceKeys.all,
})
break
case "device_status":
// A device's socket opened or closed: the Devices tab reads presence
// off the list.
client?.invalidateQueries({ queryKey: deviceKeys.all, exact: true })
break
case "run_started":
case "run_finished":
// One invalidation covers the lot: the list, the flow counts, the run
@@ -9,6 +9,7 @@ import {
KeyRound,
LayoutDashboard,
LogOut,
MonitorSmartphone,
Package,
Search,
Server,
@@ -49,6 +50,8 @@ const baseItems: Item[] = [
{ icon: Home, title: "Home", path: "/" },
{ icon: Workflow, title: "Flows", path: "/flows" },
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
// What hangs on the wall and which of the dashboards it shows.
{ icon: MonitorSmartphone, title: "Devices", path: "/devices" },
// What a batch flow leaves behind. Beside the flows rather than under Home:
// an experiment log is browsed, not glanced at.
{ icon: FlaskConical, title: "Runs", path: "/runs" },