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 ( Panels 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.
{panels.length === 0 ? (

No panels yet. Add one below.

) : null} {panels.map((panel) => ( ({ 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 ? ( <>
{ event.preventDefault() if (!newId || taken) return write({ panels: [...panels, { id: newId, title: name.trim() }], }) setName("") }} >
setName(event.target.value)} />
{taken ? (

There is already a panel called {newId}.

) : null} ) : null}
) } 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 (
onChange({ ...panel, title: event.target.value }) } /> {panel.id}
{dashboards.length === 0 ? (

No dashboards to assign yet.

) : (
{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 ( ) })}
)}
event.currentTarget.select()} /> {remoteLink ? (
event.currentTarget.select()} />
) : null} {canEdit ? ( <>
{ event.preventDefault() if (typed) pair.mutate() }} > setCode(event.target.value.toUpperCase())} />
{typed.length === CODE_LENGTH ? (

{waiting ? `${waiting.device}${waiting.remote ? " · via portal" : ""}` : "No device is waiting on that code."}

) : null}
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.
) : null}
) } /** * 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(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 ( {PORT_SUGGESTIONS.map((port) => ( {/* `disabled` on a fieldset reaches every control inside it. */}
{io.length === 0 && !draft ? (

No ports yet.

) : null} {io.map((row, index) => ( write(io.map((one, at) => (at === index ? next : one))) } onRemove={() => write(io.filter((_, at) => at !== index))} /> ))} {draft ? ( setDraft(null)} /> ) : null} {canEdit && !draft ? ( ) : null}
) } 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 (
{ const port = portOf(event.currentTarget.value) event.currentTarget.value = port || row.port if (port && port !== row.port) onChange({ ...row, port }) }} />
onChange({ ...row, direction: direction as PanelPort["direction"], }) } />
message.writable !== false : undefined } onPick={(message) => onChange({ ...row, message })} />
onChange({ ...row, config: config as PanelPort["config"] }) } />
) }