Let a node's failure outlive the run that followed it
A node's error cleared the moment it ran again, so a failure that genuinely fired an alert could leave no trace on the canvas by the time anyone looked. The engine records it now — on the node's status, so it survives a reload and every client agrees — and reading the traceback is what clears it. The seam is the event bus, which is where every failing path already meets: a queued live run, an explicit run, a preview, and a single triggered node all publish `node_error`, while the controller's own observer would have seen only one of them. That was half the confusion. The other half: clicking a failed neuron on Home often landed on a flow where everything looked fine. Nodes merge into one neuron by instance key — every InfluxDB node pointing at the same bucket is one neuron — and the click went to whichever flow contributed a member first, not the one that failed. It now goes to the failing member and selects it, and the canvas marks a failing node rather than leaving it to the dot alone. The inject node emitted one payload to every port it declared, whatever their types, so an inject on a bool port carrying the text "true" raised at publish time. Each port gets its own field now, typed and parsed by that port's dtype, and remembers what it last sent. A port that is renamed carries its value with it; one that is removed takes its value with it. An inject written before this keeps emitting exactly what it did. The derived-cron chip also appeared on the delay node, where `interval` is a rate limit and a schedule derived from it means nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
@@ -3,7 +3,7 @@ import { memo, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { duration } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useGroupActive, useGroupEmits, useGroupError } from "./liveStore"
|
||||
import { useGroupActive, useGroupEmits, useGroupFailure } from "./liveStore"
|
||||
|
||||
export type BrainNodeData = {
|
||||
label: string
|
||||
@@ -47,7 +47,10 @@ function BrainNodeComponent({ data }: NodeProps) {
|
||||
// Whether it has published at all, which the snapshot answers for what
|
||||
// happened before this page connected; `emits` only counts what we saw.
|
||||
const active = useGroupActive(members)
|
||||
const failed = useGroupError(members)
|
||||
// The failure rather than the live status: it has to still be here after the
|
||||
// node has run again, or Home and the canvas disagree about the same node.
|
||||
const failure = useGroupFailure(members)
|
||||
const failed = Boolean(failure)
|
||||
// Two faults, told apart the way the mark's two parts are: the ring is the
|
||||
// wiring around the node, so a flow that cannot run as written colours the
|
||||
// ring; the disc is the node itself, so a run that broke colours the disc.
|
||||
@@ -96,7 +99,7 @@ function BrainNodeComponent({ data }: NodeProps) {
|
||||
borderWidth: Math.round(size * RING),
|
||||
boxShadow: `inset 0 0 0 ${Math.round(size * GAP)}px var(--brain-gap)`,
|
||||
}}
|
||||
title={`${label} · ${kind} · ${members.join(", ")}${issue ? ` · ${issue}` : ""}`}
|
||||
title={`${label} · ${kind} · ${members.join(", ")}${issue ? ` · ${issue}` : ""}${failure ? ` · ${failure}` : ""}`}
|
||||
>
|
||||
{/* Both ends sit at the centre; the edge trims itself back to the rim. */}
|
||||
<Handle
|
||||
@@ -117,11 +120,16 @@ function BrainNodeComponent({ data }: NodeProps) {
|
||||
{/*
|
||||
* A name under every circle is what makes the graph unreadable, so only
|
||||
* the neuron under the pointer or the keyboard says what it is — except
|
||||
* one with something wrong, since colour is never the only carrier of a
|
||||
* status, and here it carries two of them. Two words rather than the
|
||||
* reason itself: a cycle names every node in it, which is a sentence no
|
||||
* label under a 32px circle can hold, so the phrase matches what Home
|
||||
* says and the full text stays in the tooltip.
|
||||
* one with something wrong, which always names itself: a red neuron
|
||||
* nobody can put a name to is a hunt rather than a warning.
|
||||
*
|
||||
* Under the name, only the wiring fault says what it is. "Cannot run"
|
||||
* is a state that never ends by itself, so it has to be readable; a
|
||||
* failure is a thing that happened, and the neuron carries it as far as
|
||||
* the flow it happened in — one click away, where the node says what
|
||||
* broke and when. Two words rather than the reason itself, either way:
|
||||
* a cycle names every node in it, which is a sentence no label under a
|
||||
* 32px circle can hold, so the full text stays in the tooltip.
|
||||
*
|
||||
* The word is `--foreground` rather than the terracotta beside it:
|
||||
* `--brand-secondary` measures 2.2:1 on `--card` in light, which is a
|
||||
@@ -138,11 +146,9 @@ function BrainNodeComponent({ data }: NodeProps) {
|
||||
</span>
|
||||
{/* The label box is only as wide as the circle it hangs under, so two
|
||||
words break onto two lines unless told not to. */}
|
||||
{problem ? (
|
||||
{issue ? (
|
||||
<span className="whitespace-nowrap text-xs font-medium text-foreground">
|
||||
{[failed && "failed", issue && "cannot run"]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
cannot run
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { scaleIn } from "@/lib/motion"
|
||||
import { BrainEdge, type BrainEdgeData } from "./BrainEdge"
|
||||
import { BrainNode, type BrainNodeData } from "./BrainNode"
|
||||
import "./flow.css"
|
||||
import { liveStore } from "./liveStore"
|
||||
import { graphQueryOptions } from "./queries"
|
||||
import { useFlowSocket } from "./useFlowSocket"
|
||||
|
||||
@@ -228,9 +229,24 @@ function BrainCanvas() {
|
||||
setRevealed(node.id)
|
||||
return
|
||||
}
|
||||
const [flow] = (node.data as BrainNodeData).flows
|
||||
if (flow)
|
||||
navigate({ to: "/flows/$flowName", params: { flowName: flow } })
|
||||
// A neuron merges every node talking to the same thing, and its
|
||||
// flows are in the order they contributed one — which is rarely the
|
||||
// one that failed. So the failing member picks the flow, and takes
|
||||
// its node with it; `flow.node_id`, and neither half can hold a dot.
|
||||
const { members, flows } = node.data as BrainNodeData
|
||||
const failing = members.find(
|
||||
(member) =>
|
||||
liveStore.getFailure(member) ||
|
||||
liveStore.getStatus(member)?.status === "error",
|
||||
)
|
||||
const [memberFlow, nodeId] = (failing ?? "").split(".")
|
||||
const flowName = memberFlow || flows[0]
|
||||
if (flowName)
|
||||
navigate({
|
||||
to: "/flows/$flowName",
|
||||
params: { flowName },
|
||||
search: nodeId ? { node: nodeId } : {},
|
||||
})
|
||||
}}
|
||||
onPaneClick={() => setRevealed(null)}
|
||||
className="brain-flat h-full w-full"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { qualify } from "./deriveEdges"
|
||||
import { useLiveValue } from "./liveStore"
|
||||
import { asText, DTYPES } from "./NodePanel"
|
||||
@@ -46,6 +47,66 @@ export function parseByDtype(dtype: DType | undefined, raw: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One literal, entered the way its type is entered.
|
||||
*
|
||||
* A flag has two values and gets a choice of them; everything else is typed
|
||||
* and read back with {@link parseByDtype}. Deliberately not `type="number"`
|
||||
* for the numbers — see that function on why half-typed input must survive.
|
||||
*
|
||||
* `className` carries no height: the two controls need different ones.
|
||||
*/
|
||||
export function DtypeValue({
|
||||
dtype,
|
||||
value,
|
||||
label,
|
||||
id,
|
||||
placeholder,
|
||||
className,
|
||||
onChange,
|
||||
}: {
|
||||
dtype: DType | undefined
|
||||
value: unknown
|
||||
/** What the field is called, for anyone not looking at it. */
|
||||
label: string
|
||||
id?: string
|
||||
placeholder?: string
|
||||
className?: string
|
||||
onChange: (next: unknown) => void
|
||||
}) {
|
||||
if (dtype === "bool") {
|
||||
return (
|
||||
<Select
|
||||
value={value === true ? "true" : "false"}
|
||||
onValueChange={(next) => onChange(next === "true")}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
className={cn("!h-8", className)}
|
||||
aria-label={label}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">true</SelectItem>
|
||||
<SelectItem value="false">false</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
value={asText(value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={label}
|
||||
className={cn("h-8", className)}
|
||||
onChange={(event) => onChange(parseByDtype(dtype, event.target.value))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Putting a declared value into the running graph, credited to the input. */
|
||||
function usePublishInput() {
|
||||
return useMutation({
|
||||
@@ -116,38 +177,14 @@ function InputRow({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{spec.dtype === "bool" ? (
|
||||
<Select
|
||||
value={declared.initial === true ? "true" : "false"}
|
||||
onValueChange={(next) =>
|
||||
onChange({ ...declared, initial: next === "true" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="!h-8 flex-1 text-sm"
|
||||
aria-label="Starting value"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">true</SelectItem>
|
||||
<SelectItem value="false">false</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={asText(declared.initial)}
|
||||
placeholder="starts at"
|
||||
aria-label="Starting value"
|
||||
className="h-8 flex-1 text-sm"
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...declared,
|
||||
initial: parseByDtype(spec.dtype, event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DtypeValue
|
||||
dtype={spec.dtype}
|
||||
value={declared.initial}
|
||||
label="Starting value"
|
||||
placeholder="starts at"
|
||||
className="flex-1 text-sm"
|
||||
onChange={(initial) => onChange({ ...declared, initial })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
||||
@@ -192,9 +192,12 @@ function uniqueNodeId(existing: NodeDef_Input[], type: string): string {
|
||||
|
||||
function FlowEditorInner({
|
||||
flowName,
|
||||
focus,
|
||||
onReload,
|
||||
}: {
|
||||
flowName: string
|
||||
/** A node to arrive on, from the address bar. */
|
||||
focus?: string
|
||||
onReload: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
@@ -224,7 +227,7 @@ function FlowEditorInner({
|
||||
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
|
||||
detail.definition.nodes ?? [],
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(focus ?? null)
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
const [inspected, setInspected] = useState<InspectedEdge | null>(null)
|
||||
// The edges are derived, so xyflow's own selection would be thrown away on
|
||||
@@ -562,6 +565,14 @@ function FlowEditorInner({
|
||||
])
|
||||
}, [key, direction, external, updateNodeInternals])
|
||||
|
||||
// The canvas is not remounted per node — that would throw the session away
|
||||
// on every click in the brain graph — so arriving at a flow already open
|
||||
// only changes the address. Seeded above for the first arrival, set here for
|
||||
// the ones after it.
|
||||
useEffect(() => {
|
||||
if (focus) setSelectedId(focus)
|
||||
}, [focus])
|
||||
|
||||
const selected = definitions.find((node) => node.id === selectedId) ?? null
|
||||
// A panel is the view you are working in, so it takes the room — but never
|
||||
// the lanes the bars sit in: publishing is most wanted right after editing.
|
||||
@@ -1365,7 +1376,14 @@ function useUnpositionedNodes(definitions: NodeDef_Input[]) {
|
||||
return useNodesState<FlowCanvasNode>(toCanvasNodes(definitions))
|
||||
}
|
||||
|
||||
export function FlowEditor({ flowName }: { flowName: string }) {
|
||||
export function FlowEditor({
|
||||
flowName,
|
||||
focus,
|
||||
}: {
|
||||
flowName: string
|
||||
/** A node to select on arrival — see `FlowEditorInner`. */
|
||||
focus?: string
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const onAuthFailure = useCallback(() => {
|
||||
navigate({ to: "/login" })
|
||||
@@ -1383,10 +1401,14 @@ export function FlowEditor({ flowName }: { flowName: string }) {
|
||||
* Remounting per flow keeps canvas state from leaking between them, and
|
||||
* it is what makes `fitView` run once per flow: xyflow queues the fit on
|
||||
* mount and resolves it as soon as the nodes have been measured.
|
||||
* Deliberately not per focused node — that is a selection, not another
|
||||
* document, and remounting the canvas on every click in the brain graph
|
||||
* would throw an unsaved edit away with it.
|
||||
*/}
|
||||
<FlowEditorInner
|
||||
key={`${flowName}:${epoch}`}
|
||||
flowName={flowName}
|
||||
focus={focus}
|
||||
onReload={reload}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
|
||||
@@ -32,7 +32,12 @@ import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { duration } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { portOf } from "./deriveEdges"
|
||||
import { useNodeEmits, useNodeStatus } from "./liveStore"
|
||||
import {
|
||||
liveStore,
|
||||
useNodeEmits,
|
||||
useNodeFailure,
|
||||
useNodeStatus,
|
||||
} from "./liveStore"
|
||||
|
||||
const NODE_ICONS = {
|
||||
python: Code2,
|
||||
@@ -115,8 +120,11 @@ function PortHandles({
|
||||
function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
const { definition, flow, typeLabel, isPlugin, issueText, onShowLogs } =
|
||||
data as FlowNodeData
|
||||
const live = useNodeStatus(`${flow}.${definition.id}`)
|
||||
const emits = useNodeEmits(`${flow}.${definition.id}`)
|
||||
const nodeId = `${flow}.${definition.id}`
|
||||
const live = useNodeStatus(nodeId)
|
||||
const emits = useNodeEmits(nodeId)
|
||||
// What it last failed with, which outlives the run that failed.
|
||||
const failure = useNodeFailure(nodeId)
|
||||
// The graph runs top to bottom on a phone, so the ports have to face that
|
||||
// way too — see DESIGN-GUIDELINES.md → Responsive.
|
||||
const vertical = useIsMobile()
|
||||
@@ -154,6 +162,13 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
const status = problem ? "error" : 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
|
||||
// border — those tell the truth about the last run — only the traceback
|
||||
// button, which says in words when it was.
|
||||
const failedEarlier = !problem && Boolean(failure)
|
||||
const failedAt = failure
|
||||
? new Date(failure.ts * 1000).toLocaleTimeString()
|
||||
: ""
|
||||
|
||||
return (
|
||||
// The pulse ring measures itself from here rather than from the card, so
|
||||
@@ -172,7 +187,12 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
|
||||
"flow-node-card relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
|
||||
// Whatever is wrong right now is on the card as well as on the dot:
|
||||
// a click through from the brain has to land on something visible.
|
||||
// Selection wins the border when it is both — the dot is the status
|
||||
// channel, and it is still red underneath.
|
||||
problem && "border-destructive",
|
||||
selected && "border-primary shadow-e2",
|
||||
)}
|
||||
>
|
||||
@@ -220,7 +240,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{status === "error" && onShowLogs ? (
|
||||
{(status === "error" || failedEarlier) && onShowLogs ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -228,18 +248,36 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
size="icon-sm"
|
||||
// `nodrag` keeps xyflow from reading the press as a drag; the
|
||||
// click itself is stopped so the node panel stays closed.
|
||||
className="nodrag nopan -my-1 size-6 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label="Show what this node printed"
|
||||
className={cn(
|
||||
"nodrag nopan -my-1 size-6 shrink-0 hover:text-destructive",
|
||||
// Red while it is the only thing left saying so, and named
|
||||
// in words beside it: colour never carries a status alone.
|
||||
failedEarlier
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
aria-label={
|
||||
failedEarlier
|
||||
? `Failed at ${failedAt} — show the traceback`
|
||||
: "Show what this node printed"
|
||||
}
|
||||
data-testid="node-traceback"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onShowLogs(definition.id)
|
||||
// Reading it is what dismisses it: nothing else does, and a
|
||||
// marker that never goes away stops meaning anything.
|
||||
liveStore.acknowledgeFailure(nodeId)
|
||||
}}
|
||||
>
|
||||
<Bug />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Show the traceback</TooltipContent>
|
||||
<TooltipContent>
|
||||
{failedEarlier
|
||||
? `Failed at ${failedAt} — show the traceback`
|
||||
: "Show the traceback"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Switch } from "@/components/ui/switch"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DtypeValue } from "./FlowBoundary"
|
||||
import { MessageSparkline } from "./MessageSparkline"
|
||||
import {
|
||||
flowKeys,
|
||||
@@ -219,6 +220,7 @@ function PortList({
|
||||
suggestions,
|
||||
onChange,
|
||||
onRenamed,
|
||||
onRemoved,
|
||||
streamable = false,
|
||||
}: {
|
||||
title: string
|
||||
@@ -228,6 +230,8 @@ function PortList({
|
||||
suggestions: string[]
|
||||
onChange: (next: MessageSpec[]) => void
|
||||
onRenamed?: (previous: string, next: string) => void
|
||||
/** The port about to be dropped, reported just before the shorter list. */
|
||||
onRemoved?: (spec: MessageSpec) => void
|
||||
/** Outputs only: a port a node publishes on repeatedly while it runs. */
|
||||
streamable?: boolean
|
||||
}) {
|
||||
@@ -355,7 +359,10 @@ function PortList({
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Remove port"
|
||||
onClick={() => onChange(specs.filter((_, i) => i !== index))}
|
||||
onClick={() => {
|
||||
onRemoved?.(spec)
|
||||
onChange(specs.filter((_, i) => i !== index))
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
@@ -565,9 +572,15 @@ function cronFromInterval(seconds: unknown): string | null {
|
||||
/** What the five fields mean, and the schedule the interval beside them asks for. */
|
||||
function CronHelp({
|
||||
params,
|
||||
derivable,
|
||||
onPick,
|
||||
}: {
|
||||
params: Record<string, unknown>
|
||||
/**
|
||||
* Whether `interval` beside it is a schedule at all. On a delay it is a rate
|
||||
* limit, and a cron built from it would say something the node never does.
|
||||
*/
|
||||
derivable: boolean
|
||||
onPick: (expression: string) => void
|
||||
}) {
|
||||
const derived = cronFromInterval(params.interval)
|
||||
@@ -583,7 +596,7 @@ function CronHelp({
|
||||
<span className="font-mono">*/5</span> every fifth,{" "}
|
||||
<span className="font-mono">1-5</span> a range.
|
||||
</p>
|
||||
{derived && params.cron !== derived ? (
|
||||
{derivable && derived && params.cron !== derived ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -599,16 +612,85 @@ function CronHelp({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What an inject publishes, one field per port it publishes on.
|
||||
*
|
||||
* Each port is its own message with its own type, so the value follows the
|
||||
* port rather than the node — and since what an inject emits is also what it
|
||||
* remembers, the field is where that default lives. A port with nothing of its
|
||||
* own reads through to `payload`, which is what a flow written before this had.
|
||||
*/
|
||||
function InjectPayloads({
|
||||
provides,
|
||||
params,
|
||||
onChange,
|
||||
}: {
|
||||
provides: MessageSpec[]
|
||||
params: Record<string, unknown>
|
||||
onChange: (next: Record<string, unknown>) => void
|
||||
}) {
|
||||
const payloads = (params.payloads ?? {}) as Record<string, unknown>
|
||||
const ports = provides.filter((spec) => portName(spec))
|
||||
|
||||
const set = (port: string, value: unknown) => {
|
||||
const next = { ...payloads }
|
||||
// An empty field means the current time, which is having no value at all.
|
||||
if (value === null) delete next[port]
|
||||
else next[port] = value
|
||||
onChange({ ...params, payloads: next })
|
||||
}
|
||||
|
||||
if (ports.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add a port for this to publish on.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ports.map((spec) => {
|
||||
const port = portName(spec)
|
||||
return (
|
||||
<div key={port} className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={`payload-${port}`}
|
||||
className="font-mono text-sm font-normal"
|
||||
>
|
||||
{port}
|
||||
</Label>
|
||||
<DtypeValue
|
||||
id={`payload-${port}`}
|
||||
dtype={spec.dtype}
|
||||
value={
|
||||
port in payloads ? payloads[port] : (params.payload ?? null)
|
||||
}
|
||||
label={`What to emit on ${port}`}
|
||||
placeholder="now"
|
||||
className="text-sm"
|
||||
onChange={(value) => set(port, value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** A small form built from the node type's declared parameters. */
|
||||
function ParamsForm({
|
||||
type,
|
||||
schema,
|
||||
params,
|
||||
provides,
|
||||
onChange,
|
||||
}: {
|
||||
type: string | undefined
|
||||
schema: Record<string, unknown> | undefined
|
||||
params: Record<string, unknown>
|
||||
/** The ports an inject's payload fields follow; unused by every other type. */
|
||||
provides: MessageSpec[]
|
||||
onChange: (next: Record<string, unknown>) => void
|
||||
}) {
|
||||
const properties = (schema?.properties ?? {}) as Record<
|
||||
@@ -637,6 +719,19 @@ function ParamsForm({
|
||||
<div className="grid gap-3">
|
||||
<span className={SECTION}>Settings</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.
|
||||
if (type === "inject" && key === "payload") {
|
||||
return (
|
||||
<InjectPayloads
|
||||
key={key}
|
||||
provides={provides}
|
||||
params={params}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const value = params[key] ?? property.default ?? ""
|
||||
const label = property.title ?? key
|
||||
|
||||
@@ -718,6 +813,7 @@ function ParamsForm({
|
||||
{key === "cron" ? (
|
||||
<CronHelp
|
||||
params={params}
|
||||
derivable={type === "inject"}
|
||||
onPick={(expression) => set(key, expression)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -927,6 +1023,9 @@ function PanelBody({
|
||||
})
|
||||
|
||||
const [code, setCode] = useState<string | null>(null)
|
||||
// The port the X is dropping, noted just before the shorter list arrives:
|
||||
// both are one edit, and a second one would only overwrite the first.
|
||||
const dropped = useRef<string | null>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pending = useRef<string | null>(null)
|
||||
const save = useRef(onSaveSource)
|
||||
@@ -966,6 +1065,33 @@ function PanelBody({
|
||||
if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted)
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-port inject value belongs to its port, so it follows it: carried
|
||||
* along by a finished rename, dropped with the port itself (`to` of null).
|
||||
* A node holding none of them — every other type — comes back untouched,
|
||||
* which is what makes the same reference a fair test for "nothing to do".
|
||||
*/
|
||||
const repoint = (next: NodeDef_Input, from: string, to: string | null) => {
|
||||
const payloads = (next.params?.payloads ?? {}) as Record<string, unknown>
|
||||
if (!from || from === to || !(from in payloads)) return next
|
||||
const moved = { ...payloads }
|
||||
if (to) moved[to] = moved[from]
|
||||
delete moved[from]
|
||||
return { ...next, params: { ...next.params, payloads: moved } }
|
||||
}
|
||||
|
||||
const renamePort = (previous: string, next: string) => {
|
||||
onRenameMessage(previous, next)
|
||||
// Only a finished rename moves a value; per keystroke it would delete it
|
||||
// on the first character typed.
|
||||
const moved = repoint(
|
||||
node,
|
||||
portName({ name: previous }),
|
||||
portName({ name: next }),
|
||||
)
|
||||
if (moved !== node) editNode(moved)
|
||||
}
|
||||
|
||||
// ⌘S in the editor means "apply this code"; the editor's own state is here,
|
||||
// so the binding is too. Anywhere else on the canvas it publishes the flow.
|
||||
useShortcuts(
|
||||
@@ -1016,16 +1142,24 @@ function PanelBody({
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node publishes."
|
||||
suggestions={suggestions.provides}
|
||||
onChange={(provides) => editNode({ ...node, provides })}
|
||||
onChange={(provides) => {
|
||||
const port = dropped.current
|
||||
dropped.current = null
|
||||
editNode(repoint({ ...node, provides }, port ?? "", null))
|
||||
}}
|
||||
streamable
|
||||
// Only the publishing side names a message; an input is as often
|
||||
// re-pointed at a different one as it is renamed.
|
||||
onRenamed={onRenameMessage}
|
||||
onRenamed={renamePort}
|
||||
onRemoved={(spec) => {
|
||||
dropped.current = portName(spec)
|
||||
}}
|
||||
/>
|
||||
<ParamsForm
|
||||
type={node.type}
|
||||
schema={nodeType?.params_schema}
|
||||
params={node.params ?? {}}
|
||||
provides={node.provides ?? []}
|
||||
onChange={(params) => editNode({ ...node, params })}
|
||||
/>
|
||||
{nodeType?.free_params ? (
|
||||
|
||||
@@ -211,6 +211,15 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* A keyboard has to see where it is. The outline is off above by design, so
|
||||
* focus is answered the way a neuron answers it — by recolouring the node's own
|
||||
* rim rather than by adding a ring around it. `--ring` equals `--primary`.
|
||||
*/
|
||||
.react-flow__node:focus-visible .flow-node-card {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
/*
|
||||
* Brain graph. A neuron meets its connections all round its rim, so these two
|
||||
* handles are only there to make React Flow treat the node as wired at all —
|
||||
@@ -418,4 +427,44 @@
|
||||
.brain-wire.brain-hot .brain-dot {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* A value passing lights the line for the pulse and then decays; the dots sit
|
||||
* on the same signal, so they flash with it instead of only following the
|
||||
* falloff — same duration and easing as `edge-pulse`, and the same plain
|
||||
* `--edge-rest` at the far end rather than the `color-mix()` the group rests
|
||||
* in, which is what turned an interpolated pulse fluorescent.
|
||||
*
|
||||
* Two keyframe sets, because an animation beats a normal declaration whatever
|
||||
* its specificity: animating `fill` for every dot would fill the hollow
|
||||
* arriving end for the length of the pulse, and that hole is what tells the
|
||||
* two ends apart.
|
||||
*/
|
||||
.brain-wire.brain-hot .brain-dot {
|
||||
animation: brain-dot-pulse var(--duration-pulse) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.brain-wire.brain-hot .brain-dot.brain-dot-out {
|
||||
animation: brain-dot-out-pulse var(--duration-pulse) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
@keyframes brain-dot-pulse {
|
||||
from {
|
||||
stroke: var(--primary);
|
||||
}
|
||||
to {
|
||||
stroke: var(--edge-rest);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes brain-dot-out-pulse {
|
||||
from {
|
||||
stroke: var(--primary);
|
||||
fill: var(--primary);
|
||||
}
|
||||
to {
|
||||
stroke: var(--edge-rest);
|
||||
fill: var(--edge-rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useCallback, useSyncExternalStore } from "react"
|
||||
|
||||
import { OpenAPI } from "@/client"
|
||||
import { request } from "@/client/core/request"
|
||||
|
||||
/**
|
||||
* Live engine state, deliberately outside React Query.
|
||||
*
|
||||
@@ -29,6 +32,16 @@ export type NodeHealth = {
|
||||
health: "ok" | "down" | "unknown"
|
||||
detail?: string | null
|
||||
}
|
||||
/**
|
||||
* A node's last failure, kept after it has run again.
|
||||
*
|
||||
* `LiveStatus` answers "how did the last run go", which is what the red dot
|
||||
* has to keep telling the truth about. This answers the other question — did
|
||||
* this node fail at all since anyone looked — and the engine answers it too,
|
||||
* so a reload and a second browser see the same thing. Only acknowledging one
|
||||
* clears it.
|
||||
*/
|
||||
export type NodeFailure = { error: string; ts: number }
|
||||
/** Something the engine reported about itself, for the health page. */
|
||||
export type EngineEvent = {
|
||||
type: string
|
||||
@@ -56,6 +69,7 @@ const ENGINE_EVENT_LIMIT = 100
|
||||
|
||||
const values = new Map<string, LiveValue>()
|
||||
const statuses = new Map<string, LiveStatus>()
|
||||
const failures = new Map<string, NodeFailure>()
|
||||
const health = new Map<string, NodeHealth>()
|
||||
let engineEvents: EngineEvent[] = []
|
||||
// How many times this page has seen a node emit. The number itself means
|
||||
@@ -107,9 +121,26 @@ export const liveStore = {
|
||||
setStatus(nodeId: string, status: LiveStatus) {
|
||||
statuses.set(nodeId, status)
|
||||
notify(`status:${nodeId}`)
|
||||
// Recorded here rather than waited for: the engine keeps the same record,
|
||||
// but the canvas has to mark the failure as it happens. A node only ever
|
||||
// reaches this store as "error" by having raised.
|
||||
if (status.status === "error") {
|
||||
failures.set(nodeId, {
|
||||
error: status.error || "The node raised while running.",
|
||||
ts: Date.now() / 1000,
|
||||
})
|
||||
notify(`failure:${nodeId}`)
|
||||
}
|
||||
},
|
||||
setStatuses(
|
||||
entries: { id: string; status: string; error?: string | null }[],
|
||||
entries: {
|
||||
id: string
|
||||
status: string
|
||||
error?: string | null
|
||||
/** The engine's own record of the last failure — see `NodeFailure`. */
|
||||
last_error?: string | null
|
||||
last_error_ts?: number | null
|
||||
}[],
|
||||
) {
|
||||
for (const entry of entries) {
|
||||
statuses.set(entry.id, {
|
||||
@@ -117,11 +148,46 @@ export const liveStore = {
|
||||
error: entry.error,
|
||||
})
|
||||
notify(`status:${entry.id}`)
|
||||
// The engine's record wins, in both directions: it is what a reload
|
||||
// reads, and an empty one means someone has acknowledged the failure.
|
||||
if (entry.last_error) {
|
||||
failures.set(entry.id, {
|
||||
error: entry.last_error,
|
||||
ts: entry.last_error_ts ?? Date.now() / 1000,
|
||||
})
|
||||
notify(`failure:${entry.id}`)
|
||||
} else if (failures.delete(entry.id)) {
|
||||
notify(`failure:${entry.id}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
getStatus(nodeId: string) {
|
||||
return statuses.get(nodeId)
|
||||
},
|
||||
getFailure(nodeId: string) {
|
||||
return failures.get(nodeId)
|
||||
},
|
||||
/**
|
||||
* Dismiss a node's failure, here and on the engine.
|
||||
*
|
||||
* The only thing that clears one: a good run afterwards deliberately does
|
||||
* not, which is what makes a failure between two glances at the canvas
|
||||
* findable at all.
|
||||
*/
|
||||
acknowledgeFailure(nodeId: string) {
|
||||
if (!failures.delete(nodeId)) return
|
||||
notify(`failure:${nodeId}`)
|
||||
const [flow, node] = nodeId.split(".")
|
||||
if (!flow || !node) return
|
||||
// Hand-written rather than generated: nothing hangs off the response, and
|
||||
// a call that does not arrive only means the marker is back after a
|
||||
// reload — which is the safer way round for a failure.
|
||||
request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/flows/{name}/nodes/{node_id}/acknowledge",
|
||||
path: { name: flow, node_id: node },
|
||||
}).catch(() => {})
|
||||
},
|
||||
setHealth(nodeId: string, entry: NodeHealth) {
|
||||
health.set(nodeId, entry)
|
||||
notify(`health:${nodeId}`)
|
||||
@@ -193,6 +259,8 @@ export const liveStore = {
|
||||
values.clear()
|
||||
for (const key of statuses.keys()) notify(`status:${key}`)
|
||||
statuses.clear()
|
||||
for (const key of failures.keys()) notify(`failure:${key}`)
|
||||
failures.clear()
|
||||
for (const key of new Set([...emits.keys(), ...priorEmits.keys()]))
|
||||
notify(`emit:${key}`)
|
||||
emits.clear()
|
||||
@@ -222,6 +290,14 @@ export function useNodeStatus(nodeId: string): LiveStatus | undefined {
|
||||
)
|
||||
}
|
||||
|
||||
/** The node's last failure, until someone acknowledges it. */
|
||||
export function useNodeFailure(nodeId: string): NodeFailure | undefined {
|
||||
return useSyncExternalStore(
|
||||
(listener) => subscribeKey(`failure:${nodeId}`, listener),
|
||||
() => failures.get(nodeId),
|
||||
)
|
||||
}
|
||||
|
||||
/** How the node's connection is doing, once it has said anything about it. */
|
||||
export function useNodeHealth(nodeId: string): NodeHealth | undefined {
|
||||
return useSyncExternalStore(
|
||||
@@ -324,19 +400,29 @@ export function useGroupActive(ids: string[]): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether any of these nodes is currently failing. */
|
||||
export function useGroupError(ids: string[]): boolean {
|
||||
/**
|
||||
* What any of these nodes last failed with, or `""` for none.
|
||||
*
|
||||
* The failure rather than the live status: a neuron stands for nodes across
|
||||
* several flows, and Home would otherwise disagree with the canvas the moment
|
||||
* one of them ran again. The reason itself, because a string is a snapshot
|
||||
* `useSyncExternalStore` can compare and the neuron shows it in its tooltip.
|
||||
*/
|
||||
export function useGroupFailure(ids: string[]): string {
|
||||
const joined = ids.join(SEP)
|
||||
return useSyncExternalStore(
|
||||
useCallback(
|
||||
(listener: Listener) =>
|
||||
subscribeAll(
|
||||
parts(joined).map((id) => `status:${id}`),
|
||||
parts(joined).map((id) => `failure:${id}`),
|
||||
listener,
|
||||
),
|
||||
[joined],
|
||||
),
|
||||
() => parts(joined).some((id) => statuses.get(id)?.status === "error"),
|
||||
() =>
|
||||
parts(joined)
|
||||
.map((id) => failures.get(id)?.error ?? "")
|
||||
.find(Boolean) ?? "",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { OpenAPI } from "@/client"
|
||||
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
|
||||
import { connectionStore } from "@/lib/connectionStore"
|
||||
import { apiToken } from "@/lib/portal"
|
||||
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
|
||||
@@ -87,6 +88,7 @@ type FlowEvent =
|
||||
nodes: { id: string; status: string; error?: string | null }[]
|
||||
paused?: string[]
|
||||
}
|
||||
| { type: "dashboard_changed"; dashboard?: string; ts?: number }
|
||||
|
||||
function socketUrl(): string {
|
||||
const base = String(OpenAPI.BASE || window.location.origin)
|
||||
@@ -237,6 +239,18 @@ function connect() {
|
||||
// markers on the flow chips are stale until the list is refetched.
|
||||
client?.invalidateQueries({ queryKey: flowKeys.all })
|
||||
break
|
||||
case "dashboard_changed":
|
||||
// Someone published, or changed which dashboards a panel shows. Refetch
|
||||
// rather than reload: a wall screen must not blank or sign in again.
|
||||
// `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)
|
||||
: panelKeys.all,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user