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:
@@ -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
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = (),
|
||||
|
||||
@@ -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] = (),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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" }] }),
|
||||
})
|
||||
Reference in New Issue
Block a user