Draw every flow as one graph, merged on what it talks to

A node type can now say which outside thing its parameters point at, and
nodes sharing one — a broker topic, a URL, a bucket — are drawn as a single
neuron on a new /brain canvas. That makes the wiring which runs between
flows through a broker visible for the first time; no single flow's canvas
can show it. The key is read off stored parameters, so a credential
reference never reaches an id.

Layout is a d3 force simulation settled once and then frozen, lit by the
socket the editor already listens to: a neuron pulses when any node behind
it publishes, and its connections light as values pass.

Fixes the message pulse while here: interpolating the stroke against the
edge's `color-mix()` resting colour went through oklab and left the gamut,
which turned every pulse on both canvases fluorescent yellow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
This commit is contained in:
2026-08-16 22:59:31 +02:00
co-authored by Claude Fable 5
parent af3ba51571
commit 4bcd38354b
25 changed files with 790 additions and 4 deletions
+2
View File
@@ -75,6 +75,8 @@ is what M4 still waits on, together with porting the flows.
- PERF/UI: `useParamSuggestions` fetches every flow's detail to build the suggestion list. An aggregate endpoint if an installation ever has many flows.
- CHORE/UX: the derived-cron chip also appears on the delay node, where `interval` is a rate limit rather than a schedule. May want it inject-only.
- CHORE/UX: free-form params (python nodes) get no suggestions, since there is no schema to key them off.
- FEAT/UI: the brain graph draws connections as plain lines, so which way a value travels is not shown. An arrowhead needs the path shortened to the target circle's rim, which needs the node radius on the edge.
- CHORE/UI: fixing the yellow message pulse gave `edge-pulse` an explicit `to`, so a *selected* edge now decays to `--muted-foreground` and snaps back to its blue at the end instead of decaying into it. Barely visible, and only while an edge is selected.
### Infrastructure
+6
View File
@@ -179,6 +179,12 @@ React + Vite, primarily desktop but usable on mobile. See `docs/architecture/str
over what it has been doing all day — throughput and failure charts, a
per-flow table, the recent cascades, failures that expand to their
traceback, dead-lettered work and the audit trail
- [x] Brain graph: every published flow on one canvas, with nodes that talk to the
same outside thing — a broker topic, a URL, a bucket — drawn as a single
neuron, so the wiring that runs between flows through a broker is visible at
all. Laid out by a force simulation settled once and then frozen, lit by the
same socket the editor listens to, and read-only: a neuron leads back to the
flow it came from
- [x] Mobile-friendly canvas: touch connect, full-screen node panel
- [ ] Installable as a PWA (`vite-plugin-pwa`)
+9
View File
@@ -29,6 +29,7 @@ from app.flow.messages import qualify
from app.flow.pipeline import ValidationIssue
from app.flow.schemas import (
NAME_PATTERN,
BrainGraph,
FlowDef,
FlowsPublic,
FlowStatePublic,
@@ -264,6 +265,14 @@ def read_node_types() -> Any:
return node_type_info()
# Above "/{name}" for the same reason "library" is: a flow called "graph" would
# otherwise be unreachable.
@router.get("/graph", response_model=BrainGraph)
def read_graph(controller: FlowControllerDep) -> Any:
"""Every flow as one graph, with nodes talking to the same thing merged."""
return controller.brain_graph()
# -----------------------------------------------------------------------------
# Shared nodes
#
+70
View File
@@ -42,6 +42,9 @@ from app.flow.nodes import (
)
from app.flow.pipeline import Pipeline, ValidationIssue, ValueSource
from app.flow.schemas import (
BrainEdge,
BrainGraph,
BrainNode,
FlowDef,
Health,
NodeDef,
@@ -685,6 +688,73 @@ class FlowController:
found.append((other.name, node.id, provides, requires))
return found
def brain_graph(self) -> BrainGraph:
"""Every published flow as one graph, merged on what its nodes talk to.
Two flows on the same broker topic are one neuron here: the topic is a
single thing, and the path that runs through it is real wiring that no
one flow's canvas can show. Read from the store rather than from the
compiled pipeline, so a node that failed to load still appears — a
broken neuron is exactly what someone comes to this view to find.
"""
groups: dict[str, BrainNode] = {}
# Which group each `flow.node_id` ended up in.
gid_of: dict[str, str] = {}
producers: dict[str, list[str]] = {}
# (member, message) for every input that actually wakes a node.
consumers: list[tuple[str, str]] = []
for flow in self.store.read_all():
for node_def in flow.nodes:
member = f"{flow.name}.{node_def.id}"
node_type = NODE_TYPES.get(node_def.type)
key = (
node_type.cls.instance_key(node_def.params)
if node_type is not None
else None
)
gid = f"{node_def.type}:{key}" if key else member
gid_of[member] = gid
group = groups.get(gid)
if group is None:
# The first member names the neuron. What it points at is
# in the id, which can be a whole topic map and is far too
# long to draw on a circle.
group = groups[gid] = BrainNode(
id=gid,
label=node_def.title or node_def.id,
kind=node_def.type,
)
group.members.append(member)
if flow.name not in group.flows:
group.flows.append(flow.name)
for spec in _bound(node_def.provides):
producers.setdefault(qualify(flow.name, spec.name), []).append(
member
)
for spec in _bound(node_def.requires):
if spec.trigger:
consumers.append((member, qualify(flow.name, spec.name)))
messages: dict[tuple[str, str], set[str]] = {}
for member, message in consumers:
target = gid_of[member]
for producer in producers.get(message, ()):
source = gid_of[producer]
# Everything a merged neuron says to itself is inside it.
if source != target:
messages.setdefault((source, target), set()).add(message)
return BrainGraph(
nodes=sorted(groups.values(), key=lambda node: node.id),
edges=[
BrainEdge(source=source, target=target, messages=sorted(names))
for (source, target), names in sorted(messages.items())
],
)
def publish_message(
self, name: str, value: Any, source: ValueSource | None = None
) -> None:
+15
View File
@@ -166,6 +166,21 @@ class Node:
# to the device, which at-least-once delivery must not cause.
idempotent: bool = True
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
"""Which outside thing these parameters point at, if any.
Two nodes with the same key talk to the same broker topic, URL or
bucket, however many flows they sit in — which is what the brain graph
draws as one neuron. ``None``, the default, means a node of this type
is only ever itself.
Called with the *stored* parameters, so a credential is still an
unresolved ``{"$secret": ...}`` reference. No key may be built from one:
an id is not a place for a password.
"""
return None
async def start(self, app: FastAPI | None = None) -> None:
"""Begin whatever this node listens to. Called when its flow starts."""
+8
View File
@@ -150,6 +150,14 @@ class HttpNode(Node):
json_schema_extra={"x-secret": True},
)
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
# ponytail: a webhook's stored url is the path before the flow name is
# prefixed onto it, so two flows both receiving on "/tick" merge into
# one neuron. Key on mode as well if that ever misleads.
url = params.get("url")
return str(url) if url else None
def __init__(
self,
url: str | None = None,
+6
View File
@@ -157,6 +157,12 @@ class InfluxDbNode(Node):
writes: dict[str, dict[str, Any]] = {}
queries: dict[str, dict[str, Any]] = {}
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
"""The bucket, which is the thing several flows share."""
url, bucket = params.get("url"), params.get("bucket")
return f"{url}/{bucket}" if url and bucket else None
def __init__(
self,
requires: MessageSpec | Iterable[MessageSpec] = (),
+19
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Iterable
from enum import Enum
@@ -149,6 +150,24 @@ class MqttNode(Node):
retain: bool = False
keepalive: int = 60
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
"""The broker and topic, which is one physical thing.
A publisher and a subscriber on the same topic get the same key on
purpose: they are two ends of one wire, and drawing them as one neuron
is the only way the path through the broker shows up at all.
"""
# ponytail: publisher and subscriber merge into one neuron; key on mode
# as well if the two directions ever need telling apart.
fields = cls.Params.model_fields
topic = params.get("topic", fields["topic"].default)
if isinstance(topic, dict):
topic = json.dumps(topic, sort_keys=True)
host = params.get("broker_host", fields["broker_host"].default)
port = params.get("broker_port", fields["broker_port"].default)
return f"{host}:{port}/{topic}"
def __init__(
self,
requires: MessageSpec | Iterable[MessageSpec] = (),
+31
View File
@@ -184,6 +184,37 @@ class ApplyResult(BaseModel):
output: str = ""
class BrainNode(BaseModel):
"""One neuron: a thing the engine talks to, or a node that only computes.
Nodes of the same type pointing at the same outside thing — one broker
topic, one URL, one bucket — are a single entry here, whichever flows they
sit in. ``members`` are the ``flow.node_id`` names behind it, which is also
what the live events are keyed by.
"""
id: str
label: str
kind: str
members: list[str] = Field(default_factory=list)
flows: list[str] = Field(default_factory=list)
class BrainEdge(BaseModel):
"""Messages carrying values from one neuron to another."""
source: str
target: str
messages: list[str] = Field(default_factory=list)
class BrainGraph(BaseModel):
"""Every published flow at once, merged on what its nodes talk to."""
nodes: list[BrainNode] = Field(default_factory=list)
edges: list[BrainEdge] = Field(default_factory=list)
class NodeTypeInfo(BaseModel):
"""A node type the editor can offer, with its parameter schema."""
+10
View File
@@ -136,6 +136,16 @@ async def list_shared_nodes() -> Any:
return await _call("GET", "/flows/library")
@mcp.tool()
async def get_graph() -> Any:
"""Every flow as one graph, with nodes talking to the same thing merged.
Nodes on the same broker topic, URL or bucket come back as a single entry,
which is how wiring that runs between flows shows up.
"""
return await _call("GET", "/flows/graph")
# -----------------------------------------------------------------------------
# Building
# -----------------------------------------------------------------------------
+82
View File
@@ -0,0 +1,82 @@
"""Nodes talking to the same outside thing are one neuron, across flows.
That merge is the whole point of the brain graph: a broker topic is a single
physical thing, and two flows sharing one are wired together through it even
though neither canvas can draw the other end.
"""
from pathlib import Path
import pytest
from app.flow.controller import FlowController
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import MqttNode
from app.flow.schemas import FlowDef, NodeDef
from app.flow.store import FlowStore
BROKER = {"broker_host": "mosquitto", "broker_port": 1883, "topic": "sensors/temp"}
def subscriber(node_id: str) -> NodeDef:
return NodeDef(
id=node_id,
type="mqtt",
params=dict(BROKER),
provides=[MessageSpec(name="temp", dtype=DType.FLOAT)],
)
@pytest.fixture
def controller(tmp_path: Path) -> FlowController:
store = FlowStore(tmp_path / "flows")
store.write_flow(
FlowDef(
name="house",
nodes=[
subscriber("sensor"),
NodeDef(
id="scale",
type="change",
requires=[MessageSpec(name="temp", dtype=DType.FLOAT)],
provides=[MessageSpec(name="scaled", dtype=DType.FLOAT)],
),
],
)
)
store.write_flow(FlowDef(name="shed", nodes=[subscriber("sensor")]))
return FlowController(store)
def test_two_flows_on_one_topic_are_one_neuron(controller: FlowController):
graph = controller.brain_graph()
merged = [node for node in graph.nodes if node.kind == "mqtt"]
assert len(merged) == 1
assert merged[0].id == "mqtt:mosquitto:1883/sensors/temp"
assert merged[0].members == ["house.sensor", "shed.sensor"]
assert merged[0].flows == ["house", "shed"]
def test_a_node_with_no_outside_thing_stays_its_own(controller: FlowController):
graph = controller.brain_graph()
scale = next(node for node in graph.nodes if node.kind == "change")
assert scale.id == "house.scale"
assert scale.members == ["house.scale"]
def test_an_edge_carries_the_qualified_message(controller: FlowController):
graph = controller.brain_graph()
assert [(edge.source, edge.target, edge.messages) for edge in graph.edges] == [
("mqtt:mosquitto:1883/sensors/temp", "house.scale", ["house.temp"])
]
def test_a_credential_never_reaches_the_key():
# Stored params, so a secret is still a reference. Neither its name nor its
# value belongs in something the browser gets to see.
key = MqttNode.instance_key({**BROKER, "password": {"$secret": "broker_pw"}})
assert key == "mosquitto:1883/sensors/temp"
+8
View File
@@ -36,6 +36,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"form-data": "4.0.5",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.56.0",
@@ -60,6 +61,7 @@
"@playwright/test": "1.58.0",
"@tanstack/router-devtools": "^1.142.11",
"@tanstack/router-plugin": "^1.140.0",
"@types/d3-force": "^3.0.10",
"@types/node": "^25.0.10",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -482,6 +484,8 @@
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
@@ -582,8 +586,12 @@
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
+2
View File
@@ -40,6 +40,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"form-data": "4.0.5",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.56.0",
@@ -64,6 +65,7 @@
"@playwright/test": "1.58.0",
"@tanstack/router-devtools": "^1.142.11",
"@tanstack/router-plugin": "^1.140.0",
"@types/d3-force": "^3.0.10",
"@types/node": "^25.0.10",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
+86
View File
@@ -192,6 +192,92 @@ export const Body_oauth_tokenSchema = {
title: 'Body_oauth-token'
} as const;
export const BrainEdgeSchema = {
properties: {
source: {
type: 'string',
title: 'Source'
},
target: {
type: 'string',
title: 'Target'
},
messages: {
items: {
type: 'string'
},
type: 'array',
title: 'Messages'
}
},
type: 'object',
required: ['source', 'target'],
title: 'BrainEdge',
description: 'Messages carrying values from one neuron to another.'
} as const;
export const BrainGraphSchema = {
properties: {
nodes: {
items: {
'$ref': '#/components/schemas/BrainNode'
},
type: 'array',
title: 'Nodes'
},
edges: {
items: {
'$ref': '#/components/schemas/BrainEdge'
},
type: 'array',
title: 'Edges'
}
},
type: 'object',
title: 'BrainGraph',
description: 'Every published flow at once, merged on what its nodes talk to.'
} as const;
export const BrainNodeSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
label: {
type: 'string',
title: 'Label'
},
kind: {
type: 'string',
title: 'Kind'
},
members: {
items: {
type: 'string'
},
type: 'array',
title: 'Members'
},
flows: {
items: {
type: 'string'
},
type: 'array',
title: 'Flows'
}
},
type: 'object',
required: ['id', 'label', 'kind'],
title: 'BrainNode',
description: `One neuron: a thing the engine talks to, or a node that only computes.
Nodes of the same type pointing at the same outside thing — one broker
topic, one URL, one bucket — are a single entry here, whichever flows they
sit in. \`\`members\`\` are the \`\`flow.node_id\`\` names behind it, which is also
what the live events are keyed by.`
} as const;
export const ChannelSchema = {
properties: {
name: {
+14 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, 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, PrivateCreateUserData, PrivateCreateUserResponse, 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 } from './types.gen';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, 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, PrivateCreateUserData, PrivateCreateUserResponse, 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 } from './types.gen';
export class AlertsService {
/**
@@ -211,6 +211,19 @@ export class FlowsService {
});
}
/**
* Read Graph
* Every flow as one graph, with nodes talking to the same thing merged.
* @returns BrainGraph Successful Response
* @throws ApiError
*/
public static readGraph(): CancelablePromise<FlowsReadGraphResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/flows/graph'
});
}
/**
* Read Library
* The node sources shared across flows, and which nodes use each.
+35
View File
@@ -74,6 +74,39 @@ export type Body_oauth_token = {
resource?: (string | null);
};
/**
* Messages carrying values from one neuron to another.
*/
export type BrainEdge = {
source: string;
target: string;
messages?: Array<(string)>;
};
/**
* Every published flow at once, merged on what its nodes talk to.
*/
export type BrainGraph = {
nodes?: Array<BrainNode>;
edges?: Array<BrainEdge>;
};
/**
* One neuron: a thing the engine talks to, or a node that only computes.
*
* Nodes of the same type pointing at the same outside thing — one broker
* topic, one URL, one bucket — are a single entry here, whichever flows they
* sit in. ``members`` are the ``flow.node_id`` names behind it, which is also
* what the live events are keyed by.
*/
export type BrainNode = {
id: string;
label: string;
kind: string;
members?: Array<(string)>;
flows?: Array<(string)>;
};
/**
* Somewhere to send an alert.
*/
@@ -787,6 +820,8 @@ export type FlowsReadFlowsResponse = (FlowsPublic);
export type FlowsReadNodeTypesResponse = (Array<NodeTypeInfo>);
export type FlowsReadGraphResponse = (BrainGraph);
export type FlowsReadLibraryResponse = (Array<LibraryNode>);
export type FlowsDeleteSharedNodeData = {
@@ -0,0 +1,47 @@
import { BaseEdge, type EdgeProps, getStraightPath } from "@xyflow/react"
import { memo, useEffect, useRef, useState } from "react"
import { duration } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { useLatestTs } from "./liveStore"
export type BrainEdgeData = {
/** Every message this connection carries, qualified. */
messages: string[]
[key: string]: unknown
}
/**
* A connection between neurons, lit by whatever passes along it.
*
* `LiveEdge` without the value chip or the producer check: a neuron can stand
* for several nodes, so "did *this* node publish it" has no answer here, and a
* circle the size of a coin has no room for a payload anyway.
*/
function BrainEdgeComponent({
id,
sourceX,
sourceY,
targetX,
targetY,
data,
}: EdgeProps) {
const { messages } = (data ?? { messages: [] }) as BrainEdgeData
const ts = useLatestTs(messages)
const [pulsing, setPulsing] = useState(false)
const lastTs = useRef(0)
const [path] = getStraightPath({ sourceX, sourceY, targetX, targetY })
useEffect(() => {
if (!ts || ts === lastTs.current) return
lastTs.current = ts
setPulsing(true)
const timer = setTimeout(() => setPulsing(false), duration.pulse * 1000)
return () => clearTimeout(timer)
}, [ts])
return <BaseEdge id={id} path={path} className={cn(pulsing && "edge-live")} />
}
export const BrainEdge = memo(BrainEdgeComponent)
@@ -0,0 +1,65 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"
import { memo } from "react"
import { cn } from "@/lib/utils"
import { useGroupEmits, useGroupError } from "./liveStore"
export type BrainNodeData = {
label: string
kind: string
/** The `flow.node_id` names behind this neuron; live events key by these. */
members: string[]
flows: string[]
/** Circle diameter in pixels, from how many neurons this one is wired to. */
size: number
[key: string]: unknown
}
function BrainNodeComponent({ data }: NodeProps) {
const { label, kind, members, flows, size } = data as BrainNodeData
const emits = useGroupEmits(members)
const failed = useGroupError(members)
return (
<div
className="flex flex-col items-center"
title={`${kind} · ${members.join(", ")}`}
>
<span
className={cn(
"brain-cell relative flex items-center justify-center rounded-full border bg-card shadow-e1",
failed ? "border-destructive" : "border-border",
)}
style={{ width: size, height: size }}
>
{/* Remounting on each emit is what restarts the animation. */}
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
{/* Both ends sit at the centre, so an edge runs neuron to neuron. */}
<Handle
type="target"
position={Position.Left}
className="brain-handle"
isConnectable={false}
/>
<Handle
type="source"
position={Position.Right}
className="brain-handle"
isConnectable={false}
/>
<span className="px-1 text-center text-xs font-medium text-muted-foreground">
{flows.length > 1 ? flows.length : null}
</span>
</span>
<span className="mt-1.5 max-w-[140px] truncate text-center text-xs font-medium">
{label}
</span>
{/* Colour is never the only carrier of a status. */}
{failed ? (
<span className="text-xs font-medium text-destructive">failed</span>
) : null}
</div>
)
}
export const BrainNode = memo(BrainNodeComponent)
+206
View File
@@ -0,0 +1,206 @@
import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import {
Background,
BackgroundVariant,
type Edge,
type Node,
ReactFlow,
ReactFlowProvider,
useReactFlow,
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import {
forceCollide,
forceLink,
forceManyBody,
forceSimulation,
forceX,
forceY,
type SimulationNodeDatum,
} from "d3-force"
import { Brain } from "lucide-react"
import { useEffect, useMemo } from "react"
import type { BrainGraph } from "@/client"
import { BrainEdge } from "./BrainEdge"
import { BrainNode, type BrainNodeData } from "./BrainNode"
import { CanvasTitle } from "./CanvasTitle"
import "./flow.css"
import { graphQueryOptions } from "./queries"
import { useFlowSocket } from "./useFlowSocket"
// Never past 1: the labels are `text-xs`, and a graph small enough to fit
// twice over should not render its text at twice the size of the sidebar.
const FIT = { padding: 0.2, maxZoom: 1 }
const nodeTypes = { brain: BrainNode }
const edgeTypes = { brain: BrainEdge }
/** Circle diameter, from how many neurons this one is wired to. */
const SIZE_MIN = 32
const SIZE_MAX = 96
/** Room around a circle for its label, so the layout does not overlap them. */
const LABEL_ROOM = 26
type Placed = SimulationNodeDatum & { id: string; size: number; room: number }
/**
* Where the neurons sit: a force layout run to rest once and then frozen.
*
* A live simulation would keep nudging nodes under the pointer while someone is
* panning, and a graph that never stops moving is unreadable. Starting from a
* circle rather than d3's own random phyllotaxis also means the same flows lay
* out the same way twice.
*
* `forceX`/`forceY` rather than `forceCenter`: centering only translates the
* whole thing, so unconnected flows — which is most of them — would push each
* other apart forever with nothing pulling back.
*/
function build(graph: BrainGraph): { nodes: Node[]; edges: Edge[] } {
const degree = new Map<string, number>()
for (const edge of graph.edges ?? []) {
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1)
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1)
}
const placed: Placed[] = (graph.nodes ?? []).map((node, index, all) => {
const size = Math.min(SIZE_MAX, SIZE_MIN + 8 * (degree.get(node.id) ?? 0))
const angle = (index / all.length) * 2 * Math.PI
const radius = 60 + all.length * 10
return {
id: node.id,
size,
room: size / 2 + LABEL_ROOM,
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
}
})
const links = (graph.edges ?? []).map((edge) => ({
source: edge.source,
target: edge.target,
}))
forceSimulation(placed)
.force(
"link",
forceLink(links)
.id((node) => (node as Placed).id)
.distance(130),
)
.force("charge", forceManyBody().strength(-320).distanceMax(500))
.force(
"collide",
forceCollide<Placed>((node) => node.room),
)
.force("x", forceX(0).strength(0.06))
.force("y", forceY(0).strength(0.06))
.stop()
.tick(300)
return {
nodes: placed.map((node, index) => {
const source = (graph.nodes ?? [])[index]
return {
id: node.id,
type: "brain",
// React Flow places by the top-left corner; the layout means centres.
position: {
x: (node.x ?? 0) - node.size / 2,
y: (node.y ?? 0) - node.size / 2,
},
data: {
label: source.label,
kind: source.kind,
members: source.members ?? [],
flows: source.flows ?? [],
size: node.size,
} satisfies BrainNodeData,
}
}),
edges: (graph.edges ?? []).map((edge) => ({
id: `${edge.source}->${edge.target}`,
type: "brain",
source: edge.source,
target: edge.target,
data: { messages: edge.messages ?? [] },
})),
}
}
function BrainCanvas() {
const navigate = useNavigate()
const { fitView } = useReactFlow()
useFlowSocket()
// The socket invalidates every "flows" key on a rebuild, so publishing
// anywhere re-fetches this and the layout runs again.
const { data } = useQuery(graphQueryOptions())
const { nodes, edges } = useMemo(
() => build(data ?? { nodes: [], edges: [] }),
[data],
)
// A rebuild lays the whole graph out afresh, so the viewport someone was
// looking through no longer frames anything. Only when the set of neurons
// actually changed: a value arriving must not move the canvas.
const shape = nodes.map((node) => node.id).join(" ")
useEffect(() => {
if (!shape) return
// After the new nodes have been measured, or the fit is of the old ones.
const frame = requestAnimationFrame(() =>
fitView({ ...FIT, duration: 300 }),
)
return () => cancelAnimationFrame(frame)
}, [shape, fitView])
return (
<>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
fitView
fitViewOptions={FIT}
minZoom={0.1}
maxZoom={2}
onNodeClick={(_event, node) => {
const [flow] = (node.data as BrainNodeData).flows
if (flow)
navigate({ to: "/flows/$flowName", params: { flowName: flow } })
}}
className="h-full w-full"
>
<Background variant={BackgroundVariant.Dots} gap={24} size={1.5} />
</ReactFlow>
<div className="pointer-events-none absolute inset-0">
<CanvasTitle>
<span className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium">
<Brain className="size-4 text-muted-foreground" />
Brain
</span>
</CanvasTitle>
</div>
</>
)
}
/**
* Every flow at once, merged on what each node talks to.
*
* Read-only by design: what a neuron stands for lives in the flow it came
* from, and clicking one goes there.
*/
export function BrainView() {
return (
<ReactFlowProvider>
<BrainCanvas />
</ReactFlowProvider>
)
}
+29 -3
View File
@@ -38,15 +38,21 @@
}
/*
* No `to` on purpose: the implied one is the edge's own resting style, so
* the decay lands exactly where it started rather than on a second colour
* that has to snap back — and a selected edge decays to its own blue.
* Both ends are plain colours. The implied `to` would be the edge's resting
* stroke, which is a `color-mix()` — and interpolating a hex against one of
* those goes through oklab and leaves the gamut on the way, which turned
* every pulse fluorescent yellow. Landing on `--muted-foreground` instead is
* the same hue the edge rests in, so the last step back is invisible.
*/
@keyframes edge-pulse {
from {
stroke: var(--primary);
stroke-width: 2.5;
}
to {
stroke: var(--muted-foreground);
stroke-width: var(--xy-edge-stroke-width);
}
}
}
@@ -94,3 +100,23 @@
.react-flow__node:focus {
outline: none;
}
/*
* Brain graph. Both ends of a connection sit at the neuron's centre, so an
* edge runs straight from circle to circle and disappears under them.
*/
.brain-handle {
opacity: 0;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.brain-cell {
cursor: pointer;
transition: border-color var(--duration-base) var(--ease-standard);
}
.brain-cell:hover {
border-color: var(--primary);
}
Binary file not shown.
+9
View File
@@ -23,6 +23,9 @@ export const flowKeys = {
["flows", name, "history", message] as const,
nodeTypes: ["flows", "node-types"] as const,
library: ["flows", "library"] as const,
// Under `all` on purpose: the socket invalidates that prefix on every
// rebuild, so the brain graph re-layouts when someone publishes.
graph: ["flows", "graph"] as const,
}
export const flowsQueryOptions = () => ({
@@ -41,6 +44,12 @@ export const secretsQueryOptions = () => ({
queryFn: () => SecretsService.readSecrets(),
})
/** Every flow at once, merged on what its nodes talk to. */
export const graphQueryOptions = () => ({
queryKey: flowKeys.graph,
queryFn: () => FlowsService.readGraph(),
})
/** Node sources shared across flows, with the nodes using each. */
export const libraryQueryOptions = () => ({
queryKey: flowKeys.library,
@@ -1,6 +1,7 @@
import {
Activity,
Bell,
Brain,
Home,
KeyRound,
LayoutDashboard,
@@ -26,6 +27,7 @@ const baseItems: Item[] = [
// "Home" rather than "Dashboard": dashboards are their own thing now.
{ icon: Home, title: "Home", path: "/" },
{ icon: Workflow, title: "Flows", path: "/flows" },
{ icon: Brain, title: "Brain", path: "/brain" },
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
{ icon: Activity, title: "Health", path: "/health" },
// Both are engine-wide operator settings rather than personal ones, so they
+21
View File
@@ -24,6 +24,7 @@ import { Route as LayoutModulesRouteImport } from './routes/_layout/modules'
import { Route as LayoutHealthRouteImport } from './routes/_layout/health'
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
import { Route as CanvasBrainRouteImport } from './routes/_canvas/brain'
import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
@@ -102,6 +103,11 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
path: '/admin',
getParentRoute: () => LayoutRoute,
} as any)
const CanvasBrainRoute = CanvasBrainRouteImport.update({
id: '/brain',
path: '/brain',
getParentRoute: () => CanvasRoute,
} as any)
const LayoutFlowsIndexRoute = LayoutFlowsIndexRouteImport.update({
id: '/flows/',
path: '/flows/',
@@ -129,6 +135,7 @@ export interface FileRoutesByFullPath {
'/recover-password': typeof RecoverPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/brain': typeof CanvasBrainRoute
'/admin': typeof LayoutAdminRoute
'/alerts': typeof LayoutAlertsRoute
'/health': typeof LayoutHealthRoute
@@ -148,6 +155,7 @@ export interface FileRoutesByTo {
'/recover-password': typeof RecoverPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/brain': typeof CanvasBrainRoute
'/admin': typeof LayoutAdminRoute
'/alerts': typeof LayoutAlertsRoute
'/health': typeof LayoutHealthRoute
@@ -169,6 +177,7 @@ export interface FileRoutesById {
'/recover-password': typeof RecoverPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/_canvas/brain': typeof CanvasBrainRoute
'/_layout/admin': typeof LayoutAdminRoute
'/_layout/alerts': typeof LayoutAlertsRoute
'/_layout/health': typeof LayoutHealthRoute
@@ -191,6 +200,7 @@ export interface FileRouteTypes {
| '/recover-password'
| '/reset-password'
| '/signup'
| '/brain'
| '/admin'
| '/alerts'
| '/health'
@@ -210,6 +220,7 @@ export interface FileRouteTypes {
| '/recover-password'
| '/reset-password'
| '/signup'
| '/brain'
| '/admin'
| '/alerts'
| '/health'
@@ -230,6 +241,7 @@ export interface FileRouteTypes {
| '/recover-password'
| '/reset-password'
| '/signup'
| '/_canvas/brain'
| '/_layout/admin'
| '/_layout/alerts'
| '/_layout/health'
@@ -363,6 +375,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutAdminRouteImport
parentRoute: typeof LayoutRoute
}
'/_canvas/brain': {
id: '/_canvas/brain'
path: '/brain'
fullPath: '/brain'
preLoaderRoute: typeof CanvasBrainRouteImport
parentRoute: typeof CanvasRoute
}
'/_layout/flows/': {
id: '/_layout/flows/'
path: '/flows'
@@ -395,11 +414,13 @@ declare module '@tanstack/react-router' {
}
interface CanvasRouteChildren {
CanvasBrainRoute: typeof CanvasBrainRoute
CanvasDashboardsNameRoute: typeof CanvasDashboardsNameRoute
CanvasFlowsFlowNameRoute: typeof CanvasFlowsFlowNameRoute
}
const CanvasRouteChildren: CanvasRouteChildren = {
CanvasBrainRoute: CanvasBrainRoute,
CanvasDashboardsNameRoute: CanvasDashboardsNameRoute,
CanvasFlowsFlowNameRoute: CanvasFlowsFlowNameRoute,
}
+8
View File
@@ -0,0 +1,8 @@
import { createFileRoute } from "@tanstack/react-router"
import { BrainView } from "@/components/Flow/BrainView"
export const Route = createFileRoute("/_canvas/brain")({
component: BrainView,
head: () => ({ meta: [{ title: "Brain - Fluksio" }] }),
})