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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user