Capability report: PUT /devices/{id}/report, a Capabilities section, offline devices degraded
A device reports its board, screen, camera and ports from its own credential (16 KB, schema 1, replacing the last report), and the flows are validated against it again. The node panel turns a reported port's config schema into a form, a reported screen is a canvas preset and a touch screen's first report turns Touch friendly on. An offline device node is degraded: amber on the canvas, a warning on Home, never a fault. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138N7k5PT4dcR1iEqXaaQ9k
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a9d453c9d9
commit
ad76200d3d
@@ -4,6 +4,8 @@ import { useState } from "react"
|
||||
|
||||
import type { MessageInfo, SettingDef, WidgetDef } from "@/client"
|
||||
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
|
||||
import { screensOf } from "@/components/Devices/ports"
|
||||
import { devicesQueryOptions } from "@/components/Devices/queries"
|
||||
import {
|
||||
FieldLabel,
|
||||
InfoTip,
|
||||
@@ -1934,6 +1936,16 @@ export function DashboardPanel({
|
||||
const background = settingOf(dashboard, "background")
|
||||
const paletteSetting = settingOf(dashboard, "palette")
|
||||
const palette = parsePalette(paletteSetting.value)
|
||||
const { data: devices } = useQuery({
|
||||
...devicesQueryOptions(),
|
||||
enabled: open,
|
||||
})
|
||||
// The screens devices reported come first and take the place of a named
|
||||
// preset of the same size: that is the screen this is drawn for.
|
||||
const presets = [...screensOf(devices?.data), ...CANVAS_PRESETS].filter(
|
||||
(preset, index, all) =>
|
||||
all.findIndex((one) => sizeKey(one) === sizeKey(preset)) === index,
|
||||
)
|
||||
|
||||
/** Settings are a map, so one of them changing rewrites the whole of it. */
|
||||
const setSetting = (name: SettingName, setting: SettingDef) =>
|
||||
@@ -2012,7 +2024,7 @@ export function DashboardPanel({
|
||||
>
|
||||
<Select
|
||||
value={
|
||||
CANVAS_PRESETS.some(
|
||||
presets.some(
|
||||
(preset) =>
|
||||
sizeKey(preset) ===
|
||||
sizeKey({ ...canvas, shape: dashboard.shape }),
|
||||
@@ -2021,7 +2033,7 @@ export function DashboardPanel({
|
||||
: ""
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
const preset = CANVAS_PRESETS.find(
|
||||
const preset = presets.find(
|
||||
(candidate) => sizeKey(candidate) === value,
|
||||
)
|
||||
if (preset)
|
||||
@@ -2037,7 +2049,7 @@ export function DashboardPanel({
|
||||
<SelectValue placeholder="Custom" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CANVAS_PRESETS.map((preset) => (
|
||||
{presets.map((preset) => (
|
||||
<SelectItem key={sizeKey(preset)} value={sizeKey(preset)}>
|
||||
{preset.label} — {preset.width}×{preset.height}
|
||||
</SelectItem>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { Link, useNavigate } from "@tanstack/react-router"
|
||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, X } from "lucide-react"
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
@@ -33,9 +40,13 @@ import { ago, cn } from "@/lib/utils"
|
||||
import { handleError } from "@/utils"
|
||||
import {
|
||||
DEVICE_TYPE,
|
||||
type DeviceReport,
|
||||
deviceNodeOf,
|
||||
freeNodeId,
|
||||
type ReportedPort,
|
||||
reportedPorts,
|
||||
reportOf,
|
||||
wiresAs,
|
||||
withPort,
|
||||
} from "./ports"
|
||||
import {
|
||||
@@ -141,7 +152,7 @@ export function DeviceDetail({ id }: { id: string }) {
|
||||
/>
|
||||
</section>
|
||||
<section className={CARD}>
|
||||
<DevicePorts device={device} canEdit={canEdit} />
|
||||
<Capabilities device={device} canEdit={canEdit} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -545,57 +556,160 @@ function Pairing({
|
||||
)
|
||||
}
|
||||
|
||||
/** "1024×600, touch", or "360×360, round, touch". */
|
||||
function displayText(display: NonNullable<DeviceReport["display"]>) {
|
||||
return [
|
||||
`${display.w}×${display.h}`,
|
||||
display.shape === "round" ? "round" : null,
|
||||
display.touch ? "touch" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's own ports as the published flows wire them: each one, the
|
||||
* message it publishes or follows, and a link to the node holding it.
|
||||
* Wiring is done in a flow, so this only reads.
|
||||
* 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 DevicePorts({
|
||||
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 (
|
||||
<PanelSection
|
||||
title="Ports"
|
||||
help="Hardware on the device itself, such as a GPIO pin or its backlight. A device node in a flow wires each port to a message."
|
||||
title="Capabilities"
|
||||
help="What the device reported about itself when it last connected, and its ports. A device node in a flow wires each port to a message."
|
||||
>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No ports wired yet.</p>
|
||||
{report ? (
|
||||
<dl
|
||||
className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm"
|
||||
data-testid="device-capabilities"
|
||||
>
|
||||
{facts.map(([term, value]) => (
|
||||
<div key={term} className="contents">
|
||||
<dt className="text-muted-foreground">{term}</dt>
|
||||
<dd className="min-w-0 truncate">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Not reported yet. A device says what it has once it is paired and
|
||||
connected.
|
||||
</p>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{report ? "It reports no ports." : "No ports wired yet."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-1" data-testid="device-bindings">
|
||||
{bindings.map((binding) => {
|
||||
const Way = binding.direction === "in" ? ArrowRight : ArrowLeft
|
||||
const way = binding.direction === "in" ? "Publishes" : "Follows"
|
||||
{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 (
|
||||
<li
|
||||
key={`${binding.flow}.${binding.node}.${binding.port}.${binding.message}`}
|
||||
key={port}
|
||||
className="flex min-w-0 items-center gap-2 text-sm"
|
||||
data-testid="device-port"
|
||||
>
|
||||
<span className="w-24 shrink-0 truncate font-mono">
|
||||
{binding.port}
|
||||
</span>
|
||||
<span className="w-24 shrink-0 truncate font-mono">{port}</span>
|
||||
<Way
|
||||
role="img"
|
||||
aria-label={way}
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<Link
|
||||
to="/flows/$flowName"
|
||||
params={{ flowName: binding.flow }}
|
||||
search={{ node: binding.node }}
|
||||
className="min-w-0 flex-1 truncate font-mono hover:underline"
|
||||
>
|
||||
{binding.message}
|
||||
</Link>
|
||||
<span className="w-12 shrink-0 truncate text-xs text-muted-foreground">
|
||||
{said?.dtype ?? ""}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
{wired.map((binding) => (
|
||||
<Link
|
||||
key={`${binding.flow}.${binding.node}.${binding.message}`}
|
||||
to="/flows/$flowName"
|
||||
params={{ flowName: binding.flow }}
|
||||
search={{ node: binding.node }}
|
||||
className="min-w-0 truncate font-mono hover:underline"
|
||||
>
|
||||
{binding.message}
|
||||
</Link>
|
||||
))}
|
||||
{report && !said ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Not reported by the device
|
||||
</span>
|
||||
) : null}
|
||||
{said &&
|
||||
wired.some((binding) => !wiresAs(said, binding.direction)) ? (
|
||||
<span className="text-xs text-destructive">
|
||||
Wired the other way round
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{canEdit && said && wired.length === 0 ? (
|
||||
<UseInFlow device={device} port={said} size="sm" />
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{device.reported_at ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reported {ago(device.reported_at)}
|
||||
</p>
|
||||
) : null}
|
||||
{canEdit ? <UseInFlow device={device} /> : null}
|
||||
</PanelSection>
|
||||
)
|
||||
@@ -609,9 +723,11 @@ function DevicePorts({
|
||||
export function UseInFlow({
|
||||
device,
|
||||
port,
|
||||
size,
|
||||
}: {
|
||||
device: DevicePublic
|
||||
port?: ReportedPort
|
||||
size?: "sm"
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const { showErrorToast } = useCustomToast()
|
||||
@@ -657,10 +773,15 @@ export function UseInFlow({
|
||||
return (
|
||||
<Select value="" onValueChange={(flowName) => place.mutate(flowName)}>
|
||||
<SelectTrigger
|
||||
className="justify-self-start"
|
||||
aria-label={`Use ${device.title || device.id} in a flow`}
|
||||
size={size}
|
||||
className="shrink-0 justify-self-start"
|
||||
aria-label={
|
||||
port
|
||||
? `Use ${port.port} of ${device.title || device.id} in a flow`
|
||||
: `Use ${device.title || device.id} in a flow`
|
||||
}
|
||||
disabled={place.isPending}
|
||||
data-testid="use-in-flow"
|
||||
data-testid={port ? "use-port-in-flow" : "use-in-flow"}
|
||||
>
|
||||
<SelectValue placeholder="Use in flow" />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -3,23 +3,64 @@ import type { DevicePublic, DType, MessageSpec, NodeDef_Input } from "@/client"
|
||||
/** The node type a device is in a flow. */
|
||||
export const DEVICE_TYPE = "device"
|
||||
|
||||
/** Port names the panel firmware knows; `gpio.` takes a pin number. */
|
||||
// ponytail: free-text ports until a device reports what it has (D2, PUT /devices/{id}/report)
|
||||
export const PORT_SUGGESTIONS = [
|
||||
"gpio.",
|
||||
"backlight",
|
||||
"touch",
|
||||
"mic",
|
||||
"speaker",
|
||||
]
|
||||
|
||||
/** One port as a device reports it. */
|
||||
export type ReportedPort = {
|
||||
port: string
|
||||
/** Its default direction. */
|
||||
direction: "in" | "out"
|
||||
dtype?: DType
|
||||
/** A JSON schema of the config the device reads for it. */
|
||||
config?: Record<string, unknown> | null
|
||||
on_demand?: boolean
|
||||
/** The directions it may be wired in, when it may be either: a GPIO pin. */
|
||||
modes?: ("in" | "out")[] | null
|
||||
}
|
||||
|
||||
/** What a device says it has, `PUT /devices/{id}/report` (schema 1). */
|
||||
export type DeviceReport = {
|
||||
schema: number
|
||||
platform?: string
|
||||
board?: string
|
||||
firmware?: string
|
||||
display?: {
|
||||
w: number
|
||||
h: number
|
||||
shape?: "rect" | "round"
|
||||
touch?: boolean
|
||||
} | null
|
||||
camera?: { sensor?: string; w?: number; h?: number; fps?: number } | null
|
||||
features?: Record<string, boolean>
|
||||
}
|
||||
|
||||
/** The device's report, or null before it has made one. */
|
||||
export function reportOf(device: DevicePublic | undefined) {
|
||||
const report = device?.report as DeviceReport | null | undefined
|
||||
return report && typeof report === "object" ? report : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Each screen a device reported, as a canvas preset named after the device:
|
||||
* a dashboard drawn for it then shows at 1:1 there.
|
||||
*/
|
||||
export function screensOf(devices: DevicePublic[] = []) {
|
||||
return devices.flatMap((device) => {
|
||||
const display = reportOf(device)?.display
|
||||
if (!display?.w || !display.h) return []
|
||||
return [
|
||||
{
|
||||
label: device.title || device.id,
|
||||
width: display.w,
|
||||
height: display.h,
|
||||
...(display.shape === "round" ? { shape: "round" as const } : {}),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether a reported port may be wired in `direction`. */
|
||||
export const wiresAs = (port: ReportedPort, direction: "in" | "out") =>
|
||||
(port.modes?.length ? port.modes : [port.direction]).includes(direction)
|
||||
|
||||
/** What the server accepts as a port name. */
|
||||
export const portOf = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9_.]/g, "")
|
||||
@@ -31,8 +72,7 @@ export const portOf = (value: string) =>
|
||||
* are free text.
|
||||
*/
|
||||
export function reportedPorts(device: DevicePublic | undefined) {
|
||||
const ports = (device?.report as { ports?: unknown } | null | undefined)
|
||||
?.ports
|
||||
const ports = (reportOf(device) as { ports?: unknown } | null)?.ports
|
||||
if (!Array.isArray(ports)) return null
|
||||
return ports.filter(
|
||||
(port): port is ReportedPort =>
|
||||
|
||||
@@ -424,18 +424,26 @@ function FlowEditorInner({
|
||||
[nodeTypeInfo],
|
||||
)
|
||||
|
||||
const issuesByNode = useMemo(() => {
|
||||
const map = new Map<string, string[]>()
|
||||
const [issuesByNode, warningsByNode] = useMemo(() => {
|
||||
const faults = new Map<string, string[]>()
|
||||
const warnings = new Map<string, string[]>()
|
||||
for (const issue of issues) {
|
||||
// A node's dot and border are its status channel, and status has no
|
||||
// warning tier — an advisory is not a fault, so it says its piece in the
|
||||
// dock rather than painting the node red.
|
||||
if (!issue.node || issue.advisory) continue
|
||||
// A node's dot and border are its status channel. An advisory is not a
|
||||
// fault, so it says its piece in the dock rather than painting the node
|
||||
// red. A degraded node, a device that is offline, is the one warning
|
||||
// the node itself wears, in amber.
|
||||
if (!issue.node) continue
|
||||
const map = !issue.advisory
|
||||
? faults
|
||||
: issue.code === "node_degraded"
|
||||
? warnings
|
||||
: null
|
||||
if (!map) continue
|
||||
const list = map.get(issue.node) ?? []
|
||||
list.push(issue.message)
|
||||
map.set(issue.node, list)
|
||||
}
|
||||
return map
|
||||
return [faults, warnings]
|
||||
}, [issues])
|
||||
|
||||
const showLogs = useCallback((nodeId: string) => {
|
||||
@@ -449,6 +457,7 @@ function FlowEditorInner({
|
||||
canvasNodes.map((node) => {
|
||||
const definition = definitions.find((entry) => entry.id === node.id)
|
||||
const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? []
|
||||
const nodeWarnings = warningsByNode.get(`${flowName}.${node.id}`) ?? []
|
||||
return {
|
||||
...node,
|
||||
selected: node.selected || node.id === selectedId,
|
||||
@@ -459,6 +468,7 @@ function FlowEditorInner({
|
||||
typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "",
|
||||
isPlugin: pluginTypes.has(definition?.type ?? ""),
|
||||
issueText: nodeIssues.join("\n"),
|
||||
warningText: nodeWarnings.join("\n"),
|
||||
onShowLogs: showLogs,
|
||||
} satisfies FlowNodeData,
|
||||
}
|
||||
@@ -468,6 +478,7 @@ function FlowEditorInner({
|
||||
definitions,
|
||||
flowName,
|
||||
issuesByNode,
|
||||
warningsByNode,
|
||||
pluginTypes,
|
||||
selectedId,
|
||||
showLogs,
|
||||
|
||||
@@ -77,6 +77,8 @@ const STATUS_STYLES = {
|
||||
},
|
||||
running: { dot: "bg-primary animate-pulse", label: "Running" },
|
||||
success: { dot: "bg-status-success", label: "Last run succeeded" },
|
||||
// Works, if not as it should: a device that is offline. Not a fault.
|
||||
warning: { dot: "bg-status-warning", label: "Degraded" },
|
||||
error: { dot: "bg-destructive", label: "Something went wrong" },
|
||||
} as const
|
||||
|
||||
@@ -86,6 +88,8 @@ export type FlowNodeData = {
|
||||
typeLabel: string
|
||||
isPlugin?: boolean
|
||||
issueText: string
|
||||
/** Why the node is degraded, if it is: amber, never a fault. */
|
||||
warningText?: string
|
||||
/** Open the logs at this node's own lines. */
|
||||
onShowLogs?: (nodeId: string) => void
|
||||
[key: string]: unknown
|
||||
@@ -126,8 +130,15 @@ function PortHandles({
|
||||
}
|
||||
|
||||
function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
const { definition, flow, typeLabel, isPlugin, issueText, onShowLogs } =
|
||||
data as FlowNodeData
|
||||
const {
|
||||
definition,
|
||||
flow,
|
||||
typeLabel,
|
||||
isPlugin,
|
||||
issueText,
|
||||
warningText,
|
||||
onShowLogs,
|
||||
} = data as FlowNodeData
|
||||
const nodeId = `${flow}.${definition.id}`
|
||||
const live = useNodeStatus(nodeId)
|
||||
const emits = useNodeEmits(nodeId)
|
||||
@@ -174,7 +185,12 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
.join("\n")
|
||||
.split("\n")
|
||||
const problem = [...new Set(lines)].join("\n")
|
||||
const status = problem ? "error" : live?.status
|
||||
const warning = problem ? "" : (warningText ?? "")
|
||||
const status = problem
|
||||
? "error"
|
||||
: warning && live?.status !== "running"
|
||||
? "warning"
|
||||
: live?.status
|
||||
const running = status === "running"
|
||||
const style = STATUS_STYLES[status as keyof typeof STATUS_STYLES]
|
||||
// Failed at some point, fine as of the last run. It gets no dot and no
|
||||
@@ -209,6 +225,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
// Selection wins the border when it is both — the dot is the status
|
||||
// channel, and it is still red underneath.
|
||||
problem && "border-destructive",
|
||||
status === "warning" && "border-status-warning",
|
||||
selected && "border-primary shadow-e2",
|
||||
)}
|
||||
>
|
||||
@@ -318,7 +335,10 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-h-60 max-w-xs overflow-y-auto whitespace-pre-line break-words">
|
||||
{problem || live?.detail || style.label}
|
||||
{problem ||
|
||||
(status === "warning" ? warning : "") ||
|
||||
live?.detail ||
|
||||
style.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
@@ -27,10 +27,10 @@ import {
|
||||
} from "@/client"
|
||||
import {
|
||||
DEVICE_TYPE,
|
||||
PORT_SUGGESTIONS,
|
||||
portOf,
|
||||
type ReportedPort,
|
||||
reportedPorts,
|
||||
wiresAs,
|
||||
} from "@/components/Devices/ports"
|
||||
import { devicesQueryOptions } from "@/components/Devices/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -637,8 +637,7 @@ function DevicePortList({
|
||||
onChange: (next: MessageSpec[], renamed?: [string, string | null]) => void
|
||||
onConfig: (port: string, next: Record<string, unknown>) => void
|
||||
}) {
|
||||
const offered = reported?.filter((port) => port.direction === direction)
|
||||
const listId = `device-ports-${direction}`
|
||||
const offered = reported?.filter((port) => wiresAs(port, direction))
|
||||
// An out port is read when the device asks for it, never a reason to run.
|
||||
const fresh = (port: ReportedPort | null): MessageSpec => ({
|
||||
name: port ? port.port.replace(/\./g, "_") : "",
|
||||
@@ -670,17 +669,19 @@ function DevicePortList({
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<datalist id={listId}>
|
||||
{PORT_SUGGESTIONS.map((port) => (
|
||||
<option key={port} value={port} />
|
||||
))}
|
||||
</datalist>
|
||||
{specs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No ports yet.</p>
|
||||
) : null}
|
||||
{specs.map((spec, index) => {
|
||||
const port = spec.port ?? ""
|
||||
const name = port || "new port"
|
||||
const said = reported?.find((one) => one.port === port)
|
||||
// The device says which settings it reads, so they get a form.
|
||||
const schema =
|
||||
said?.config &&
|
||||
Object.keys((said.config.properties as object) ?? {}).length
|
||||
? said.config
|
||||
: undefined
|
||||
return (
|
||||
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
@@ -730,7 +731,6 @@ function DevicePortList({
|
||||
placeholder="Port, e.g. gpio.4"
|
||||
aria-label={`Port of ${name}`}
|
||||
autoComplete="off"
|
||||
list={listId}
|
||||
className="h-8 min-w-0 flex-1 font-mono text-sm"
|
||||
onBlur={(event) => {
|
||||
const next = portOf(event.currentTarget.value)
|
||||
@@ -792,16 +792,35 @@ function DevicePortList({
|
||||
autoFocus={false}
|
||||
onChange={(next) => update(index, { name: next })}
|
||||
/>
|
||||
<JsonField
|
||||
value={config[port] ?? {}}
|
||||
label={`Config of ${name}`}
|
||||
check={objectOnly}
|
||||
className="h-8 w-24 shrink-0"
|
||||
onChange={(next) => {
|
||||
if (port) onConfig(port, next as Record<string, unknown>)
|
||||
}}
|
||||
/>
|
||||
{schema ? null : (
|
||||
<JsonField
|
||||
value={config[port] ?? {}}
|
||||
label={`Config of ${name}`}
|
||||
check={objectOnly}
|
||||
className="h-8 w-24 shrink-0"
|
||||
onChange={(next) => {
|
||||
if (port) onConfig(port, next as Record<string, unknown>)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{reported && port && !said ? (
|
||||
<p className="pl-5.5 text-xs text-muted-foreground">
|
||||
The device does not report this port.
|
||||
</p>
|
||||
) : null}
|
||||
{schema ? (
|
||||
<div className="pl-5.5">
|
||||
<ParamsForm
|
||||
type={undefined}
|
||||
title={`${port} settings`}
|
||||
schema={schema}
|
||||
params={(config[port] ?? {}) as Record<string, unknown>}
|
||||
provides={[]}
|
||||
onChange={(next) => onConfig(port, next)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -1120,12 +1139,14 @@ function InjectPayloads({
|
||||
/** A small form built from the node type's declared parameters. */
|
||||
function ParamsForm({
|
||||
type,
|
||||
title = "Settings",
|
||||
schema,
|
||||
params,
|
||||
provides,
|
||||
onChange,
|
||||
}: {
|
||||
type: string | undefined
|
||||
title?: string
|
||||
schema: Record<string, unknown> | undefined
|
||||
params: Record<string, unknown>
|
||||
/** The ports an inject's payload fields follow; unused by every other type. */
|
||||
@@ -1139,6 +1160,7 @@ function ParamsForm({
|
||||
title?: string
|
||||
description?: string
|
||||
default?: unknown
|
||||
enum?: unknown[]
|
||||
"x-secret"?: boolean
|
||||
}
|
||||
>
|
||||
@@ -1161,7 +1183,7 @@ function ParamsForm({
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<span className={SECTION}>Settings</span>
|
||||
<span className={SECTION}>{title}</span>
|
||||
{entries.map(([key, property]) => {
|
||||
// In the schema's own place, so the payload keeps its position in the
|
||||
// form even though it is now one field per port.
|
||||
@@ -1261,6 +1283,37 @@ function ParamsForm({
|
||||
)
|
||||
}
|
||||
|
||||
// A closed set of choices, such as a pin's pull.
|
||||
if (property.enum?.length) {
|
||||
return (
|
||||
<div key={key} className="grid gap-1.5">
|
||||
<FieldLabel htmlFor={`param-${key}`} help={property.description}>
|
||||
{label}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(next) =>
|
||||
set(
|
||||
key,
|
||||
property.enum?.find((choice) => String(choice) === next),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`param-${key}`} className="!h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{property.enum.map((choice) => (
|
||||
<SelectItem key={String(choice)} value={String(choice)}>
|
||||
{String(choice)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const numeric =
|
||||
property.type === "integer" || property.type === "number"
|
||||
return (
|
||||
|
||||
@@ -85,7 +85,7 @@ type FlowEvent =
|
||||
type: "node_health"
|
||||
flow?: string
|
||||
node: string
|
||||
health: "ok" | "down" | "unknown"
|
||||
health: "ok" | "degraded" | "down" | "unknown"
|
||||
detail?: string | null
|
||||
ts?: number
|
||||
}
|
||||
@@ -335,7 +335,9 @@ function open(url: string) {
|
||||
// or a rebuild, never when health actually flipped. The store had a
|
||||
// health map of its own and nothing ever read it.
|
||||
invalidateHealth(message.flow)
|
||||
if (message.health === "down") {
|
||||
// Degraded is held back by the same floor, as a warning rather than
|
||||
// a fault.
|
||||
if (message.health === "down" || message.health === "degraded") {
|
||||
// This fires at the transition, while the floor is still hiding the
|
||||
// node, and the engine publishes nothing when the floor expires — so
|
||||
// read again ourselves at that moment, or the issue waits for an
|
||||
@@ -355,7 +357,11 @@ function open(url: string) {
|
||||
type: message.type,
|
||||
flow: message.flow,
|
||||
node: message.node,
|
||||
detail: message.detail ?? "Reported itself down.",
|
||||
detail:
|
||||
message.detail ??
|
||||
(message.health === "down"
|
||||
? "Reported itself down."
|
||||
: "Reported itself degraded."),
|
||||
ts: message.ts ?? Date.now() / 1000,
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { TriangleAlert } from "lucide-react"
|
||||
|
||||
import { type Range, RangePicker } from "@/components/Common/RangePicker"
|
||||
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
|
||||
@@ -96,6 +97,17 @@ export function HealthOverview({
|
||||
{summary.problems.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{/* Worth a look and not a fault, such as a device that is offline: the
|
||||
badge above stays as it is. */}
|
||||
{summary?.warnings?.length ? (
|
||||
<p
|
||||
className="flex items-center gap-1.5 text-sm text-status-warning"
|
||||
data-testid="health-warnings"
|
||||
>
|
||||
<TriangleAlert aria-hidden className="size-4 shrink-0" />
|
||||
<span className="min-w-0">{summary.warnings.join(" · ")}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`grid gap-3 sm:grid-cols-2 ${runs?.length ? "lg:grid-cols-6" : "lg:grid-cols-5"}`}
|
||||
|
||||
Reference in New Issue
Block a user