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),
|
enabled=controller.is_enabled(name),
|
||||||
paused=controller.is_paused(name),
|
paused=controller.is_paused(name),
|
||||||
quarantined=controller.is_quarantined(name),
|
quarantined=controller.is_quarantined(name),
|
||||||
|
version=definition.version,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return FlowsPublic(data=summaries, count=len(summaries))
|
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")
|
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)
|
@router.get("/{name}/state", response_model=FlowStatePublic)
|
||||||
def read_flow_state(name: str, controller: FlowControllerDep) -> Any:
|
def read_flow_state(name: str, controller: FlowControllerDep) -> Any:
|
||||||
"""The last value seen on every message of this flow."""
|
"""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.
|
"""Whether a panel's socket should carry this event.
|
||||||
|
|
||||||
The same bound as the snapshot above, applied to the stream that follows
|
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
|
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()
|
sender.cancel()
|
||||||
break
|
break
|
||||||
event = sender.result()
|
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):
|
if only is not None and not event_for_panel(event, only):
|
||||||
continue
|
continue
|
||||||
await websocket.send_json(event)
|
await websocket.send_json(event)
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
@@ -91,6 +93,11 @@ class LoadedNode:
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
health: Health = "ok"
|
health: Health = "ok"
|
||||||
health_detail: str | None = None
|
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
|
@dataclass
|
||||||
@@ -307,12 +314,18 @@ class FlowController:
|
|||||||
self.supervisor = Supervisor(events)
|
self.supervisor = Supervisor(events)
|
||||||
self.history_limits: dict[str, int] = {}
|
self.history_limits: dict[str, int] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._failures: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
async def start(self) -> None:
|
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
|
# Build first: the consumer must have a pipeline to execute against
|
||||||
# before it claims anything, or work waiting from the last run would be
|
# 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.
|
# taken and dropped — which is the very case the queue exists for.
|
||||||
@@ -321,10 +334,50 @@ class FlowController:
|
|||||||
self.execution.start()
|
self.execution.start()
|
||||||
|
|
||||||
async def stop(self) -> None:
|
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()
|
await self._teardown()
|
||||||
if self.execution is not None:
|
if self.execution is not None:
|
||||||
await run_in_threadpool(self.execution.stop)
|
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:
|
async def set_enabled(self, flow: str, enabled: bool) -> None:
|
||||||
"""Stop or start one flow. Rebuilding is what applies it."""
|
"""Stop or start one flow. Rebuilding is what applies it."""
|
||||||
await run_in_threadpool(self.store.write_enabled, flow, enabled)
|
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]
|
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.loaded = loaded
|
||||||
self.pipeline = Pipeline(
|
self.pipeline = Pipeline(
|
||||||
nodes=nodes,
|
nodes=nodes,
|
||||||
@@ -600,6 +662,8 @@ class FlowController:
|
|||||||
error=entry.error,
|
error=entry.error,
|
||||||
health=entry.health,
|
health=entry.health,
|
||||||
health_detail=entry.health_detail,
|
health_detail=entry.health_detail,
|
||||||
|
last_error=entry.last_error,
|
||||||
|
last_error_ts=entry.last_error_ts,
|
||||||
)
|
)
|
||||||
for entry in self.loaded.values()
|
for entry in self.loaded.values()
|
||||||
if flow is None or entry.flow == flow
|
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.
|
"""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
|
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):
|
class Params(BaseModel):
|
||||||
@@ -38,6 +39,13 @@ class InjectNode(Node):
|
|||||||
default=None,
|
default=None,
|
||||||
description="What to emit. Empty emits the current time.",
|
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(
|
interval: float = Field(
|
||||||
default=0,
|
default=0,
|
||||||
ge=0,
|
ge=0,
|
||||||
@@ -76,13 +84,19 @@ class InjectNode(Node):
|
|||||||
name=name or "inject",
|
name=name or "inject",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _payload(self) -> Any:
|
def _values(self) -> dict[str, Any]:
|
||||||
return time.time() if self.cfg.payload is None else self.cfg.payload
|
"""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:
|
def _emit(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None:
|
||||||
"""Every output carries the same value; that is what injecting means."""
|
"""Each output carries what its port says to emit."""
|
||||||
value = self._payload()
|
return self._values() or None
|
||||||
return {spec.port: value for spec in self.output_ports} or None
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Its own schedule
|
# Its own schedule
|
||||||
@@ -149,8 +163,7 @@ class InjectNode(Node):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def _fire(self) -> None:
|
async def _fire(self) -> None:
|
||||||
value = self._payload()
|
outputs = self._values()
|
||||||
outputs = {spec.port: value for spec in self.output_ports}
|
|
||||||
if outputs:
|
if outputs:
|
||||||
# inject runs the graph, which is blocking work.
|
# inject runs the graph, which is blocking work.
|
||||||
await asyncio.to_thread(self.inject, outputs)
|
await asyncio.to_thread(self.inject, outputs)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ FIXTURES: dict[str, dict] = {
|
|||||||
"provides": [MessageSpec(name="score", dtype=DType.FLOAT)],
|
"provides": [MessageSpec(name="score", dtype=DType.FLOAT)],
|
||||||
},
|
},
|
||||||
"inject": {
|
"inject": {
|
||||||
"params": {"payload": 1.0, "interval": 60},
|
"params": {"payload": 1.0, "payloads": {"tick": 2.0}, "interval": 60},
|
||||||
"requires": [],
|
"requires": [],
|
||||||
"provides": [MessageSpec(name="tick", dtype=DType.FLOAT)],
|
"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"]}
|
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():
|
def test_every_offered_type_has_a_fixture():
|
||||||
# A new built-in without a fixture here would ship untested. Connectors are
|
# 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.
|
# 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',
|
title: 'Canvas Height',
|
||||||
default: 1080
|
default: 1080
|
||||||
},
|
},
|
||||||
|
icon: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Icon',
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
pages: {
|
pages: {
|
||||||
items: {
|
items: {
|
||||||
'$ref': '#/components/schemas/PageDef-Input'
|
'$ref': '#/components/schemas/PageDef-Input'
|
||||||
@@ -479,6 +484,11 @@ export const DashboardDef_OutputSchema = {
|
|||||||
title: 'Canvas Height',
|
title: 'Canvas Height',
|
||||||
default: 1080
|
default: 1080
|
||||||
},
|
},
|
||||||
|
icon: {
|
||||||
|
type: 'string',
|
||||||
|
title: 'Icon',
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
pages: {
|
pages: {
|
||||||
items: {
|
items: {
|
||||||
'$ref': '#/components/schemas/PageDef-Output'
|
'$ref': '#/components/schemas/PageDef-Output'
|
||||||
@@ -528,6 +538,11 @@ export const DashboardSummarySchema = {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
title: 'Has Draft',
|
title: 'Has Draft',
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Version',
|
||||||
|
default: 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -1003,6 +1018,11 @@ export const FlowSummarySchema = {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
title: 'Quarantined',
|
title: 'Quarantined',
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Version',
|
||||||
|
default: 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -1679,6 +1699,22 @@ export const NodeStatusPublicSchema = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
title: 'Health Detail'
|
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',
|
type: 'object',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { CancelablePromise } from './core/CancelablePromise';
|
import type { CancelablePromise } from './core/CancelablePromise';
|
||||||
import { OpenAPI } from './core/OpenAPI';
|
import { OpenAPI } from './core/OpenAPI';
|
||||||
import { request as __request } from './core/request';
|
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 {
|
export class AlertsService {
|
||||||
/**
|
/**
|
||||||
@@ -235,6 +235,9 @@ export class DashboardsService {
|
|||||||
/**
|
/**
|
||||||
* Create Dashboard
|
* Create Dashboard
|
||||||
* Start a dashboard: one page, one section, nothing on it yet.
|
* 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 The data for the request.
|
||||||
* @param data.name
|
* @param data.name
|
||||||
* @returns DashboardDef_Output Successful Response
|
* @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
|
* Read Flow State
|
||||||
* The last value seen on every message of this flow.
|
* The last value seen on every message of this flow.
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export type DashboardDef_Input = {
|
|||||||
columns?: number;
|
columns?: number;
|
||||||
canvas_width?: number;
|
canvas_width?: number;
|
||||||
canvas_height?: number;
|
canvas_height?: number;
|
||||||
|
icon?: string;
|
||||||
pages?: Array<PageDef_Input>;
|
pages?: Array<PageDef_Input>;
|
||||||
version?: number;
|
version?: number;
|
||||||
has_draft?: boolean;
|
has_draft?: boolean;
|
||||||
@@ -203,6 +204,7 @@ export type DashboardDef_Output = {
|
|||||||
columns?: number;
|
columns?: number;
|
||||||
canvas_width?: number;
|
canvas_width?: number;
|
||||||
canvas_height?: number;
|
canvas_height?: number;
|
||||||
|
icon?: string;
|
||||||
pages?: Array<PageDef_Output>;
|
pages?: Array<PageDef_Output>;
|
||||||
version?: number;
|
version?: number;
|
||||||
has_draft?: boolean;
|
has_draft?: boolean;
|
||||||
@@ -222,6 +224,7 @@ export type DashboardSummary = {
|
|||||||
page_count?: number;
|
page_count?: number;
|
||||||
widget_count?: number;
|
widget_count?: number;
|
||||||
has_draft?: boolean;
|
has_draft?: boolean;
|
||||||
|
version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeadLetter = {
|
export type DeadLetter = {
|
||||||
@@ -391,6 +394,7 @@ export type FlowSummary = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
quarantined?: boolean;
|
quarantined?: boolean;
|
||||||
|
version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HealthSummary = {
|
export type HealthSummary = {
|
||||||
@@ -633,6 +637,8 @@ export type NodeStatusPublic = {
|
|||||||
error?: (string | null);
|
error?: (string | null);
|
||||||
health?: 'ok' | 'degraded' | 'down';
|
health?: 'ok' | 'degraded' | 'down';
|
||||||
health_detail?: (string | null);
|
health_detail?: (string | null);
|
||||||
|
last_error?: string;
|
||||||
|
last_error_ts?: (number | null);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type health = 'ok' | 'degraded' | 'down';
|
export type health = 'ok' | 'degraded' | 'down';
|
||||||
@@ -1294,6 +1300,13 @@ export type FlowsCancelNodeData = {
|
|||||||
|
|
||||||
export type FlowsCancelNodeResponse = (Message);
|
export type FlowsCancelNodeResponse = (Message);
|
||||||
|
|
||||||
|
export type FlowsAcknowledgeNodeErrorData = {
|
||||||
|
name: string;
|
||||||
|
nodeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlowsAcknowledgeNodeErrorResponse = (Message);
|
||||||
|
|
||||||
export type FlowsReadFlowStateData = {
|
export type FlowsReadFlowStateData = {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { memo, useEffect, useRef, useState } from "react"
|
|||||||
|
|
||||||
import { duration } from "@/lib/motion"
|
import { duration } from "@/lib/motion"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useGroupActive, useGroupEmits, useGroupError } from "./liveStore"
|
import { useGroupActive, useGroupEmits, useGroupFailure } from "./liveStore"
|
||||||
|
|
||||||
export type BrainNodeData = {
|
export type BrainNodeData = {
|
||||||
label: string
|
label: string
|
||||||
@@ -47,7 +47,10 @@ function BrainNodeComponent({ data }: NodeProps) {
|
|||||||
// Whether it has published at all, which the snapshot answers for what
|
// Whether it has published at all, which the snapshot answers for what
|
||||||
// happened before this page connected; `emits` only counts what we saw.
|
// happened before this page connected; `emits` only counts what we saw.
|
||||||
const active = useGroupActive(members)
|
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
|
// 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
|
// 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.
|
// 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),
|
borderWidth: Math.round(size * RING),
|
||||||
boxShadow: `inset 0 0 0 ${Math.round(size * GAP)}px var(--brain-gap)`,
|
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. */}
|
{/* Both ends sit at the centre; the edge trims itself back to the rim. */}
|
||||||
<Handle
|
<Handle
|
||||||
@@ -117,11 +120,16 @@ function BrainNodeComponent({ data }: NodeProps) {
|
|||||||
{/*
|
{/*
|
||||||
* A name under every circle is what makes the graph unreadable, so only
|
* 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
|
* 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
|
* one with something wrong, which always names itself: a red neuron
|
||||||
* status, and here it carries two of them. Two words rather than the
|
* nobody can put a name to is a hunt rather than a warning.
|
||||||
* 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
|
* Under the name, only the wiring fault says what it is. "Cannot run"
|
||||||
* says and the full text stays in the tooltip.
|
* 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:
|
* The word is `--foreground` rather than the terracotta beside it:
|
||||||
* `--brand-secondary` measures 2.2:1 on `--card` in light, which is a
|
* `--brand-secondary` measures 2.2:1 on `--card` in light, which is a
|
||||||
@@ -138,11 +146,9 @@ function BrainNodeComponent({ data }: NodeProps) {
|
|||||||
</span>
|
</span>
|
||||||
{/* The label box is only as wide as the circle it hangs under, so two
|
{/* The label box is only as wide as the circle it hangs under, so two
|
||||||
words break onto two lines unless told not to. */}
|
words break onto two lines unless told not to. */}
|
||||||
{problem ? (
|
{issue ? (
|
||||||
<span className="whitespace-nowrap text-xs font-medium text-foreground">
|
<span className="whitespace-nowrap text-xs font-medium text-foreground">
|
||||||
{[failed && "failed", issue && "cannot run"]
|
cannot run
|
||||||
.filter(Boolean)
|
|
||||||
.join(" · ")}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { scaleIn } from "@/lib/motion"
|
|||||||
import { BrainEdge, type BrainEdgeData } from "./BrainEdge"
|
import { BrainEdge, type BrainEdgeData } from "./BrainEdge"
|
||||||
import { BrainNode, type BrainNodeData } from "./BrainNode"
|
import { BrainNode, type BrainNodeData } from "./BrainNode"
|
||||||
import "./flow.css"
|
import "./flow.css"
|
||||||
|
import { liveStore } from "./liveStore"
|
||||||
import { graphQueryOptions } from "./queries"
|
import { graphQueryOptions } from "./queries"
|
||||||
import { useFlowSocket } from "./useFlowSocket"
|
import { useFlowSocket } from "./useFlowSocket"
|
||||||
|
|
||||||
@@ -228,9 +229,24 @@ function BrainCanvas() {
|
|||||||
setRevealed(node.id)
|
setRevealed(node.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const [flow] = (node.data as BrainNodeData).flows
|
// A neuron merges every node talking to the same thing, and its
|
||||||
if (flow)
|
// flows are in the order they contributed one — which is rarely the
|
||||||
navigate({ to: "/flows/$flowName", params: { flowName: flow } })
|
// 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)}
|
onPaneClick={() => setRevealed(null)}
|
||||||
className="brain-flat h-full w-full"
|
className="brain-flat h-full w-full"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { qualify } from "./deriveEdges"
|
import { qualify } from "./deriveEdges"
|
||||||
import { useLiveValue } from "./liveStore"
|
import { useLiveValue } from "./liveStore"
|
||||||
import { asText, DTYPES } from "./NodePanel"
|
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. */
|
/** Putting a declared value into the running graph, credited to the input. */
|
||||||
function usePublishInput() {
|
function usePublishInput() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -116,38 +177,14 @@ function InputRow({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{spec.dtype === "bool" ? (
|
<DtypeValue
|
||||||
<Select
|
dtype={spec.dtype}
|
||||||
value={declared.initial === true ? "true" : "false"}
|
value={declared.initial}
|
||||||
onValueChange={(next) =>
|
label="Starting value"
|
||||||
onChange({ ...declared, initial: next === "true" })
|
placeholder="starts at"
|
||||||
}
|
className="flex-1 text-sm"
|
||||||
>
|
onChange={(initial) => onChange({ ...declared, initial })}
|
||||||
<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),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
|||||||
@@ -192,9 +192,12 @@ function uniqueNodeId(existing: NodeDef_Input[], type: string): string {
|
|||||||
|
|
||||||
function FlowEditorInner({
|
function FlowEditorInner({
|
||||||
flowName,
|
flowName,
|
||||||
|
focus,
|
||||||
onReload,
|
onReload,
|
||||||
}: {
|
}: {
|
||||||
flowName: string
|
flowName: string
|
||||||
|
/** A node to arrive on, from the address bar. */
|
||||||
|
focus?: string
|
||||||
onReload: () => void
|
onReload: () => void
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -224,7 +227,7 @@ function FlowEditorInner({
|
|||||||
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
|
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
|
||||||
detail.definition.nodes ?? [],
|
detail.definition.nodes ?? [],
|
||||||
)
|
)
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(focus ?? null)
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||||
const [inspected, setInspected] = useState<InspectedEdge | null>(null)
|
const [inspected, setInspected] = useState<InspectedEdge | null>(null)
|
||||||
// The edges are derived, so xyflow's own selection would be thrown away on
|
// The edges are derived, so xyflow's own selection would be thrown away on
|
||||||
@@ -562,6 +565,14 @@ function FlowEditorInner({
|
|||||||
])
|
])
|
||||||
}, [key, direction, external, updateNodeInternals])
|
}, [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
|
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
|
// 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.
|
// 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))
|
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 navigate = useNavigate()
|
||||||
const onAuthFailure = useCallback(() => {
|
const onAuthFailure = useCallback(() => {
|
||||||
navigate({ to: "/login" })
|
navigate({ to: "/login" })
|
||||||
@@ -1383,10 +1401,14 @@ export function FlowEditor({ flowName }: { flowName: string }) {
|
|||||||
* Remounting per flow keeps canvas state from leaking between them, and
|
* 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
|
* 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.
|
* 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
|
<FlowEditorInner
|
||||||
key={`${flowName}:${epoch}`}
|
key={`${flowName}:${epoch}`}
|
||||||
flowName={flowName}
|
flowName={flowName}
|
||||||
|
focus={focus}
|
||||||
onReload={reload}
|
onReload={reload}
|
||||||
/>
|
/>
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ import { useIsMobile } from "@/hooks/useMobile"
|
|||||||
import { duration } from "@/lib/motion"
|
import { duration } from "@/lib/motion"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { portOf } from "./deriveEdges"
|
import { portOf } from "./deriveEdges"
|
||||||
import { useNodeEmits, useNodeStatus } from "./liveStore"
|
import {
|
||||||
|
liveStore,
|
||||||
|
useNodeEmits,
|
||||||
|
useNodeFailure,
|
||||||
|
useNodeStatus,
|
||||||
|
} from "./liveStore"
|
||||||
|
|
||||||
const NODE_ICONS = {
|
const NODE_ICONS = {
|
||||||
python: Code2,
|
python: Code2,
|
||||||
@@ -115,8 +120,11 @@ function PortHandles({
|
|||||||
function FlowNodeComponent({ data, selected }: NodeProps) {
|
function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||||
const { definition, flow, typeLabel, isPlugin, issueText, onShowLogs } =
|
const { definition, flow, typeLabel, isPlugin, issueText, onShowLogs } =
|
||||||
data as FlowNodeData
|
data as FlowNodeData
|
||||||
const live = useNodeStatus(`${flow}.${definition.id}`)
|
const nodeId = `${flow}.${definition.id}`
|
||||||
const emits = useNodeEmits(`${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
|
// The graph runs top to bottom on a phone, so the ports have to face that
|
||||||
// way too — see DESIGN-GUIDELINES.md → Responsive.
|
// way too — see DESIGN-GUIDELINES.md → Responsive.
|
||||||
const vertical = useIsMobile()
|
const vertical = useIsMobile()
|
||||||
@@ -154,6 +162,13 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
const status = problem ? "error" : live?.status
|
const status = problem ? "error" : live?.status
|
||||||
const running = status === "running"
|
const running = status === "running"
|
||||||
const style = STATUS_STYLES[status as keyof typeof STATUS_STYLES]
|
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 (
|
return (
|
||||||
// The pulse ring measures itself from here rather than from the card, so
|
// The pulse ring measures itself from here rather than from the card, so
|
||||||
@@ -172,7 +187,12 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
selected && "border-primary shadow-e2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -220,7 +240,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{status === "error" && onShowLogs ? (
|
{(status === "error" || failedEarlier) && onShowLogs ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -228,18 +248,36 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
// `nodrag` keeps xyflow from reading the press as a drag; the
|
// `nodrag` keeps xyflow from reading the press as a drag; the
|
||||||
// click itself is stopped so the node panel stays closed.
|
// 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"
|
className={cn(
|
||||||
aria-label="Show what this node printed"
|
"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"
|
data-testid="node-traceback"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
onShowLogs(definition.id)
|
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 />
|
<Bug />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Show the traceback</TooltipContent>
|
<TooltipContent>
|
||||||
|
{failedEarlier
|
||||||
|
? `Failed at ${failedAt} — show the traceback`
|
||||||
|
: "Show the traceback"}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { Switch } from "@/components/ui/switch"
|
|||||||
import useCustomToast from "@/hooks/useCustomToast"
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DtypeValue } from "./FlowBoundary"
|
||||||
import { MessageSparkline } from "./MessageSparkline"
|
import { MessageSparkline } from "./MessageSparkline"
|
||||||
import {
|
import {
|
||||||
flowKeys,
|
flowKeys,
|
||||||
@@ -219,6 +220,7 @@ function PortList({
|
|||||||
suggestions,
|
suggestions,
|
||||||
onChange,
|
onChange,
|
||||||
onRenamed,
|
onRenamed,
|
||||||
|
onRemoved,
|
||||||
streamable = false,
|
streamable = false,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
@@ -228,6 +230,8 @@ function PortList({
|
|||||||
suggestions: string[]
|
suggestions: string[]
|
||||||
onChange: (next: MessageSpec[]) => void
|
onChange: (next: MessageSpec[]) => void
|
||||||
onRenamed?: (previous: string, next: string) => 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. */
|
/** Outputs only: a port a node publishes on repeatedly while it runs. */
|
||||||
streamable?: boolean
|
streamable?: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -355,7 +359,10 @@ function PortList({
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
aria-label="Remove port"
|
aria-label="Remove port"
|
||||||
onClick={() => onChange(specs.filter((_, i) => i !== index))}
|
onClick={() => {
|
||||||
|
onRemoved?.(spec)
|
||||||
|
onChange(specs.filter((_, i) => i !== index))
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X />
|
<X />
|
||||||
</Button>
|
</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. */
|
/** What the five fields mean, and the schedule the interval beside them asks for. */
|
||||||
function CronHelp({
|
function CronHelp({
|
||||||
params,
|
params,
|
||||||
|
derivable,
|
||||||
onPick,
|
onPick,
|
||||||
}: {
|
}: {
|
||||||
params: Record<string, unknown>
|
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
|
onPick: (expression: string) => void
|
||||||
}) {
|
}) {
|
||||||
const derived = cronFromInterval(params.interval)
|
const derived = cronFromInterval(params.interval)
|
||||||
@@ -583,7 +596,7 @@ function CronHelp({
|
|||||||
<span className="font-mono">*/5</span> every fifth,{" "}
|
<span className="font-mono">*/5</span> every fifth,{" "}
|
||||||
<span className="font-mono">1-5</span> a range.
|
<span className="font-mono">1-5</span> a range.
|
||||||
</p>
|
</p>
|
||||||
{derived && params.cron !== derived ? (
|
{derivable && derived && params.cron !== derived ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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. */
|
/** A small form built from the node type's declared parameters. */
|
||||||
function ParamsForm({
|
function ParamsForm({
|
||||||
type,
|
type,
|
||||||
schema,
|
schema,
|
||||||
params,
|
params,
|
||||||
|
provides,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
type: string | undefined
|
type: string | undefined
|
||||||
schema: Record<string, unknown> | undefined
|
schema: Record<string, unknown> | undefined
|
||||||
params: Record<string, unknown>
|
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
|
onChange: (next: Record<string, unknown>) => void
|
||||||
}) {
|
}) {
|
||||||
const properties = (schema?.properties ?? {}) as Record<
|
const properties = (schema?.properties ?? {}) as Record<
|
||||||
@@ -637,6 +719,19 @@ function ParamsForm({
|
|||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<span className={SECTION}>Settings</span>
|
<span className={SECTION}>Settings</span>
|
||||||
{entries.map(([key, property]) => {
|
{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 value = params[key] ?? property.default ?? ""
|
||||||
const label = property.title ?? key
|
const label = property.title ?? key
|
||||||
|
|
||||||
@@ -718,6 +813,7 @@ function ParamsForm({
|
|||||||
{key === "cron" ? (
|
{key === "cron" ? (
|
||||||
<CronHelp
|
<CronHelp
|
||||||
params={params}
|
params={params}
|
||||||
|
derivable={type === "inject"}
|
||||||
onPick={(expression) => set(key, expression)}
|
onPick={(expression) => set(key, expression)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -927,6 +1023,9 @@ function PanelBody({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [code, setCode] = useState<string | null>(null)
|
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 timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const pending = useRef<string | null>(null)
|
const pending = useRef<string | null>(null)
|
||||||
const save = useRef(onSaveSource)
|
const save = useRef(onSaveSource)
|
||||||
@@ -966,6 +1065,33 @@ function PanelBody({
|
|||||||
if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted)
|
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,
|
// ⌘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.
|
// so the binding is too. Anywhere else on the canvas it publishes the flow.
|
||||||
useShortcuts(
|
useShortcuts(
|
||||||
@@ -1016,16 +1142,24 @@ function PanelBody({
|
|||||||
flow={flow}
|
flow={flow}
|
||||||
emptyHint="Nothing yet. Add a message this node publishes."
|
emptyHint="Nothing yet. Add a message this node publishes."
|
||||||
suggestions={suggestions.provides}
|
suggestions={suggestions.provides}
|
||||||
onChange={(provides) => editNode({ ...node, provides })}
|
onChange={(provides) => {
|
||||||
|
const port = dropped.current
|
||||||
|
dropped.current = null
|
||||||
|
editNode(repoint({ ...node, provides }, port ?? "", null))
|
||||||
|
}}
|
||||||
streamable
|
streamable
|
||||||
// Only the publishing side names a message; an input is as often
|
// Only the publishing side names a message; an input is as often
|
||||||
// re-pointed at a different one as it is renamed.
|
// re-pointed at a different one as it is renamed.
|
||||||
onRenamed={onRenameMessage}
|
onRenamed={renamePort}
|
||||||
|
onRemoved={(spec) => {
|
||||||
|
dropped.current = portName(spec)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<ParamsForm
|
<ParamsForm
|
||||||
type={node.type}
|
type={node.type}
|
||||||
schema={nodeType?.params_schema}
|
schema={nodeType?.params_schema}
|
||||||
params={node.params ?? {}}
|
params={node.params ?? {}}
|
||||||
|
provides={node.provides ?? []}
|
||||||
onChange={(params) => editNode({ ...node, params })}
|
onChange={(params) => editNode({ ...node, params })}
|
||||||
/>
|
/>
|
||||||
{nodeType?.free_params ? (
|
{nodeType?.free_params ? (
|
||||||
|
|||||||
@@ -211,6 +211,15 @@
|
|||||||
outline: none;
|
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
|
* 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 —
|
* 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 {
|
.brain-wire.brain-hot .brain-dot {
|
||||||
transition: none;
|
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 { useCallback, useSyncExternalStore } from "react"
|
||||||
|
|
||||||
|
import { OpenAPI } from "@/client"
|
||||||
|
import { request } from "@/client/core/request"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live engine state, deliberately outside React Query.
|
* Live engine state, deliberately outside React Query.
|
||||||
*
|
*
|
||||||
@@ -29,6 +32,16 @@ export type NodeHealth = {
|
|||||||
health: "ok" | "down" | "unknown"
|
health: "ok" | "down" | "unknown"
|
||||||
detail?: string | null
|
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. */
|
/** Something the engine reported about itself, for the health page. */
|
||||||
export type EngineEvent = {
|
export type EngineEvent = {
|
||||||
type: string
|
type: string
|
||||||
@@ -56,6 +69,7 @@ const ENGINE_EVENT_LIMIT = 100
|
|||||||
|
|
||||||
const values = new Map<string, LiveValue>()
|
const values = new Map<string, LiveValue>()
|
||||||
const statuses = new Map<string, LiveStatus>()
|
const statuses = new Map<string, LiveStatus>()
|
||||||
|
const failures = new Map<string, NodeFailure>()
|
||||||
const health = new Map<string, NodeHealth>()
|
const health = new Map<string, NodeHealth>()
|
||||||
let engineEvents: EngineEvent[] = []
|
let engineEvents: EngineEvent[] = []
|
||||||
// How many times this page has seen a node emit. The number itself means
|
// 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) {
|
setStatus(nodeId: string, status: LiveStatus) {
|
||||||
statuses.set(nodeId, status)
|
statuses.set(nodeId, status)
|
||||||
notify(`status:${nodeId}`)
|
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(
|
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) {
|
for (const entry of entries) {
|
||||||
statuses.set(entry.id, {
|
statuses.set(entry.id, {
|
||||||
@@ -117,11 +148,46 @@ export const liveStore = {
|
|||||||
error: entry.error,
|
error: entry.error,
|
||||||
})
|
})
|
||||||
notify(`status:${entry.id}`)
|
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) {
|
getStatus(nodeId: string) {
|
||||||
return statuses.get(nodeId)
|
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) {
|
setHealth(nodeId: string, entry: NodeHealth) {
|
||||||
health.set(nodeId, entry)
|
health.set(nodeId, entry)
|
||||||
notify(`health:${nodeId}`)
|
notify(`health:${nodeId}`)
|
||||||
@@ -193,6 +259,8 @@ export const liveStore = {
|
|||||||
values.clear()
|
values.clear()
|
||||||
for (const key of statuses.keys()) notify(`status:${key}`)
|
for (const key of statuses.keys()) notify(`status:${key}`)
|
||||||
statuses.clear()
|
statuses.clear()
|
||||||
|
for (const key of failures.keys()) notify(`failure:${key}`)
|
||||||
|
failures.clear()
|
||||||
for (const key of new Set([...emits.keys(), ...priorEmits.keys()]))
|
for (const key of new Set([...emits.keys(), ...priorEmits.keys()]))
|
||||||
notify(`emit:${key}`)
|
notify(`emit:${key}`)
|
||||||
emits.clear()
|
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. */
|
/** How the node's connection is doing, once it has said anything about it. */
|
||||||
export function useNodeHealth(nodeId: string): NodeHealth | undefined {
|
export function useNodeHealth(nodeId: string): NodeHealth | undefined {
|
||||||
return useSyncExternalStore(
|
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)
|
const joined = ids.join(SEP)
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
useCallback(
|
useCallback(
|
||||||
(listener: Listener) =>
|
(listener: Listener) =>
|
||||||
subscribeAll(
|
subscribeAll(
|
||||||
parts(joined).map((id) => `status:${id}`),
|
parts(joined).map((id) => `failure:${id}`),
|
||||||
listener,
|
listener,
|
||||||
),
|
),
|
||||||
[joined],
|
[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 { useEffect } from "react"
|
||||||
|
|
||||||
import { OpenAPI } from "@/client"
|
import { OpenAPI } from "@/client"
|
||||||
|
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
|
||||||
import { connectionStore } from "@/lib/connectionStore"
|
import { connectionStore } from "@/lib/connectionStore"
|
||||||
import { apiToken } from "@/lib/portal"
|
import { apiToken } from "@/lib/portal"
|
||||||
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
|
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
|
||||||
@@ -87,6 +88,7 @@ type FlowEvent =
|
|||||||
nodes: { id: string; status: string; error?: string | null }[]
|
nodes: { id: string; status: string; error?: string | null }[]
|
||||||
paused?: string[]
|
paused?: string[]
|
||||||
}
|
}
|
||||||
|
| { type: "dashboard_changed"; dashboard?: string; ts?: number }
|
||||||
|
|
||||||
function socketUrl(): string {
|
function socketUrl(): string {
|
||||||
const base = String(OpenAPI.BASE || window.location.origin)
|
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.
|
// markers on the flow chips are stale until the list is refetched.
|
||||||
client?.invalidateQueries({ queryKey: flowKeys.all })
|
client?.invalidateQueries({ queryKey: flowKeys.all })
|
||||||
break
|
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 { FlowEditor } from "@/components/Flow/FlowEditor"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
type Search = { node?: string }
|
||||||
|
|
||||||
export const Route = createFileRoute("/_canvas/flows/$flowName")({
|
export const Route = createFileRoute("/_canvas/flows/$flowName")({
|
||||||
component: FlowRoute,
|
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 }) => ({
|
head: ({ params }) => ({
|
||||||
meta: [{ title: `${params.flowName} - Fluksio` }],
|
meta: [{ title: `${params.flowName} - Fluksio` }],
|
||||||
}),
|
}),
|
||||||
@@ -21,10 +29,11 @@ function Loading() {
|
|||||||
|
|
||||||
function FlowRoute() {
|
function FlowRoute() {
|
||||||
const { flowName } = Route.useParams()
|
const { flowName } = Route.useParams()
|
||||||
|
const { node } = Route.useSearch()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loading />}>
|
<Suspense fallback={<Loading />}>
|
||||||
<FlowEditor flowName={flowName} />
|
<FlowEditor flowName={flowName} focus={node} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ import { Switch } from "@/components/ui/switch"
|
|||||||
import useCustomToast from "@/hooks/useCustomToast"
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_layout/")({
|
export const Route = createFileRoute("/_layout/")({
|
||||||
component: Dashboard,
|
component: Home,
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{
|
||||||
title: "Dashboard - Fluksio",
|
title: "Home - Fluksio",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -50,7 +50,7 @@ function FlowRow({ flow }: { flow: FlowSummary }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 border-b border-border px-5 py-3 last:border-b-0"
|
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
|
<Link
|
||||||
to="/flows/$flowName"
|
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
|
* it has been doing. The brain and the health screens compose in here rather
|
||||||
* than living at routes of their own.
|
* 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 health block's window: one choice, read by the tiles, the flow table,
|
||||||
// the charts and the lists under them.
|
// the charts and the lists under them.
|
||||||
const [range, setRange] = useState(DEFAULT_RANGE)
|
const [range, setRange] = useState(DEFAULT_RANGE)
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const PRINTING_NODE = `def process():
|
|||||||
const BROKEN_NODE = `def process(reading):
|
const BROKEN_NODE = `def process(reading):
|
||||||
raise RuntimeError("downstream blew up")
|
raise RuntimeError("downstream blew up")
|
||||||
`
|
`
|
||||||
|
const FIXED_NODE = `def process(reading):
|
||||||
|
print(f"logged {reading}")
|
||||||
|
`
|
||||||
|
|
||||||
test.afterAll(async ({ browser }) => {
|
test.afterAll(async ({ browser }) => {
|
||||||
await deleteAll(browser, [`/flows/${flowName}`])
|
await deleteAll(browser, [`/flows/${flowName}`])
|
||||||
@@ -61,11 +64,9 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
await page.close()
|
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("/")
|
await page.goto("/")
|
||||||
const row = page
|
const row = page.getByTestId("home-flow-row").filter({ hasText: "Runtime" })
|
||||||
.getByTestId("dashboard-flow-row")
|
|
||||||
.filter({ hasText: "Runtime" })
|
|
||||||
await expect(row).toBeVisible()
|
await expect(row).toBeVisible()
|
||||||
await expect(row).toContainText("Running")
|
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()
|
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 }) => {
|
test("a flow can be paused and let go again", async ({ page }) => {
|
||||||
await page.goto(`/flows/${flowName}`)
|
await page.goto(`/flows/${flowName}`)
|
||||||
await page.waitForSelector(".react-flow__node")
|
await page.waitForSelector(".react-flow__node")
|
||||||
|
|||||||
Reference in New Issue
Block a user