- flow/nodes/device.py: `device` node with params `device` and `config`;
provides the device's `in` ports, requires its `out` ports without
waking anything, never run (Node.passive), down "Offline" from presence
- DeviceDef.io and DevicePort go; GET /devices/{id} computes `io` and
`bindings` from the device nodes of published flows, in a fixed order,
cached by FlowStore.revision; the credential's scope comes from them
- issues `unknown_device` and `device_port` (wired twice; against a report
the wrong direction or dtype, or unlisted as advisory), rechecked when a
device is created or deleted; ValidationIssue.advisory is a field
- a device publishing on a bound port is credited to its node by the
server; the panel endpoint kind is gone from `_endpoints` and the canvas
- a rebuild that moves a device's bindings sends `dashboard_changed` with
an empty name, which the firmware refetches on
- startup moves each device's `io` rows into device nodes in the flows
declaring their messages, draft and published, one commit; rows no
flow declares are logged and left out
- frontend: Device node settings (device, Publishes, Follows, a port
picker from a report), palette shortcuts per reported port, DeviceDetail
lists bindings with "Use in flow" in place of the I/O editor
- docs: devices, flow editor, node types, issues, API
138 lines
5.1 KiB
TypeScript
138 lines
5.1 KiB
TypeScript
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
|
import { LayoutDashboard, LogIn, LogOut, Workflow } from "lucide-react"
|
|
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
|
|
|
import { useIsMobile } from "@/hooks/useMobile"
|
|
import { cn } from "@/lib/utils"
|
|
import { type EndpointNodeData, publisherOf } from "./endpoints"
|
|
import { handleOffset, nodeHeight } from "./layout"
|
|
import { useLiveValues } from "./liveStore"
|
|
|
|
const KIND_ICONS = {
|
|
dashboard: LayoutDashboard,
|
|
flow: Workflow,
|
|
input: LogIn,
|
|
output: LogOut,
|
|
} as const
|
|
|
|
/**
|
|
* A thing wired into this flow that is not a node in it.
|
|
*
|
|
* Drawn as a label rather than a card on purpose: a dashboard with twenty
|
|
* tiles would otherwise bury the logic the canvas exists to show, which is
|
|
* the same reason dashboards are their own documents. It is here to account
|
|
* for a value, not to compete with the nodes for attention.
|
|
*/
|
|
function EndpointNodeComponent({ id, data, selected }: NodeProps) {
|
|
const { label, kind, detail, provides, requires } = data as EndpointNodeData
|
|
const Icon = KIND_ICONS[kind as keyof typeof KIND_ICONS] ?? Workflow
|
|
const messages = useMemo(
|
|
() => [...provides, ...requires],
|
|
[provides, requires],
|
|
)
|
|
const values = useLiveValues(messages)
|
|
// Which way the last value went, and a count whose change remounts the sweep
|
|
// — remounting is what restarts it. See `.endpoint-sweep` in flow.css.
|
|
const [sweep, setSweep] = useState<{ dir: "in" | "out"; n: number }>()
|
|
// When each message last carried a value, as far as this label has seen.
|
|
// The first look only takes notes: mounting is not a value passing, and
|
|
// neither is a snapshot catching the page up.
|
|
const seen = useRef<Map<string, number>>(null)
|
|
// Follows the graph's own direction; see DESIGN-GUIDELINES.md → Responsive.
|
|
const vertical = useIsMobile()
|
|
|
|
// A dashboard reading six messages has six edges, and stacking their dots on
|
|
// one point is what made them leave in a knot. Spread and sized the way a
|
|
// node's ports are, so the layout can reserve exactly this much.
|
|
const ports = Math.max(provides.length, requires.length)
|
|
|
|
useEffect(() => {
|
|
const first = seen.current === null
|
|
seen.current ??= new Map()
|
|
let dir: "in" | "out" | undefined
|
|
for (const [index, name] of messages.entries()) {
|
|
const live = values[index]
|
|
// An empty store is a reconnect on its way, not a value.
|
|
if (!live?.ts || seen.current.get(name) === live.ts) continue
|
|
seen.current.set(name, live.ts)
|
|
if (first || live.prior) continue
|
|
// Published by this label, by the rule its own edges pulse on (LiveEdge).
|
|
if (
|
|
provides.includes(name) &&
|
|
(!live.source || live.source.id === publisherOf(id))
|
|
) {
|
|
dir = "in"
|
|
} else if (requires.includes(name)) {
|
|
dir ??= "out"
|
|
}
|
|
}
|
|
if (!dir) return
|
|
const next = dir
|
|
setSweep((last) => ({ dir: next, n: (last?.n ?? 0) + 1 }))
|
|
}, [values, messages, provides, requires, id])
|
|
|
|
return (
|
|
<div
|
|
style={vertical ? undefined : { minHeight: nodeHeight(ports) }}
|
|
className={cn(
|
|
// Padding keeps the text off the connector dot, which sits on the edge.
|
|
// Isolated so the sweep can sit behind the text without leaving it.
|
|
"relative isolate flex max-w-48 cursor-pointer items-center gap-2 px-3 py-1",
|
|
// Quiet at rest so the logic reads first; legible when reached for.
|
|
"text-muted-foreground/55 transition-colors",
|
|
"hover:text-foreground",
|
|
selected && "text-foreground",
|
|
)}
|
|
title={messages.join("\n")}
|
|
>
|
|
{/* Both sides always exist so an edge can attach; only one is used. */}
|
|
{requires.map((message, index) => {
|
|
const offset = handleOffset(index, requires.length)
|
|
return (
|
|
<Handle
|
|
key={`in-${message}`}
|
|
type="target"
|
|
id={message}
|
|
position={vertical ? Position.Top : Position.Left}
|
|
className="!border-border !bg-card"
|
|
style={vertical ? { left: offset } : { top: offset }}
|
|
/>
|
|
)
|
|
})}
|
|
{provides.map((message, index) => {
|
|
const offset = handleOffset(index, provides.length)
|
|
return (
|
|
<Handle
|
|
key={`out-${message}`}
|
|
type="source"
|
|
id={message}
|
|
position={vertical ? Position.Bottom : Position.Right}
|
|
className="!border-border !bg-card"
|
|
style={vertical ? { left: offset } : { top: offset }}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{sweep ? (
|
|
<span
|
|
key={sweep.n}
|
|
className={cn(
|
|
"endpoint-sweep rounded-sm",
|
|
sweep.dir === "out" && "endpoint-sweep-out",
|
|
)}
|
|
/>
|
|
) : null}
|
|
<Icon className="size-4 shrink-0" />
|
|
<span className="grid min-w-0">
|
|
<span className="truncate text-sm leading-tight">{label}</span>
|
|
<span className="truncate text-xs tracking-wide uppercase opacity-70">
|
|
{detail}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const EndpointNode = memo(EndpointNodeComponent)
|
|
export default EndpointNode
|