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.
|
- 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: 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.
|
- 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
|
### 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
|
over what it has been doing all day — throughput and failure charts, a
|
||||||
per-flow table, the recent cascades, failures that expand to their
|
per-flow table, the recent cascades, failures that expand to their
|
||||||
traceback, dead-lettered work and the audit trail
|
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
|
- [x] Mobile-friendly canvas: touch connect, full-screen node panel
|
||||||
- [ ] Installable as a PWA (`vite-plugin-pwa`)
|
- [ ] 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.pipeline import ValidationIssue
|
||||||
from app.flow.schemas import (
|
from app.flow.schemas import (
|
||||||
NAME_PATTERN,
|
NAME_PATTERN,
|
||||||
|
BrainGraph,
|
||||||
FlowDef,
|
FlowDef,
|
||||||
FlowsPublic,
|
FlowsPublic,
|
||||||
FlowStatePublic,
|
FlowStatePublic,
|
||||||
@@ -264,6 +265,14 @@ def read_node_types() -> Any:
|
|||||||
return node_type_info()
|
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
|
# Shared nodes
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ from app.flow.nodes import (
|
|||||||
)
|
)
|
||||||
from app.flow.pipeline import Pipeline, ValidationIssue, ValueSource
|
from app.flow.pipeline import Pipeline, ValidationIssue, ValueSource
|
||||||
from app.flow.schemas import (
|
from app.flow.schemas import (
|
||||||
|
BrainEdge,
|
||||||
|
BrainGraph,
|
||||||
|
BrainNode,
|
||||||
FlowDef,
|
FlowDef,
|
||||||
Health,
|
Health,
|
||||||
NodeDef,
|
NodeDef,
|
||||||
@@ -685,6 +688,73 @@ class FlowController:
|
|||||||
found.append((other.name, node.id, provides, requires))
|
found.append((other.name, node.id, provides, requires))
|
||||||
return found
|
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(
|
def publish_message(
|
||||||
self, name: str, value: Any, source: ValueSource | None = None
|
self, name: str, value: Any, source: ValueSource | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -166,6 +166,21 @@ class Node:
|
|||||||
# to the device, which at-least-once delivery must not cause.
|
# to the device, which at-least-once delivery must not cause.
|
||||||
idempotent: bool = True
|
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:
|
async def start(self, app: FastAPI | None = None) -> None:
|
||||||
"""Begin whatever this node listens to. Called when its flow starts."""
|
"""Begin whatever this node listens to. Called when its flow starts."""
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ class HttpNode(Node):
|
|||||||
json_schema_extra={"x-secret": True},
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
url: str | None = None,
|
url: str | None = None,
|
||||||
|
|||||||
@@ -157,6 +157,12 @@ class InfluxDbNode(Node):
|
|||||||
writes: dict[str, dict[str, Any]] = {}
|
writes: dict[str, dict[str, Any]] = {}
|
||||||
queries: 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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
requires: MessageSpec | Iterable[MessageSpec] = (),
|
requires: MessageSpec | Iterable[MessageSpec] = (),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -149,6 +150,24 @@ class MqttNode(Node):
|
|||||||
retain: bool = False
|
retain: bool = False
|
||||||
keepalive: int = 60
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
requires: MessageSpec | Iterable[MessageSpec] = (),
|
requires: MessageSpec | Iterable[MessageSpec] = (),
|
||||||
|
|||||||
@@ -184,6 +184,37 @@ class ApplyResult(BaseModel):
|
|||||||
output: str = ""
|
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):
|
class NodeTypeInfo(BaseModel):
|
||||||
"""A node type the editor can offer, with its parameter schema."""
|
"""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")
|
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
|
# 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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"form-data": "4.0.5",
|
"form-data": "4.0.5",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"monaco-editor": "^0.56.0",
|
"monaco-editor": "^0.56.0",
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
"@playwright/test": "1.58.0",
|
"@playwright/test": "1.58.0",
|
||||||
"@tanstack/router-devtools": "^1.142.11",
|
"@tanstack/router-devtools": "^1.142.11",
|
||||||
"@tanstack/router-plugin": "^1.140.0",
|
"@tanstack/router-plugin": "^1.140.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@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-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-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=="],
|
"@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-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-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-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
|
||||||
|
|
||||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"form-data": "4.0.5",
|
"form-data": "4.0.5",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"monaco-editor": "^0.56.0",
|
"monaco-editor": "^0.56.0",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"@playwright/test": "1.58.0",
|
"@playwright/test": "1.58.0",
|
||||||
"@tanstack/router-devtools": "^1.142.11",
|
"@tanstack/router-devtools": "^1.142.11",
|
||||||
"@tanstack/router-plugin": "^1.140.0",
|
"@tanstack/router-plugin": "^1.140.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -192,6 +192,92 @@ export const Body_oauth_tokenSchema = {
|
|||||||
title: 'Body_oauth-token'
|
title: 'Body_oauth-token'
|
||||||
} as const;
|
} 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 = {
|
export const ChannelSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -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, 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 {
|
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
|
* Read Library
|
||||||
* The node sources shared across flows, and which nodes use each.
|
* The node sources shared across flows, and which nodes use each.
|
||||||
|
|||||||
@@ -74,6 +74,39 @@ export type Body_oauth_token = {
|
|||||||
resource?: (string | null);
|
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.
|
* Somewhere to send an alert.
|
||||||
*/
|
*/
|
||||||
@@ -787,6 +820,8 @@ export type FlowsReadFlowsResponse = (FlowsPublic);
|
|||||||
|
|
||||||
export type FlowsReadNodeTypesResponse = (Array<NodeTypeInfo>);
|
export type FlowsReadNodeTypesResponse = (Array<NodeTypeInfo>);
|
||||||
|
|
||||||
|
export type FlowsReadGraphResponse = (BrainGraph);
|
||||||
|
|
||||||
export type FlowsReadLibraryResponse = (Array<LibraryNode>);
|
export type FlowsReadLibraryResponse = (Array<LibraryNode>);
|
||||||
|
|
||||||
export type FlowsDeleteSharedNodeData = {
|
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
|
* Both ends are plain colours. The implied `to` would be the edge's resting
|
||||||
* the decay lands exactly where it started rather than on a second colour
|
* stroke, which is a `color-mix()` — and interpolating a hex against one of
|
||||||
* that has to snap back — and a selected edge decays to its own blue.
|
* 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 {
|
@keyframes edge-pulse {
|
||||||
from {
|
from {
|
||||||
stroke: var(--primary);
|
stroke: var(--primary);
|
||||||
stroke-width: 2.5;
|
stroke-width: 2.5;
|
||||||
}
|
}
|
||||||
|
to {
|
||||||
|
stroke: var(--muted-foreground);
|
||||||
|
stroke-width: var(--xy-edge-stroke-width);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,3 +100,23 @@
|
|||||||
.react-flow__node:focus {
|
.react-flow__node:focus {
|
||||||
outline: none;
|
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,
|
["flows", name, "history", message] as const,
|
||||||
nodeTypes: ["flows", "node-types"] as const,
|
nodeTypes: ["flows", "node-types"] as const,
|
||||||
library: ["flows", "library"] 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 = () => ({
|
export const flowsQueryOptions = () => ({
|
||||||
@@ -41,6 +44,12 @@ export const secretsQueryOptions = () => ({
|
|||||||
queryFn: () => SecretsService.readSecrets(),
|
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. */
|
/** Node sources shared across flows, with the nodes using each. */
|
||||||
export const libraryQueryOptions = () => ({
|
export const libraryQueryOptions = () => ({
|
||||||
queryKey: flowKeys.library,
|
queryKey: flowKeys.library,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bell,
|
Bell,
|
||||||
|
Brain,
|
||||||
Home,
|
Home,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -26,6 +27,7 @@ const baseItems: Item[] = [
|
|||||||
// "Home" rather than "Dashboard": dashboards are their own thing now.
|
// "Home" rather than "Dashboard": dashboards are their own thing now.
|
||||||
{ icon: Home, title: "Home", path: "/" },
|
{ icon: Home, title: "Home", path: "/" },
|
||||||
{ icon: Workflow, title: "Flows", path: "/flows" },
|
{ icon: Workflow, title: "Flows", path: "/flows" },
|
||||||
|
{ icon: Brain, title: "Brain", path: "/brain" },
|
||||||
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
|
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
|
||||||
{ icon: Activity, title: "Health", path: "/health" },
|
{ icon: Activity, title: "Health", path: "/health" },
|
||||||
// Both are engine-wide operator settings rather than personal ones, so they
|
// 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 LayoutHealthRouteImport } from './routes/_layout/health'
|
||||||
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
|
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
|
||||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
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 LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
|
||||||
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
|
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
|
||||||
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
||||||
@@ -102,6 +103,11 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
|||||||
path: '/admin',
|
path: '/admin',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const CanvasBrainRoute = CanvasBrainRouteImport.update({
|
||||||
|
id: '/brain',
|
||||||
|
path: '/brain',
|
||||||
|
getParentRoute: () => CanvasRoute,
|
||||||
|
} as any)
|
||||||
const LayoutFlowsIndexRoute = LayoutFlowsIndexRouteImport.update({
|
const LayoutFlowsIndexRoute = LayoutFlowsIndexRouteImport.update({
|
||||||
id: '/flows/',
|
id: '/flows/',
|
||||||
path: '/flows/',
|
path: '/flows/',
|
||||||
@@ -129,6 +135,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/recover-password': typeof RecoverPasswordRoute
|
'/recover-password': typeof RecoverPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
|
'/brain': typeof CanvasBrainRoute
|
||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
'/alerts': typeof LayoutAlertsRoute
|
'/alerts': typeof LayoutAlertsRoute
|
||||||
'/health': typeof LayoutHealthRoute
|
'/health': typeof LayoutHealthRoute
|
||||||
@@ -148,6 +155,7 @@ export interface FileRoutesByTo {
|
|||||||
'/recover-password': typeof RecoverPasswordRoute
|
'/recover-password': typeof RecoverPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
|
'/brain': typeof CanvasBrainRoute
|
||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
'/alerts': typeof LayoutAlertsRoute
|
'/alerts': typeof LayoutAlertsRoute
|
||||||
'/health': typeof LayoutHealthRoute
|
'/health': typeof LayoutHealthRoute
|
||||||
@@ -169,6 +177,7 @@ export interface FileRoutesById {
|
|||||||
'/recover-password': typeof RecoverPasswordRoute
|
'/recover-password': typeof RecoverPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
|
'/_canvas/brain': typeof CanvasBrainRoute
|
||||||
'/_layout/admin': typeof LayoutAdminRoute
|
'/_layout/admin': typeof LayoutAdminRoute
|
||||||
'/_layout/alerts': typeof LayoutAlertsRoute
|
'/_layout/alerts': typeof LayoutAlertsRoute
|
||||||
'/_layout/health': typeof LayoutHealthRoute
|
'/_layout/health': typeof LayoutHealthRoute
|
||||||
@@ -191,6 +200,7 @@ export interface FileRouteTypes {
|
|||||||
| '/recover-password'
|
| '/recover-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
|
| '/brain'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/alerts'
|
| '/alerts'
|
||||||
| '/health'
|
| '/health'
|
||||||
@@ -210,6 +220,7 @@ export interface FileRouteTypes {
|
|||||||
| '/recover-password'
|
| '/recover-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
|
| '/brain'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/alerts'
|
| '/alerts'
|
||||||
| '/health'
|
| '/health'
|
||||||
@@ -230,6 +241,7 @@ export interface FileRouteTypes {
|
|||||||
| '/recover-password'
|
| '/recover-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
|
| '/_canvas/brain'
|
||||||
| '/_layout/admin'
|
| '/_layout/admin'
|
||||||
| '/_layout/alerts'
|
| '/_layout/alerts'
|
||||||
| '/_layout/health'
|
| '/_layout/health'
|
||||||
@@ -363,6 +375,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutAdminRouteImport
|
preLoaderRoute: typeof LayoutAdminRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
|
'/_canvas/brain': {
|
||||||
|
id: '/_canvas/brain'
|
||||||
|
path: '/brain'
|
||||||
|
fullPath: '/brain'
|
||||||
|
preLoaderRoute: typeof CanvasBrainRouteImport
|
||||||
|
parentRoute: typeof CanvasRoute
|
||||||
|
}
|
||||||
'/_layout/flows/': {
|
'/_layout/flows/': {
|
||||||
id: '/_layout/flows/'
|
id: '/_layout/flows/'
|
||||||
path: '/flows'
|
path: '/flows'
|
||||||
@@ -395,11 +414,13 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CanvasRouteChildren {
|
interface CanvasRouteChildren {
|
||||||
|
CanvasBrainRoute: typeof CanvasBrainRoute
|
||||||
CanvasDashboardsNameRoute: typeof CanvasDashboardsNameRoute
|
CanvasDashboardsNameRoute: typeof CanvasDashboardsNameRoute
|
||||||
CanvasFlowsFlowNameRoute: typeof CanvasFlowsFlowNameRoute
|
CanvasFlowsFlowNameRoute: typeof CanvasFlowsFlowNameRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const CanvasRouteChildren: CanvasRouteChildren = {
|
const CanvasRouteChildren: CanvasRouteChildren = {
|
||||||
|
CanvasBrainRoute: CanvasBrainRoute,
|
||||||
CanvasDashboardsNameRoute: CanvasDashboardsNameRoute,
|
CanvasDashboardsNameRoute: CanvasDashboardsNameRoute,
|
||||||
CanvasFlowsFlowNameRoute: CanvasFlowsFlowNameRoute,
|
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