The Panels dialog re-reads the list on 409. A panels.json without a version reads as 0.
661 lines
22 KiB
TypeScript
661 lines
22 KiB
TypeScript
import { useMutation, useQuery } from "@tanstack/react-query"
|
|
import { Plus, Trash2, X } from "lucide-react"
|
|
import { 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 { 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, "")
|
|
|
|
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() {
|
|
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("")
|
|
|
|
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)) })
|
|
|
|
const newId = slugify(name)
|
|
const taken = panels.some((panel) => panel.id === newId)
|
|
|
|
return (
|
|
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
|
|
<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}
|
|
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 />
|
|
|
|
<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}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</DialogContent>
|
|
)
|
|
}
|
|
|
|
function PanelRow({
|
|
panel,
|
|
host,
|
|
remoteHost,
|
|
canEdit,
|
|
dashboards,
|
|
onChange,
|
|
onRemove,
|
|
}: {
|
|
panel: PanelDef
|
|
/** 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="grid gap-3" data-testid={`panel-${panel.id}`}>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={panel.title}
|
|
placeholder={panel.id}
|
|
aria-label={`Title of ${panel.id}`}
|
|
disabled={!canEdit}
|
|
onChange={(event) =>
|
|
onChange({ ...panel, title: event.target.value })
|
|
}
|
|
/>
|
|
<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} 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 here until it has both a name and a message, since the server
|
|
* refuses a port without either.
|
|
*/
|
|
function PanelIo({
|
|
panel,
|
|
canEdit,
|
|
onChange,
|
|
}: {
|
|
panel: PanelDef
|
|
canEdit: boolean
|
|
onChange: (next: PanelDef) => void
|
|
}) {
|
|
const io = panel.io ?? []
|
|
const [draft, setDraft] = useState<PanelPort | null>(null)
|
|
const listId = `ports-${panel.id}`
|
|
|
|
const write = (next: PanelPort[]) => onChange({ ...panel, io: next })
|
|
const keepDraft = (next: PanelPort) => {
|
|
if (!next.port || !next.message) 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-${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}
|
|
onRemove={() => setDraft(null)}
|
|
/>
|
|
) : null}
|
|
{canEdit && !draft ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm-touch"
|
|
className="justify-self-start"
|
|
data-testid={`add-port-${panel.id}`}
|
|
onClick={() => setDraft({ port: "", message: "", direction: "in" })}
|
|
>
|
|
<Plus />
|
|
Add port
|
|
</Button>
|
|
) : null}
|
|
</fieldset>
|
|
</PanelSection>
|
|
)
|
|
}
|
|
|
|
function IoRow({
|
|
row,
|
|
listId,
|
|
testId,
|
|
onChange,
|
|
onRemove,
|
|
}: {
|
|
row: PanelPort
|
|
/** The datalist of port names to suggest. */
|
|
listId: string
|
|
testId: string
|
|
onChange: (next: PanelPort) => void
|
|
onRemove: () => void
|
|
}) {
|
|
const name = row.port || "new port"
|
|
return (
|
|
<div className="grid gap-2" data-testid={testId}>
|
|
<div className="flex items-center gap-2">
|
|
<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>
|
|
</div>
|
|
)
|
|
}
|