import { useMutation, useQuery } from "@tanstack/react-query"
import { Link, useNavigate } from "@tanstack/react-router"
import {
ArrowDown,
ArrowLeft,
ArrowLeftRight,
ArrowRight,
ArrowUp,
X,
} from "lucide-react"
import { useState } from "react"
import {
type ApiError,
CloudService,
type DeviceDef,
type DevicePublic,
DevicesService,
FlowsService,
} from "@/client"
import { initials, resolveIcon } from "@/components/Dashboard/icons"
import { MediaView } from "@/components/Dashboard/MediaWidget"
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
import { flowsQueryOptions } from "@/components/Flow/queries"
import { InfoTip, PanelSection } from "@/components/Flow/SidePanel"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { ago, cn } from "@/lib/utils"
import { handleError } from "@/utils"
import {
DEVICE_TYPE,
type DeviceReport,
deviceNodeOf,
freeNodeId,
LIVE_PORT,
type ReportedPort,
reportedPorts,
reportOf,
wiresAs,
withPort,
} from "./ports"
import {
devicesQueryOptions,
pendingQueryOptions,
usePairDevice,
useSaveDevice,
useUnpairDevice,
} from "./queries"
const CARD = "rounded-lg border border-border bg-card p-4 shadow-e1"
/** As many characters as a device puts on the wall. */
const CODE_LENGTH = 6
/** "Online", or when it was last heard from. */
export function presenceText(device: DevicePublic) {
if (device.online) return "Online"
return device.last_seen ? `Seen ${ago(device.last_seen)}` : "Not seen yet"
}
/**
* One device: whether it is there, how it pairs, what it shows and its own
* hardware.
*
* Every change saves as it is made, against the version it was read at, so
* there is no form to submit and no draft to publish: a device is a thing on
* the wall rather than a document.
*/
export function DeviceDetail({ id }: { id: string }) {
const { data, isPending } = useQuery(devicesQueryOptions())
const { user } = useAuth()
const save = useSaveDevice(id)
const { showErrorToast } = useCustomToast()
// Reading devices is any account's, every change to them a superuser's.
const canEdit = Boolean(user?.is_superuser)
if (isPending) return
const device = data?.data?.find((one) => one.id === id)
if (!device) {
return (
Devices
No device named {id}.
)
}
const write = (changes: Partial) =>
save.mutate(changes, {
onError: (error) =>
(error as ApiError).status === 409
? showErrorToast(
"Someone else changed this device first. Their version is shown now, so make the change again.",
)
: handleError.call(showErrorToast, error as ApiError),
})
return (
{
if (event.key === "Enter") event.currentTarget.blur()
}}
onBlur={(event) => {
const title = event.currentTarget.value.trim()
event.currentTarget.value = title
if (title !== device.title) onChange({ title })
}}
/>
{device.id}
)
}
/**
* What the device's camera sees, once a flow wires its `camera` port.
*
* Watching is what turns the camera on: it sends frames only while this
* preview, a tile or a flow reading them wants them.
*/
function Camera({ device }: { device: DevicePublic }) {
const bound = (device.bindings ?? []).find(
(binding) => binding.port === "camera" && binding.direction === "in",
)
if (!bound) return null
return (
)
}
/** Which dashboards the device shows, in the order its rail carries them. */
function Dashboards({
device,
canEdit,
onChange,
}: {
device: DeviceDef
canEdit: boolean
onChange: (changes: Partial) => void
}) {
const { data } = useQuery(dashboardsQueryOptions())
const known = data?.data ?? []
const assigned = device.dashboards ?? []
const byName = new Map(known.map((dashboard) => [dashboard.name, dashboard]))
const free = known.filter((dashboard) => !assigned.includes(dashboard.name))
const set = (dashboards: string[]) => onChange({ dashboards })
const move = (from: number, by: number) => {
const next = [...assigned]
const [name] = next.splice(from, 1)
next.splice(from + by, 0, name)
set(next)
}
return (
{assigned.length === 0 ? (
No dashboards yet. The device shows nothing until it has one.
) : (
{assigned.map((name, index) => {
const dashboard = byName.get(name)
const title = dashboard?.title || name
// What this dashboard draws on the rail.
const Glyph = resolveIcon(dashboard?.icon ?? "")
return (
)
})}
)}
{canEdit && free.length > 0 ? (
) : null}
)
}
/**
* How a screen gets its credential: by the code it shows, or picked from the
* devices asking on this network. Plus the addresses to point one at, and the
* way to sign the current one out.
*/
function Pairing({
id,
host,
canEdit,
}: {
id: string
/** Where this instance answers, as it knows itself. */
host: string
canEdit: boolean
}) {
const { showSuccessToast, showErrorToast } = useCustomToast()
const [code, setCode] = useState("")
// Unpairing cannot be undone from here, since the screen has to be at hand
// to read out a new code, so the button asks a second time before it fires.
const [confirmUnpair, setConfirmUnpair] = useState(false)
const pair = usePairDevice(id)
const unpair = useUnpairDevice(id)
const typed = code.trim().toUpperCase()
// Where a screen that cannot reach this instance pairs instead. The issuer
// is the portal as a browser reaches it.
const { data: cloud } = useQuery({
queryKey: ["cloud", "status"],
queryFn: async () =>
(await CloudService.readStatus()) as {
enrolled: boolean
issuer: string | null
instance_id: string | null
},
})
const remoteLink =
cloud?.enrolled && cloud.issuer && cloud.instance_id
? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.instance_id}/panel`
: ""
// The instance's own address, not this browser's: administering through
// the portal puts the page on the portal's origin, which a screen on this
// network cannot be sent to.
const link = host ? `${host}/panel/${id}` : ""
const { data: pending } = useQuery({
...pendingQueryOptions(),
enabled: canEdit,
})
// Approving a code adopts whatever holds it, so say what that is while
// there is still time to stop.
const { data: waiting } = useQuery({
queryKey: ["pending-device", typed],
queryFn: () => DevicesService.pendingDevice({ code: typed }),
enabled: canEdit && typed.length === CODE_LENGTH,
retry: false,
})
const paired = {
onSuccess: () => {
setCode("")
showSuccessToast("Paired. The screen switches over within a few seconds.")
},
onError: (error: Error) =>
handleError.call(showErrorToast, error as ApiError),
}
return (
Sends the screen back to a pairing code and keeps the device and
its dashboards, also for a screen paired through the portal.
>
) : null}
)
}
/** "1024×600, touch", or "360×360, round, touch". */
function displayText(display: NonNullable) {
return [
`${display.w}×${display.h}`,
display.shape === "round" ? "round" : null,
display.touch ? "touch" : null,
]
.filter(Boolean)
.join(", ")
}
/**
* What the device says it has, and its ports as the published flows wire
* them: each port it reports, with the message it publishes or follows or a
* way to wire it, and any port wired that it does not report. Wiring is done
* in a flow, so this only reads.
*/
function Capabilities({
device,
canEdit,
}: {
device: DevicePublic
canEdit: boolean
}) {
const report = reportOf(device)
const reported = reportedPorts(device)
const bindings = device.bindings ?? []
const camera = report?.camera
const features = Object.keys(report?.features ?? {}).filter(
(name) => report?.features?.[name],
)
const facts = report
? [
["Board", [report.board, report.platform].filter(Boolean).join(" · ")],
["Firmware", report.firmware],
["Display", report.display ? displayText(report.display) : "None"],
camera?.sensor
? [
"Camera",
[
camera.sensor,
camera.w ? `${camera.w}×${camera.h} at ${camera.fps} fps` : "",
]
.filter(Boolean)
.join(", "),
]
: null,
features.length ? ["Features", features.join(", ")] : null,
].filter((fact): fact is [string, string] => Boolean(fact?.[1]))
: []
// A port the device lists, then one wired that it does not, so a typo in a
// flow shows up here as well as on the canvas.
const rows = [
...(reported ?? []).map((port) => ({ port: port.port, said: port })),
...[...new Set(bindings.map((binding) => binding.port))]
.filter((port) => !reported?.some((one) => one.port === port))
.map((port) => ({ port, said: undefined })),
]
return (
{report ? (
{facts.map(([term, value]) => (
{term}
{value}
))}
) : (
Not reported yet. A device says what it has once it is paired and
connected.
)}
{rows.length === 0 ? (
{report ? "It reports no ports." : "No ports wired yet."}
) : (
{rows.map(({ port, said }) => {
const wired = bindings.filter((binding) => binding.port === port)
const either = (said?.modes?.length ?? 0) > 1
const direction = said?.direction ?? wired[0]?.direction
const Way = either
? ArrowLeftRight
: direction === "in"
? ArrowRight
: ArrowLeft
const way = either
? "Publishes or follows"
: direction === "in"
? "Publishes"
: "Follows"
return (
{port}
{said?.dtype ?? ""}
{wired.map((binding) => (
{binding.message}
))}
{report && !said ? (
Not reported by the device
) : null}
{said &&
wired.some((binding) => !wiresAs(said, binding.direction)) ? (
Wired the other way round
) : null}
{said?.port === LIVE_PORT ? (
Live view
) : canEdit && said && wired.length === 0 ? (
) : null}
)
})}
)}
{device.reported_at ? (
Reported {ago(device.reported_at)}
) : null}
{canEdit ? : null}
)
}
/**
* Put this device in a flow: its device node there, made when there is none,
* with `port` on it when one is given, and the flow opened on it. A draft,
* so nothing is wired until the flow is published.
*/
export function UseInFlow({
device,
port,
size,
}: {
device: DevicePublic
port?: ReportedPort
size?: "sm"
}) {
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
const { data } = useQuery(flowsQueryOptions())
const flows = data?.data ?? []
const place = useMutation({
mutationFn: async (flowName: string) => {
const { definition } = await FlowsService.readFlow({ name: flowName })
const nodes = definition.nodes ?? []
const existing = deviceNodeOf(nodes, device.id)
const node = existing ?? {
id: freeNodeId(nodes, device.id),
type: DEVICE_TYPE,
title: device.title || device.id,
params: { device: device.id, config: {} },
requires: [],
provides: [],
}
const placed = port ? withPort(node, port) : node
if (placed !== existing) {
await FlowsService.saveFlow({
name: flowName,
requestBody: {
...definition,
nodes: existing
? nodes.map((one) => (one === existing ? placed : one))
: [...nodes, placed],
},
})
}
return { flowName, node: placed.id }
},
onSuccess: ({ flowName, node }) =>
navigate({
to: "/flows/$flowName",
params: { flowName },
search: { node },
}),
onError: (error) => handleError.call(showErrorToast, error as ApiError),
})
if (flows.length === 0) return null
return (
)
}