import { useMutation, useQuery } from "@tanstack/react-query" import { Trash2 } from "lucide-react" import { useState } from "react" import { type ApiError, CloudService, type PanelDef, type PanelsConfig, PanelsService, } from "@/client" import { dashboardsQueryOptions, panelsQueryOptions, useSavePanels, useUnpairPanel, } from "@/components/Dashboard/queries" 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 { Separator } from "@/components/ui/separator" import useAuth from "@/hooks/useAuth" import useCustomToast from "@/hooks/useCustomToast" 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, "_") /** * 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 installation 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 installation_id: string | null }, }) const remoteHost = cloud?.enrolled && cloud.issuer && cloud.installation_id ? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.installation_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 installation'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, { onError: (error) => 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 — this installation's own address for a screen on your network, or the portal's for one hanging where this machine is not reachable.
{panels.length === 0 ? (

No panels yet. Add one below.

) : null} {panels.map((panel) => ( ({ name: dashboard.name, title: dashboard.title || dashboard.name, }))} 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 installation answers, as it knows itself. */ host: string /** Where the portal serves this installation, 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 }[] 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) 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` : "" 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}` return ( ) })}
)}
event.currentTarget.select()} /> {remoteLink ? (
event.currentTarget.select()} /> via portal
) : 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 and keeps the panel, its dashboards and their arrangement — unlike removing the panel, which throws all three away. A screen paired through the portal holds a credential this does not reach; that one is revoked at the portal.

) : null}
) }