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
+35 -1
View File
@@ -278,6 +278,7 @@ def read_flows(controller: FlowControllerDep) -> Any:
enabled=controller.is_enabled(name),
paused=controller.is_paused(name),
quarantined=controller.is_quarantined(name),
version=definition.version,
)
)
return FlowsPublic(data=summaries, count=len(summaries))
@@ -720,6 +721,25 @@ def cancel_node(name: str, node_id: str, controller: FlowControllerDep) -> Any:
return Message(message=f"'{node_id}' was not running")
@router.post("/{name}/nodes/{node_id}/acknowledge", response_model=Message)
def acknowledge_node_error(
name: str, node_id: str, controller: FlowControllerDep
) -> Any:
"""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.
"""
try:
controller.acknowledge_error(f"{name}.{node_id}")
except KeyError:
raise HTTPException(
status_code=404, detail=f"No node named '{node_id}' in flow '{name}'"
) from None
return Message(message=f"Cleared the failure on '{node_id}'")
@router.get("/{name}/state", response_model=FlowStatePublic)
def read_flow_state(name: str, controller: FlowControllerDep) -> Any:
"""The last value seen on every message of this flow."""
@@ -815,8 +835,12 @@ def event_for_panel(event: dict[str, Any], only: set[str]) -> bool:
"""Whether a panel's socket should carry this event.
The same bound as the snapshot above, applied to the stream that follows
it: a value the panel draws, and nothing else on the bus.
it: a value the panel draws, and nothing else on the bus — save for a
dashboard being published, which is how a screen hears that the document
it is drawing, or the set of them it was given, has moved.
"""
if event.get("type") == "dashboard_changed":
return True
return event.get("type") == "message_value" and str(event.get("name") or "") in only
@@ -855,6 +879,16 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
sender.cancel()
break
event = sender.result()
if only is not None and event.get("type") == "dashboard_changed":
# The scope was resolved once, at the handshake. A panel
# pointed at another dashboard would otherwise fetch the
# new document and then draw tiles nothing ever updates.
# ``or set()`` because a panel that was deleted resolves to
# None, the same as a person's token — and that would widen
# this socket to everything on the bus.
only = panel_scope(token, websocket.app) or set()
if controller is not None:
await websocket.send_json(snapshot_payload(controller, only))
if only is not None and not event_for_panel(event, only):
continue
await websocket.send_json(event)
+64
View File
@@ -10,8 +10,10 @@ from __future__ import annotations
import asyncio
import logging
import time
import traceback
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, cast
@@ -91,6 +93,11 @@ class LoadedNode:
error: str | None = None
health: Health = "ok"
health_detail: str | None = None
#: The last time this node raised while running, and what it said. Kept
#: after it has run again — a failure nobody saw is the one worth keeping —
#: so only an acknowledgement clears it, not a good run and not a rebuild.
last_error: str = ""
last_error_ts: float | None = None
@dataclass
@@ -307,12 +314,18 @@ class FlowController:
self.supervisor = Supervisor(events)
self.history_limits: dict[str, int] = {}
self._lock = asyncio.Lock()
self._failures: asyncio.Task[None] | None = None
# -------------------------------------------------------------------------
# Lifecycle
# -------------------------------------------------------------------------
async def start(self) -> None:
# Subscribed before anything runs, so no failure falls between the
# first build and someone watching for one.
self._failures = asyncio.create_task(
self._watch_failures(), name="node-failures"
)
# Build first: the consumer must have a pipeline to execute against
# before it claims anything, or work waiting from the last run would be
# taken and dropped — which is the very case the queue exists for.
@@ -321,10 +334,50 @@ class FlowController:
self.execution.start()
async def stop(self) -> None:
watcher, self._failures = self._failures, None
if watcher is not None:
watcher.cancel()
with suppress(asyncio.CancelledError):
await watcher
await self._teardown()
if self.execution is not None:
await run_in_threadpool(self.execution.stop)
async def _watch_failures(self) -> None:
"""Record every node failure the engine reports, from the event bus.
A node can be run by the live pipeline, by a run's own, or by hand, and
each builds its own graph — but they all report through
``Pipeline.publish_error``, so the bus is the one place they meet.
Which is the point: what is recorded here is the node's, not any
particular pipeline's, and it outlives both the good run that follows
it and the rebuild after that.
"""
if self.events is None:
return
async with self.events.subscribe() as queue:
while True:
event = await queue.get()
if event.get("type") != "node_error":
continue
entry = self.loaded.get(str(event.get("node") or ""))
if entry is None:
continue
entry.last_error = str(event.get("error") or "")
entry.last_error_ts = float(event.get("ts") or time.time())
def acknowledge_error(self, node_id: str) -> None:
"""Forget what a node's last failure was. The only thing that clears it.
Raises ``KeyError`` for a node the engine does not have, so a route can
answer that the way every other node call does.
"""
entry = self.loaded.get(node_id)
if entry is None:
raise KeyError(node_id)
entry.last_error = ""
entry.last_error_ts = None
async def set_enabled(self, flow: str, enabled: bool) -> None:
"""Stop or start one flow. Rebuilding is what applies it."""
await run_in_threadpool(self.store.write_enabled, flow, enabled)
@@ -360,6 +413,15 @@ class FlowController:
self._build_flows, [(flow, False) for flow in published]
)
# A rebuild is a fresh set of nodes, but not a fresh history: every
# publish rebuilds every flow, so dropping the failures here would
# wipe them constantly. They are the operator's to dismiss.
for node_id, entry in loaded.items():
previous = self.loaded.get(node_id)
if previous is not None and previous.last_error:
entry.last_error = previous.last_error
entry.last_error_ts = previous.last_error_ts
self.loaded = loaded
self.pipeline = Pipeline(
nodes=nodes,
@@ -600,6 +662,8 @@ class FlowController:
error=entry.error,
health=entry.health,
health_detail=entry.health_detail,
last_error=entry.last_error,
last_error_ts=entry.last_error_ts,
)
for entry in self.loaded.values()
if flow is None or entry.flow == flow
+21 -8
View File
@@ -28,7 +28,8 @@ class InjectNode(Node):
"""Emit a value: on request, every n seconds, on a schedule, or at startup.
The value is whatever ``payload`` says, or the current time when it says
nothing — a timestamp is what most schedules actually want.
nothing — a timestamp is what most schedules actually want. ``payloads``
overrides that per output port, for a node that starts more than one thing.
"""
class Params(BaseModel):
@@ -38,6 +39,13 @@ class InjectNode(Node):
default=None,
description="What to emit. Empty emits the current time.",
)
payloads: dict[str, Any] = Field(
default_factory=dict,
description=(
"What to emit on each output port, keyed by port name. A port not "
"named here falls back to `payload`."
),
)
interval: float = Field(
default=0,
ge=0,
@@ -76,13 +84,19 @@ class InjectNode(Node):
name=name or "inject",
)
def _payload(self) -> Any:
return time.time() if self.cfg.payload is None else self.cfg.payload
def _values(self) -> dict[str, Any]:
"""One value per output port: its own, or the node-wide payload."""
# Read once, so an emission that falls back carries a single timestamp
# across every port rather than one per port.
fallback = time.time() if self.cfg.payload is None else self.cfg.payload
return {
spec.port: self.cfg.payloads.get(spec.port, fallback)
for spec in self.output_ports
}
def _emit(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None:
"""Every output carries the same value; that is what injecting means."""
value = self._payload()
return {spec.port: value for spec in self.output_ports} or None
"""Each output carries what its port says to emit."""
return self._values() or None
# -------------------------------------------------------------------------
# Its own schedule
@@ -149,8 +163,7 @@ class InjectNode(Node):
pass
async def _fire(self) -> None:
value = self._payload()
outputs = {spec.port: value for spec in self.output_ports}
outputs = self._values()
if outputs:
# inject runs the graph, which is blocking work.
await asyncio.to_thread(self.inject, outputs)
+24 -1
View File
@@ -43,7 +43,7 @@ FIXTURES: dict[str, dict] = {
"provides": [MessageSpec(name="score", dtype=DType.FLOAT)],
},
"inject": {
"params": {"payload": 1.0, "interval": 60},
"params": {"payload": 1.0, "payloads": {"tick": 2.0}, "interval": 60},
"requires": [],
"provides": [MessageSpec(name="tick", dtype=DType.FLOAT)],
},
@@ -124,6 +124,29 @@ def test_mqtt_routes_topics_by_port():
assert node._topic_to_ports == {"house/temp": ["temp"], "house/hum": ["humidity"]}
def test_inject_emits_per_port_and_falls_back_to_one_payload():
"""A port named in `payloads` gets its own value; the rest share `payload`."""
# Each port is published as its own declared type, which `check` enforces.
typed = NODE_TYPES["inject"].cls(
provides=[
MessageSpec(name="flag", dtype=DType.BOOL),
MessageSpec(name="count", dtype=DType.INT),
],
params={"payloads": {"flag": True, "count": 3}},
)
assert typed.execute({}) == {"flag": True, "count": 3}
# No `payloads` at all is what every flow written so far carries.
shared = NODE_TYPES["inject"].cls(
provides=[
MessageSpec(name="left", dtype=DType.FLOAT),
MessageSpec(name="right", dtype=DType.FLOAT),
],
params={"payload": 1.0},
)
assert shared.execute({}) == {"left": 1.0, "right": 1.0}
def test_every_offered_type_has_a_fixture():
# A new built-in without a fixture here would ship untested. Connectors are
# separate packages and carry their own tests, so they are not this suite's.
+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"
+67 -30
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)}
<DtypeValue
dtype={spec.dtype}
value={declared.initial}
label="Starting value"
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),
})
}
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)
+32 -4
View File
@@ -19,6 +19,9 @@ const PRINTING_NODE = `def process():
const BROKEN_NODE = `def process(reading):
raise RuntimeError("downstream blew up")
`
const FIXED_NODE = `def process(reading):
print(f"logged {reading}")
`
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [`/flows/${flowName}`])
@@ -61,11 +64,9 @@ test.beforeAll(async ({ browser }) => {
await page.close()
})
test("the dashboard lists flows and can stop one", async ({ page }) => {
test("the home page lists flows and can stop one", async ({ page }) => {
await page.goto("/")
const row = page
.getByTestId("dashboard-flow-row")
.filter({ hasText: "Runtime" })
const row = page.getByTestId("home-flow-row").filter({ hasText: "Runtime" })
await expect(row).toBeVisible()
await expect(row).toContainText("Running")
@@ -117,6 +118,33 @@ test("the logs panel shows what a node printed and why one failed", async ({
await expect(panel).toBeHidden()
})
test("a node's failure outlives its next good run", async ({ page }) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
const logger = page.locator(".react-flow__node").filter({ hasText: "logger" })
await page.getByTestId("run-flow").click()
await expect(logger.getByTestId("node-traceback")).toBeVisible()
// Fix it the way the editor does, and put it on the engine: publishing
// rebuilds every node, which is exactly what must not wipe the record.
await api(page, `/flows/${flowName}/nodes/logger/source`, {
method: "PUT",
data: { code: FIXED_NODE },
})
const detail = await (await api(page, `/flows/${flowName}`)).json()
await api(page, `/flows/${flowName}/publish`, {
method: "POST",
data: { version: detail.definition.version },
})
await page.getByTestId("run-flow").click()
// The dot says how the *last* run went, and the traceback button says the
// node failed at some point since anyone looked. Both at once is the point.
await expect(logger.getByLabel("Last run succeeded")).toBeVisible()
await expect(logger.getByTestId("node-traceback")).toBeVisible()
})
test("a flow can be paused and let go again", async ({ page }) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")