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