Device security and robustness (openpanel C0): check code, pnc, lenient report
- POST /devices/pair answers a 2-character `check`, listed by /devices/pending and shown beside each waiting entry; five codes a minute per address, and at 50 waiting the oldest gives way instead of a 429 - portal-minted device tokens are asked for with the device's nonce (`pnc`) and held against it, so unpair and delete revoke them; one without `pnc` stands only while the nonce is 0 - a device credential acts as the device (`device_principal`), not as the approving superuser, so deleting that account unpairs nothing - the report is validated port by port: unknown enum values read as `unknown`, unreadable ports and sections are left out and logged, `config` may name an entry of `config_schemas`; 422 only for no schema 1 or no object - devices.json is parsed once per change (cached by stat), delete drops the report and keeps the nonce, a busy recheck runs in the background - scope and io only from enabled flows (start/stop tells the devices), a port bound twice gives one io row - the Devices tab's saves cancel and hold back device refetches until settled
This commit is contained in:
@@ -477,11 +477,29 @@ function Pairing({
|
||||
? "Waiting to be paired"
|
||||
: "No device is waiting"}
|
||||
</span>
|
||||
{pending?.some((entry) => !entry.needs_code) ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Compare the check beside each with the one the device shows next
|
||||
to its code before choosing Pair this one.
|
||||
</p>
|
||||
) : null}
|
||||
{(pending ?? []).map((entry) => (
|
||||
<div
|
||||
key={entry.request}
|
||||
className="flex items-center gap-2 rounded-sm text-sm"
|
||||
>
|
||||
{entry.check ? (
|
||||
<span
|
||||
className="shrink-0 rounded-sm border border-border px-2 py-1 font-mono text-base font-medium tracking-[0.2em]"
|
||||
title="The check this device shows next to its code"
|
||||
data-testid="pending-check"
|
||||
>
|
||||
<span aria-hidden="true">{entry.check}</span>
|
||||
<span className="sr-only">
|
||||
Check {entry.check.split("").join(" ")}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="grid min-w-0 flex-1">
|
||||
<span className="truncate">
|
||||
{entry.name || entry.board || "Unnamed device"}
|
||||
@@ -546,8 +564,7 @@ function Pairing({
|
||||
</Button>
|
||||
<InfoTip label="Unpair screen">
|
||||
Sends the screen back to a pairing code and keeps the device and
|
||||
its dashboards. A screen paired through the portal is revoked
|
||||
there instead.
|
||||
its dashboards, also for a screen paired through the portal.
|
||||
</InfoTip>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -25,7 +25,7 @@ export type DeviceReport = {
|
||||
display?: {
|
||||
w: number
|
||||
h: number
|
||||
shape?: "rect" | "round"
|
||||
shape?: "rect" | "round" | "unknown"
|
||||
touch?: boolean
|
||||
} | null
|
||||
camera?: { sensor?: string; w?: number; h?: number; fps?: number } | null
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
type QueryClient,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query"
|
||||
|
||||
import {
|
||||
type DeviceDef,
|
||||
@@ -11,6 +15,20 @@ export const deviceKeys = {
|
||||
all: ["devices"] as const,
|
||||
detail: (id: string) => ["devices", id] as const,
|
||||
pending: ["devices-pending"] as const,
|
||||
/** Every save of a device, whichever one. */
|
||||
save: ["devices", "save"] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the devices again, unless a save is under way.
|
||||
*
|
||||
* A refetch started during a save can land before the save does, which shows
|
||||
* the change undone and hands the next save an older version, which the
|
||||
* server refuses with 409. The last save to settle reads the list instead.
|
||||
*/
|
||||
export function refetchDevices(client: QueryClient, exact = true) {
|
||||
if (client.isMutating({ mutationKey: deviceKeys.save })) return
|
||||
client.invalidateQueries({ queryKey: deviceKeys.all, exact })
|
||||
}
|
||||
|
||||
/** Every device, what it shows and whether it is connected. */
|
||||
@@ -43,8 +61,10 @@ export const pendingQueryOptions = () => ({
|
||||
* A change is shown at once, and the saves of one device go one at a time,
|
||||
* each laid over the device as the cache holds it when it is sent and
|
||||
* against the version the save before it left. So a title and a switch
|
||||
* changed in one gesture neither refuse nor undo each other. Any failure, a
|
||||
* 409 from someone else's save included, reads the list again.
|
||||
* changed in one gesture neither refuse nor undo each other. A refetch the
|
||||
* socket asks for meanwhile waits (`refetchDevices`), and the last save to
|
||||
* settle reads the list again, after a failure too, a 409 from someone else's
|
||||
* save included.
|
||||
*/
|
||||
export function useSaveDevice(id: string) {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -64,8 +84,13 @@ export function useSaveDevice(id: string) {
|
||||
: all,
|
||||
)
|
||||
return useMutation({
|
||||
mutationKey: deviceKeys.save,
|
||||
scope: { id: `device:${id}` },
|
||||
onMutate: (changes: Partial<DeviceDef>) => patch(changes),
|
||||
onMutate: async (changes: Partial<DeviceDef>) => {
|
||||
// One already on its way would land over this change.
|
||||
await queryClient.cancelQueries({ queryKey: deviceKeys.all, exact: true })
|
||||
patch(changes)
|
||||
},
|
||||
mutationFn: (changes: Partial<DeviceDef>) => {
|
||||
const current = stored()
|
||||
if (!current) throw new Error(`No device named ${id}`)
|
||||
@@ -87,7 +112,11 @@ export function useSaveDevice(id: string) {
|
||||
// The version alone: the cache already shows this change, and a save
|
||||
// queued behind it may have shown another since.
|
||||
onSuccess: (saved) => patch({ version: saved.version }),
|
||||
onError: () => queryClient.invalidateQueries({ queryKey: deviceKeys.all }),
|
||||
// Still counted while it settles, so 1 is this one alone.
|
||||
onSettled: () => {
|
||||
if (queryClient.isMutating({ mutationKey: deviceKeys.save }) === 1)
|
||||
queryClient.invalidateQueries({ queryKey: deviceKeys.all, exact: true })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from "react"
|
||||
|
||||
import { ApiError, FlowsService, OpenAPI } from "@/client"
|
||||
import { dashboardKeys } from "@/components/Dashboard/queries"
|
||||
import { deviceKeys } from "@/components/Devices/queries"
|
||||
import { deviceKeys, refetchDevices } from "@/components/Devices/queries"
|
||||
import { healthKeys } from "@/components/Health/queries"
|
||||
import { runKeys } from "@/components/Runs/queries"
|
||||
import { connectionStore } from "@/lib/connectionStore"
|
||||
@@ -418,16 +418,16 @@ function open(url: string) {
|
||||
// `exact` matters — the list key is a prefix of every detail key,
|
||||
// including the draft an editor may have open.
|
||||
client?.invalidateQueries({ queryKey: dashboardKeys.all, exact: true })
|
||||
client?.invalidateQueries({
|
||||
queryKey: message.dashboard
|
||||
? dashboardKeys.detail(message.dashboard)
|
||||
: deviceKeys.all,
|
||||
})
|
||||
if (message.dashboard)
|
||||
client?.invalidateQueries({
|
||||
queryKey: dashboardKeys.detail(message.dashboard),
|
||||
})
|
||||
else if (client) refetchDevices(client, false)
|
||||
break
|
||||
case "device_status":
|
||||
// A device's socket opened or closed: the Devices tab reads presence
|
||||
// off the list.
|
||||
client?.invalidateQueries({ queryKey: deviceKeys.all, exact: true })
|
||||
if (client) refetchDevices(client)
|
||||
break
|
||||
case "run_started":
|
||||
case "run_finished":
|
||||
|
||||
Reference in New Issue
Block a user