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.
)
}
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 (
{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 (
)
})}
{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 (
{/* `disabled` on a fieldset reaches every control inside it. */}
)
}
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"],
})
}
/>