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:
2026-08-21 14:33:41 +02:00
co-authored by Claude Opus 5
parent 06f84e18ae
commit b0efb4b0f1
19 changed files with 738 additions and 86 deletions
+36
View File
@@ -423,6 +423,11 @@ export const DashboardDef_InputSchema = {
title: 'Canvas Height',
default: 1080
},
icon: {
type: 'string',
title: 'Icon',
default: ''
},
pages: {
items: {
'$ref': '#/components/schemas/PageDef-Input'
@@ -479,6 +484,11 @@ export const DashboardDef_OutputSchema = {
title: 'Canvas Height',
default: 1080
},
icon: {
type: 'string',
title: 'Icon',
default: ''
},
pages: {
items: {
'$ref': '#/components/schemas/PageDef-Output'
@@ -528,6 +538,11 @@ export const DashboardSummarySchema = {
type: 'boolean',
title: 'Has Draft',
default: false
},
version: {
type: 'integer',
title: 'Version',
default: 1
}
},
type: 'object',
@@ -1003,6 +1018,11 @@ export const FlowSummarySchema = {
type: 'boolean',
title: 'Quarantined',
default: false
},
version: {
type: 'integer',
title: 'Version',
default: 1
}
},
type: 'object',
@@ -1679,6 +1699,22 @@ export const NodeStatusPublicSchema = {
}
],
title: 'Health Detail'
},
last_error: {
type: 'string',
title: 'Last Error',
default: ''
},
last_error_ts: {
anyOf: [
{
type: 'number'
},
{
type: 'null'
}
],
title: 'Last Error Ts'
}
},
type: 'object',
+31 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
export class AlertsService {
/**
@@ -235,6 +235,9 @@ export class DashboardsService {
/**
* Create Dashboard
* Start a dashboard: one page, one section, nothing on it yet.
*
* A draft, like every edit that follows it — a dashboard reaches a panel
* only once someone publishes it, so an empty one never does.
* @param data The data for the request.
* @param data.name
* @returns DashboardDef_Output Successful Response
@@ -899,6 +902,33 @@ export class FlowsService {
});
}
/**
* Acknowledge Node Error
* Dismiss what a node last failed with, so the canvas stops marking it.
*
* A failure outlives the next good run on purpose — otherwise one that fired
* an alert leaves no trace by the time anyone looks. Reading the traceback is
* what says it has been seen.
* @param data The data for the request.
* @param data.name
* @param data.nodeId
* @returns Message Successful Response
* @throws ApiError
*/
public static acknowledgeNodeError(data: FlowsAcknowledgeNodeErrorData): CancelablePromise<FlowsAcknowledgeNodeErrorResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/flows/{name}/nodes/{node_id}/acknowledge',
path: {
name: data.name,
node_id: data.nodeId
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Read Flow State
* The last value seen on every message of this flow.
+13
View File
@@ -189,6 +189,7 @@ export type DashboardDef_Input = {
columns?: number;
canvas_width?: number;
canvas_height?: number;
icon?: string;
pages?: Array<PageDef_Input>;
version?: number;
has_draft?: boolean;
@@ -203,6 +204,7 @@ export type DashboardDef_Output = {
columns?: number;
canvas_width?: number;
canvas_height?: number;
icon?: string;
pages?: Array<PageDef_Output>;
version?: number;
has_draft?: boolean;
@@ -222,6 +224,7 @@ export type DashboardSummary = {
page_count?: number;
widget_count?: number;
has_draft?: boolean;
version?: number;
};
export type DeadLetter = {
@@ -391,6 +394,7 @@ export type FlowSummary = {
enabled?: boolean;
paused?: boolean;
quarantined?: boolean;
version?: number;
};
export type HealthSummary = {
@@ -633,6 +637,8 @@ export type NodeStatusPublic = {
error?: (string | null);
health?: 'ok' | 'degraded' | 'down';
health_detail?: (string | null);
last_error?: string;
last_error_ts?: (number | null);
};
export type health = 'ok' | 'degraded' | 'down';
@@ -1294,6 +1300,13 @@ export type FlowsCancelNodeData = {
export type FlowsCancelNodeResponse = (Message);
export type FlowsAcknowledgeNodeErrorData = {
name: string;
nodeId: string;
};
export type FlowsAcknowledgeNodeErrorResponse = (Message);
export type FlowsReadFlowStateData = {
name: string;
};
+18 -12
View File
@@ -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>
+19 -3
View File
@@ -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"
+69 -32
View File
@@ -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"
+24 -2
View File
@@ -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>
+46 -8
View File
@@ -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}
+138 -4
View File
@@ -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 ? (
+49
View File
@@ -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);
}
}
}
+91 -5
View File
@@ -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
}
}
@@ -4,8 +4,16 @@ import { Suspense } from "react"
import { FlowEditor } from "@/components/Flow/FlowEditor"
import { Skeleton } from "@/components/ui/skeleton"
type Search = { node?: string }
export const Route = createFileRoute("/_canvas/flows/$flowName")({
component: FlowRoute,
// Which node to arrive on. A search param rather than a hash so the brain
// graph's click-through — and any link out of an alert — is a whole address
// someone can send.
validateSearch: (search: Record<string, unknown>): Search => ({
node: typeof search.node === "string" ? search.node : undefined,
}),
head: ({ params }) => ({
meta: [{ title: `${params.flowName} - Fluksio` }],
}),
@@ -21,10 +29,11 @@ function Loading() {
function FlowRoute() {
const { flowName } = Route.useParams()
const { node } = Route.useSearch()
return (
<Suspense fallback={<Loading />}>
<FlowEditor flowName={flowName} />
<FlowEditor flowName={flowName} focus={node} />
</Suspense>
)
}
+4 -4
View File
@@ -17,11 +17,11 @@ import { Switch } from "@/components/ui/switch"
import useCustomToast from "@/hooks/useCustomToast"
export const Route = createFileRoute("/_layout/")({
component: Dashboard,
component: Home,
head: () => ({
meta: [
{
title: "Dashboard - Fluksio",
title: "Home - Fluksio",
},
],
}),
@@ -50,7 +50,7 @@ function FlowRow({ flow }: { flow: FlowSummary }) {
return (
<div
className="flex items-center gap-3 border-b border-border px-5 py-3 last:border-b-0"
data-testid="dashboard-flow-row"
data-testid="home-flow-row"
>
<Link
to="/flows/$flowName"
@@ -100,7 +100,7 @@ function FlowRow({ flow }: { flow: FlowSummary }) {
* it has been doing. The brain and the health screens compose in here rather
* than living at routes of their own.
*/
function Dashboard() {
function Home() {
// The health block's window: one choice, read by the tiles, the flow table,
// the charts and the lists under them.
const [range, setRange] = useState(DEFAULT_RANGE)