Files
app/frontend/src/components/Dashboard/PanelsDialog.tsx
T

436 lines
14 KiB
TypeScript

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 { 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, "_")
/**
* 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 (
<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 this installation's own
address for a screen on your network, or the portal's for one hanging
where this machine is not reachable.
</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,
}))}
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 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)
// 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"
className={cn(
"size-11 shrink-0 md:size-8",
!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>
{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}`
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 className="flex-1 truncate">{dashboard.title}</span>
{position >= 0 ? (
<span className="text-xs text-muted-foreground">
{position + 1}
</span>
) : null}
</label>
)
})}
</div>
)}
<div className="grid gap-2">
<Input
readOnly
value={link}
placeholder="This installation 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()}
/>
<span className="shrink-0 text-xs text-muted-foreground">
via portal
</span>
</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>
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
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.
</p>
</div>
</>
) : null}
</div>
</div>
)
}