From 4bcd38354b94c3fde1ef1704182f01b44cdf6c0d Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 16 Aug 2026 22:59:31 +0200 Subject: [PATCH] Draw every flow as one graph, merged on what it talks to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt --- NOTEPAD.md | 2 + ROADMAP.md | 6 + backend/app/api/routes/flows.py | 9 + backend/app/flow/controller.py | 70 ++++++ backend/app/flow/nodes/base.py | 15 ++ backend/app/flow/nodes/http.py | 8 + backend/app/flow/nodes/influx.py | 6 + backend/app/flow/nodes/mqtt.py | 19 ++ backend/app/flow/schemas.py | 31 +++ backend/app/mcp/server.py | 10 + backend/tests/flow/test_brain_graph.py | 82 +++++++ bun.lock | 8 + frontend/package.json | 2 + frontend/src/client/schemas.gen.ts | 86 ++++++++ frontend/src/client/sdk.gen.ts | 15 +- frontend/src/client/types.gen.ts | 35 +++ frontend/src/components/Flow/BrainEdge.tsx | 47 ++++ frontend/src/components/Flow/BrainNode.tsx | 65 ++++++ frontend/src/components/Flow/BrainView.tsx | 206 ++++++++++++++++++ frontend/src/components/Flow/flow.css | 32 ++- frontend/src/components/Flow/liveStore.ts | Bin 7024 -> 9108 bytes frontend/src/components/Flow/queries.ts | 9 + .../src/components/Sidebar/AppSidebar.tsx | 2 + frontend/src/routeTree.gen.ts | 21 ++ frontend/src/routes/_canvas/brain.tsx | 8 + 25 files changed, 790 insertions(+), 4 deletions(-) create mode 100644 backend/tests/flow/test_brain_graph.py create mode 100644 frontend/src/components/Flow/BrainEdge.tsx create mode 100644 frontend/src/components/Flow/BrainNode.tsx create mode 100644 frontend/src/components/Flow/BrainView.tsx create mode 100644 frontend/src/routes/_canvas/brain.tsx diff --git a/NOTEPAD.md b/NOTEPAD.md index 27d156d..50cfe89 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index f663982..f0ff954 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`) diff --git a/backend/app/api/routes/flows.py b/backend/app/api/routes/flows.py index b6e725e..bb23521 100644 --- a/backend/app/api/routes/flows.py +++ b/backend/app/api/routes/flows.py @@ -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 # diff --git a/backend/app/flow/controller.py b/backend/app/flow/controller.py index 4d34771..1aef065 100644 --- a/backend/app/flow/controller.py +++ b/backend/app/flow/controller.py @@ -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: diff --git a/backend/app/flow/nodes/base.py b/backend/app/flow/nodes/base.py index 5ef5888..efc236c 100644 --- a/backend/app/flow/nodes/base.py +++ b/backend/app/flow/nodes/base.py @@ -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.""" diff --git a/backend/app/flow/nodes/http.py b/backend/app/flow/nodes/http.py index 67fc688..41a4a4e 100644 --- a/backend/app/flow/nodes/http.py +++ b/backend/app/flow/nodes/http.py @@ -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, diff --git a/backend/app/flow/nodes/influx.py b/backend/app/flow/nodes/influx.py index bed16bd..7ce3a2b 100644 --- a/backend/app/flow/nodes/influx.py +++ b/backend/app/flow/nodes/influx.py @@ -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] = (), diff --git a/backend/app/flow/nodes/mqtt.py b/backend/app/flow/nodes/mqtt.py index 738f776..8cf2505 100644 --- a/backend/app/flow/nodes/mqtt.py +++ b/backend/app/flow/nodes/mqtt.py @@ -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] = (), diff --git a/backend/app/flow/schemas.py b/backend/app/flow/schemas.py index 64fc731..9932a20 100644 --- a/backend/app/flow/schemas.py +++ b/backend/app/flow/schemas.py @@ -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.""" diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py index 59bd56d..f014005 100644 --- a/backend/app/mcp/server.py +++ b/backend/app/mcp/server.py @@ -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 # ----------------------------------------------------------------------------- diff --git a/backend/tests/flow/test_brain_graph.py b/backend/tests/flow/test_brain_graph.py new file mode 100644 index 0000000..71f44b7 --- /dev/null +++ b/backend/tests/flow/test_brain_graph.py @@ -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" diff --git a/bun.lock b/bun.lock index 21cc647..959774c 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/frontend/package.json b/frontend/package.json index 843046f..84d2a6c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 08507e7..aafd885 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 8ad79b5..856ffd5 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -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 { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/flows/graph' + }); + } + /** * Read Library * The node sources shared across flows, and which nodes use each. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index c110dac..dde669b 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -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; + edges?: Array; +}; + +/** + * 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); +export type FlowsReadGraphResponse = (BrainGraph); + export type FlowsReadLibraryResponse = (Array); export type FlowsDeleteSharedNodeData = { diff --git a/frontend/src/components/Flow/BrainEdge.tsx b/frontend/src/components/Flow/BrainEdge.tsx new file mode 100644 index 0000000..73a92eb --- /dev/null +++ b/frontend/src/components/Flow/BrainEdge.tsx @@ -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 +} + +export const BrainEdge = memo(BrainEdgeComponent) diff --git a/frontend/src/components/Flow/BrainNode.tsx b/frontend/src/components/Flow/BrainNode.tsx new file mode 100644 index 0000000..bd9d18a --- /dev/null +++ b/frontend/src/components/Flow/BrainNode.tsx @@ -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 ( +
+ + {/* Remounting on each emit is what restarts the animation. */} + {emits > 0 ? : null} + {/* Both ends sit at the centre, so an edge runs neuron to neuron. */} + + + + {flows.length > 1 ? flows.length : null} + + + + {label} + + {/* Colour is never the only carrier of a status. */} + {failed ? ( + failed + ) : null} +
+ ) +} + +export const BrainNode = memo(BrainNodeComponent) diff --git a/frontend/src/components/Flow/BrainView.tsx b/frontend/src/components/Flow/BrainView.tsx new file mode 100644 index 0000000..8f41ddc --- /dev/null +++ b/frontend/src/components/Flow/BrainView.tsx @@ -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() + 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((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 ( + <> + { + const [flow] = (node.data as BrainNodeData).flows + if (flow) + navigate({ to: "/flows/$flowName", params: { flowName: flow } }) + }} + className="h-full w-full" + > + + + +
+ + + + Brain + + +
+ + ) +} + +/** + * 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 ( + + + + ) +} diff --git a/frontend/src/components/Flow/flow.css b/frontend/src/components/Flow/flow.css index 16fa810..af18e38 100644 --- a/frontend/src/components/Flow/flow.css +++ b/frontend/src/components/Flow/flow.css @@ -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); +} diff --git a/frontend/src/components/Flow/liveStore.ts b/frontend/src/components/Flow/liveStore.ts index d057301521750de3a211aef85e35e90ccf6d37b5..3fa714cb0586aaf9986bb92912b1a53190bf8cf5 100644 GIT binary patch delta 1891 zcmc&#(Q4E{6!o#>xs_T`E~Q{nW46BdP}r7Q7X=l>3O-1&ljQDZ+R03qnY4yd7QsJQ z-~9$Z#Xs>AyfaC6Qz``o@nIoz=gyfk=iGbu=aa9`S9n<|ZQ&ehgZoT~lw~LJ_oXju z)4^r{oA6w#x`INf6BEN&X|M%Csu`Cs(X1-AK}iIOwN?_0WimHD&EP3&CP1nj=_JJ+ zn#;v}f>%;XG=@#kB$VNjMl35pPtn#|8emXqUUJJ%F@YmudCWR(V~F z)m%;>MRy~xx3aN?3lvOF45;zgNnbi1gCNM1G!_m7Og3P-*OVT3jYx{$HiW_1&h6EwzGz2OG$!PEPk?9}WL>6Yv!mQ{?Q9rQt(e3uLQ*xF zeO}&J?zKedp9m1n9xp9VhdbR87CJe(=TYd~O-v=Y^@y#!JokznA(7kOIOqArQ55%% zU5dPj&9^_aOb_E=>g4X?-hmFFP3YgLF|RWWL#r$kF+I`W?pN>-LdU`+6SO2#1iQPi z6~*pHc+sUP?+YXcs8<$l+_|)aYCWqpHHsAtj2RcR!Rp=pR(-9NzNUVvltAj3|1Cca zDqEYsG+^4A(>LHZb{kca@SWoAeKg`at#^|)0>dHoFFLK(ugOk>WuZC3<(PzJ!Kn8$ zH-cz>FgRtR_I3a46|-pUI}`dLwP^es>)z-7`^tCMw6*W~lJ+k0hvh%Toa+C^@}DS*Cbi~V5t1Ya{-P`7@X?|8!Ay9Zv`Xg8sMn6_PdNL!1ho!wu#H~Y5s F;} ({ @@ -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, diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index de843da..314166b 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -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 diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 794b3e9..2a84ae2 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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, } diff --git a/frontend/src/routes/_canvas/brain.tsx b/frontend/src/routes/_canvas/brain.tsx new file mode 100644 index 0000000..af735f2 --- /dev/null +++ b/frontend/src/routes/_canvas/brain.tsx @@ -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" }] }), +})