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:
@@ -40,6 +40,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"form-data": "4.0.5",
|
||||
"lucide-react": "^0.562.0",
|
||||
"monaco-editor": "^0.56.0",
|
||||
@@ -64,6 +65,7 @@
|
||||
"@playwright/test": "1.58.0",
|
||||
"@tanstack/router-devtools": "^1.142.11",
|
||||
"@tanstack/router-plugin": "^1.140.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -192,6 +192,92 @@ export const Body_oauth_tokenSchema = {
|
||||
title: 'Body_oauth-token'
|
||||
} as const;
|
||||
|
||||
export const BrainEdgeSchema = {
|
||||
properties: {
|
||||
source: {
|
||||
type: 'string',
|
||||
title: 'Source'
|
||||
},
|
||||
target: {
|
||||
type: 'string',
|
||||
title: 'Target'
|
||||
},
|
||||
messages: {
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Messages'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['source', 'target'],
|
||||
title: 'BrainEdge',
|
||||
description: 'Messages carrying values from one neuron to another.'
|
||||
} as const;
|
||||
|
||||
export const BrainGraphSchema = {
|
||||
properties: {
|
||||
nodes: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/BrainNode'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Nodes'
|
||||
},
|
||||
edges: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/BrainEdge'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Edges'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'BrainGraph',
|
||||
description: 'Every published flow at once, merged on what its nodes talk to.'
|
||||
} as const;
|
||||
|
||||
export const BrainNodeSchema = {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
title: 'Id'
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
title: 'Label'
|
||||
},
|
||||
kind: {
|
||||
type: 'string',
|
||||
title: 'Kind'
|
||||
},
|
||||
members: {
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Members'
|
||||
},
|
||||
flows: {
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Flows'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['id', 'label', 'kind'],
|
||||
title: 'BrainNode',
|
||||
description: `One neuron: a thing the engine talks to, or a node that only computes.
|
||||
|
||||
Nodes of the same type pointing at the same outside thing — one broker
|
||||
topic, one URL, one bucket — are a single entry here, whichever flows they
|
||||
sit in. \`\`members\`\` are the \`\`flow.node_id\`\` names behind it, which is also
|
||||
what the live events are keyed by.`
|
||||
} as const;
|
||||
|
||||
export const ChannelSchema = {
|
||||
properties: {
|
||||
name: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen';
|
||||
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen';
|
||||
|
||||
export class AlertsService {
|
||||
/**
|
||||
@@ -211,6 +211,19 @@ export class FlowsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Graph
|
||||
* Every flow as one graph, with nodes talking to the same thing merged.
|
||||
* @returns BrainGraph Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readGraph(): CancelablePromise<FlowsReadGraphResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/flows/graph'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Library
|
||||
* The node sources shared across flows, and which nodes use each.
|
||||
|
||||
@@ -74,6 +74,39 @@ export type Body_oauth_token = {
|
||||
resource?: (string | null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Messages carrying values from one neuron to another.
|
||||
*/
|
||||
export type BrainEdge = {
|
||||
source: string;
|
||||
target: string;
|
||||
messages?: Array<(string)>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every published flow at once, merged on what its nodes talk to.
|
||||
*/
|
||||
export type BrainGraph = {
|
||||
nodes?: Array<BrainNode>;
|
||||
edges?: Array<BrainEdge>;
|
||||
};
|
||||
|
||||
/**
|
||||
* One neuron: a thing the engine talks to, or a node that only computes.
|
||||
*
|
||||
* Nodes of the same type pointing at the same outside thing — one broker
|
||||
* topic, one URL, one bucket — are a single entry here, whichever flows they
|
||||
* sit in. ``members`` are the ``flow.node_id`` names behind it, which is also
|
||||
* what the live events are keyed by.
|
||||
*/
|
||||
export type BrainNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
members?: Array<(string)>;
|
||||
flows?: Array<(string)>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Somewhere to send an alert.
|
||||
*/
|
||||
@@ -787,6 +820,8 @@ export type FlowsReadFlowsResponse = (FlowsPublic);
|
||||
|
||||
export type FlowsReadNodeTypesResponse = (Array<NodeTypeInfo>);
|
||||
|
||||
export type FlowsReadGraphResponse = (BrainGraph);
|
||||
|
||||
export type FlowsReadLibraryResponse = (Array<LibraryNode>);
|
||||
|
||||
export type FlowsDeleteSharedNodeData = {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BaseEdge, type EdgeProps, getStraightPath } from "@xyflow/react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { duration } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useLatestTs } from "./liveStore"
|
||||
|
||||
export type BrainEdgeData = {
|
||||
/** Every message this connection carries, qualified. */
|
||||
messages: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection between neurons, lit by whatever passes along it.
|
||||
*
|
||||
* `LiveEdge` without the value chip or the producer check: a neuron can stand
|
||||
* for several nodes, so "did *this* node publish it" has no answer here, and a
|
||||
* circle the size of a coin has no room for a payload anyway.
|
||||
*/
|
||||
function BrainEdgeComponent({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const { messages } = (data ?? { messages: [] }) as BrainEdgeData
|
||||
const ts = useLatestTs(messages)
|
||||
const [pulsing, setPulsing] = useState(false)
|
||||
const lastTs = useRef(0)
|
||||
|
||||
const [path] = getStraightPath({ sourceX, sourceY, targetX, targetY })
|
||||
|
||||
useEffect(() => {
|
||||
if (!ts || ts === lastTs.current) return
|
||||
lastTs.current = ts
|
||||
setPulsing(true)
|
||||
const timer = setTimeout(() => setPulsing(false), duration.pulse * 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [ts])
|
||||
|
||||
return <BaseEdge id={id} path={path} className={cn(pulsing && "edge-live")} />
|
||||
}
|
||||
|
||||
export const BrainEdge = memo(BrainEdgeComponent)
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
||||
import { memo } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useGroupEmits, useGroupError } from "./liveStore"
|
||||
|
||||
export type BrainNodeData = {
|
||||
label: string
|
||||
kind: string
|
||||
/** The `flow.node_id` names behind this neuron; live events key by these. */
|
||||
members: string[]
|
||||
flows: string[]
|
||||
/** Circle diameter in pixels, from how many neurons this one is wired to. */
|
||||
size: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function BrainNodeComponent({ data }: NodeProps) {
|
||||
const { label, kind, members, flows, size } = data as BrainNodeData
|
||||
const emits = useGroupEmits(members)
|
||||
const failed = useGroupError(members)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center"
|
||||
title={`${kind} · ${members.join(", ")}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"brain-cell relative flex items-center justify-center rounded-full border bg-card shadow-e1",
|
||||
failed ? "border-destructive" : "border-border",
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{/* Remounting on each emit is what restarts the animation. */}
|
||||
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
|
||||
{/* Both ends sit at the centre, so an edge runs neuron to neuron. */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="brain-handle"
|
||||
isConnectable={false}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="brain-handle"
|
||||
isConnectable={false}
|
||||
/>
|
||||
<span className="px-1 text-center text-xs font-medium text-muted-foreground">
|
||||
{flows.length > 1 ? flows.length : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1.5 max-w-[140px] truncate text-center text-xs font-medium">
|
||||
{label}
|
||||
</span>
|
||||
{/* Colour is never the only carrier of a status. */}
|
||||
{failed ? (
|
||||
<span className="text-xs font-medium text-destructive">failed</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BrainNode = memo(BrainNodeComponent)
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
type Edge,
|
||||
type Node,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react"
|
||||
import "@xyflow/react/dist/style.css"
|
||||
import {
|
||||
forceCollide,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
forceSimulation,
|
||||
forceX,
|
||||
forceY,
|
||||
type SimulationNodeDatum,
|
||||
} from "d3-force"
|
||||
import { Brain } from "lucide-react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
|
||||
import type { BrainGraph } from "@/client"
|
||||
import { BrainEdge } from "./BrainEdge"
|
||||
import { BrainNode, type BrainNodeData } from "./BrainNode"
|
||||
import { CanvasTitle } from "./CanvasTitle"
|
||||
import "./flow.css"
|
||||
import { graphQueryOptions } from "./queries"
|
||||
import { useFlowSocket } from "./useFlowSocket"
|
||||
|
||||
// Never past 1: the labels are `text-xs`, and a graph small enough to fit
|
||||
// twice over should not render its text at twice the size of the sidebar.
|
||||
const FIT = { padding: 0.2, maxZoom: 1 }
|
||||
|
||||
const nodeTypes = { brain: BrainNode }
|
||||
const edgeTypes = { brain: BrainEdge }
|
||||
|
||||
/** Circle diameter, from how many neurons this one is wired to. */
|
||||
const SIZE_MIN = 32
|
||||
const SIZE_MAX = 96
|
||||
/** Room around a circle for its label, so the layout does not overlap them. */
|
||||
const LABEL_ROOM = 26
|
||||
|
||||
type Placed = SimulationNodeDatum & { id: string; size: number; room: number }
|
||||
|
||||
/**
|
||||
* Where the neurons sit: a force layout run to rest once and then frozen.
|
||||
*
|
||||
* A live simulation would keep nudging nodes under the pointer while someone is
|
||||
* panning, and a graph that never stops moving is unreadable. Starting from a
|
||||
* circle rather than d3's own random phyllotaxis also means the same flows lay
|
||||
* out the same way twice.
|
||||
*
|
||||
* `forceX`/`forceY` rather than `forceCenter`: centering only translates the
|
||||
* whole thing, so unconnected flows — which is most of them — would push each
|
||||
* other apart forever with nothing pulling back.
|
||||
*/
|
||||
function build(graph: BrainGraph): { nodes: Node[]; edges: Edge[] } {
|
||||
const degree = new Map<string, number>()
|
||||
for (const edge of graph.edges ?? []) {
|
||||
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1)
|
||||
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const placed: Placed[] = (graph.nodes ?? []).map((node, index, all) => {
|
||||
const size = Math.min(SIZE_MAX, SIZE_MIN + 8 * (degree.get(node.id) ?? 0))
|
||||
const angle = (index / all.length) * 2 * Math.PI
|
||||
const radius = 60 + all.length * 10
|
||||
return {
|
||||
id: node.id,
|
||||
size,
|
||||
room: size / 2 + LABEL_ROOM,
|
||||
x: Math.cos(angle) * radius,
|
||||
y: Math.sin(angle) * radius,
|
||||
}
|
||||
})
|
||||
const links = (graph.edges ?? []).map((edge) => ({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
}))
|
||||
|
||||
forceSimulation(placed)
|
||||
.force(
|
||||
"link",
|
||||
forceLink(links)
|
||||
.id((node) => (node as Placed).id)
|
||||
.distance(130),
|
||||
)
|
||||
.force("charge", forceManyBody().strength(-320).distanceMax(500))
|
||||
.force(
|
||||
"collide",
|
||||
forceCollide<Placed>((node) => node.room),
|
||||
)
|
||||
.force("x", forceX(0).strength(0.06))
|
||||
.force("y", forceY(0).strength(0.06))
|
||||
.stop()
|
||||
.tick(300)
|
||||
|
||||
return {
|
||||
nodes: placed.map((node, index) => {
|
||||
const source = (graph.nodes ?? [])[index]
|
||||
return {
|
||||
id: node.id,
|
||||
type: "brain",
|
||||
// React Flow places by the top-left corner; the layout means centres.
|
||||
position: {
|
||||
x: (node.x ?? 0) - node.size / 2,
|
||||
y: (node.y ?? 0) - node.size / 2,
|
||||
},
|
||||
data: {
|
||||
label: source.label,
|
||||
kind: source.kind,
|
||||
members: source.members ?? [],
|
||||
flows: source.flows ?? [],
|
||||
size: node.size,
|
||||
} satisfies BrainNodeData,
|
||||
}
|
||||
}),
|
||||
edges: (graph.edges ?? []).map((edge) => ({
|
||||
id: `${edge.source}->${edge.target}`,
|
||||
type: "brain",
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
data: { messages: edge.messages ?? [] },
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function BrainCanvas() {
|
||||
const navigate = useNavigate()
|
||||
const { fitView } = useReactFlow()
|
||||
useFlowSocket()
|
||||
// The socket invalidates every "flows" key on a rebuild, so publishing
|
||||
// anywhere re-fetches this and the layout runs again.
|
||||
const { data } = useQuery(graphQueryOptions())
|
||||
|
||||
const { nodes, edges } = useMemo(
|
||||
() => build(data ?? { nodes: [], edges: [] }),
|
||||
[data],
|
||||
)
|
||||
|
||||
// A rebuild lays the whole graph out afresh, so the viewport someone was
|
||||
// looking through no longer frames anything. Only when the set of neurons
|
||||
// actually changed: a value arriving must not move the canvas.
|
||||
const shape = nodes.map((node) => node.id).join(" ")
|
||||
useEffect(() => {
|
||||
if (!shape) return
|
||||
// After the new nodes have been measured, or the fit is of the old ones.
|
||||
const frame = requestAnimationFrame(() =>
|
||||
fitView({ ...FIT, duration: 300 }),
|
||||
)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [shape, fitView])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
fitView
|
||||
fitViewOptions={FIT}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
onNodeClick={(_event, node) => {
|
||||
const [flow] = (node.data as BrainNodeData).flows
|
||||
if (flow)
|
||||
navigate({ to: "/flows/$flowName", params: { flowName: flow } })
|
||||
}}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={24} size={1.5} />
|
||||
</ReactFlow>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<CanvasTitle>
|
||||
<span className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium">
|
||||
<Brain className="size-4 text-muted-foreground" />
|
||||
Brain
|
||||
</span>
|
||||
</CanvasTitle>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every flow at once, merged on what each node talks to.
|
||||
*
|
||||
* Read-only by design: what a neuron stands for lives in the flow it came
|
||||
* from, and clicking one goes there.
|
||||
*/
|
||||
export function BrainView() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<BrainCanvas />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
@@ -38,15 +38,21 @@
|
||||
}
|
||||
|
||||
/*
|
||||
* No `to` on purpose: the implied one is the edge's own resting style, so
|
||||
* the decay lands exactly where it started rather than on a second colour
|
||||
* that has to snap back — and a selected edge decays to its own blue.
|
||||
* Both ends are plain colours. The implied `to` would be the edge's resting
|
||||
* stroke, which is a `color-mix()` — and interpolating a hex against one of
|
||||
* those goes through oklab and leaves the gamut on the way, which turned
|
||||
* every pulse fluorescent yellow. Landing on `--muted-foreground` instead is
|
||||
* the same hue the edge rests in, so the last step back is invisible.
|
||||
*/
|
||||
@keyframes edge-pulse {
|
||||
from {
|
||||
stroke: var(--primary);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
to {
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: var(--xy-edge-stroke-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,3 +100,23 @@
|
||||
.react-flow__node:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Brain graph. Both ends of a connection sit at the neuron's centre, so an
|
||||
* edge runs straight from circle to circle and disappears under them.
|
||||
*/
|
||||
.brain-handle {
|
||||
opacity: 0;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.brain-cell {
|
||||
cursor: pointer;
|
||||
transition: border-color var(--duration-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.brain-cell:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -23,6 +23,9 @@ export const flowKeys = {
|
||||
["flows", name, "history", message] as const,
|
||||
nodeTypes: ["flows", "node-types"] as const,
|
||||
library: ["flows", "library"] as const,
|
||||
// Under `all` on purpose: the socket invalidates that prefix on every
|
||||
// rebuild, so the brain graph re-layouts when someone publishes.
|
||||
graph: ["flows", "graph"] as const,
|
||||
}
|
||||
|
||||
export const flowsQueryOptions = () => ({
|
||||
@@ -41,6 +44,12 @@ export const secretsQueryOptions = () => ({
|
||||
queryFn: () => SecretsService.readSecrets(),
|
||||
})
|
||||
|
||||
/** Every flow at once, merged on what its nodes talk to. */
|
||||
export const graphQueryOptions = () => ({
|
||||
queryKey: flowKeys.graph,
|
||||
queryFn: () => FlowsService.readGraph(),
|
||||
})
|
||||
|
||||
/** Node sources shared across flows, with the nodes using each. */
|
||||
export const libraryQueryOptions = () => ({
|
||||
queryKey: flowKeys.library,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Brain,
|
||||
Home,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
@@ -26,6 +27,7 @@ const baseItems: Item[] = [
|
||||
// "Home" rather than "Dashboard": dashboards are their own thing now.
|
||||
{ icon: Home, title: "Home", path: "/" },
|
||||
{ icon: Workflow, title: "Flows", path: "/flows" },
|
||||
{ icon: Brain, title: "Brain", path: "/brain" },
|
||||
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
|
||||
{ icon: Activity, title: "Health", path: "/health" },
|
||||
// Both are engine-wide operator settings rather than personal ones, so they
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Route as LayoutModulesRouteImport } from './routes/_layout/modules'
|
||||
import { Route as LayoutHealthRouteImport } from './routes/_layout/health'
|
||||
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
|
||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||
import { Route as CanvasBrainRouteImport } from './routes/_canvas/brain'
|
||||
import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
|
||||
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
|
||||
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
||||
@@ -102,6 +103,11 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||
path: '/admin',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const CanvasBrainRoute = CanvasBrainRouteImport.update({
|
||||
id: '/brain',
|
||||
path: '/brain',
|
||||
getParentRoute: () => CanvasRoute,
|
||||
} as any)
|
||||
const LayoutFlowsIndexRoute = LayoutFlowsIndexRouteImport.update({
|
||||
id: '/flows/',
|
||||
path: '/flows/',
|
||||
@@ -129,6 +135,7 @@ export interface FileRoutesByFullPath {
|
||||
'/recover-password': typeof RecoverPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/brain': typeof CanvasBrainRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/alerts': typeof LayoutAlertsRoute
|
||||
'/health': typeof LayoutHealthRoute
|
||||
@@ -148,6 +155,7 @@ export interface FileRoutesByTo {
|
||||
'/recover-password': typeof RecoverPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/brain': typeof CanvasBrainRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/alerts': typeof LayoutAlertsRoute
|
||||
'/health': typeof LayoutHealthRoute
|
||||
@@ -169,6 +177,7 @@ export interface FileRoutesById {
|
||||
'/recover-password': typeof RecoverPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/_canvas/brain': typeof CanvasBrainRoute
|
||||
'/_layout/admin': typeof LayoutAdminRoute
|
||||
'/_layout/alerts': typeof LayoutAlertsRoute
|
||||
'/_layout/health': typeof LayoutHealthRoute
|
||||
@@ -191,6 +200,7 @@ export interface FileRouteTypes {
|
||||
| '/recover-password'
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/brain'
|
||||
| '/admin'
|
||||
| '/alerts'
|
||||
| '/health'
|
||||
@@ -210,6 +220,7 @@ export interface FileRouteTypes {
|
||||
| '/recover-password'
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/brain'
|
||||
| '/admin'
|
||||
| '/alerts'
|
||||
| '/health'
|
||||
@@ -230,6 +241,7 @@ export interface FileRouteTypes {
|
||||
| '/recover-password'
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/_canvas/brain'
|
||||
| '/_layout/admin'
|
||||
| '/_layout/alerts'
|
||||
| '/_layout/health'
|
||||
@@ -363,6 +375,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LayoutAdminRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_canvas/brain': {
|
||||
id: '/_canvas/brain'
|
||||
path: '/brain'
|
||||
fullPath: '/brain'
|
||||
preLoaderRoute: typeof CanvasBrainRouteImport
|
||||
parentRoute: typeof CanvasRoute
|
||||
}
|
||||
'/_layout/flows/': {
|
||||
id: '/_layout/flows/'
|
||||
path: '/flows'
|
||||
@@ -395,11 +414,13 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
|
||||
interface CanvasRouteChildren {
|
||||
CanvasBrainRoute: typeof CanvasBrainRoute
|
||||
CanvasDashboardsNameRoute: typeof CanvasDashboardsNameRoute
|
||||
CanvasFlowsFlowNameRoute: typeof CanvasFlowsFlowNameRoute
|
||||
}
|
||||
|
||||
const CanvasRouteChildren: CanvasRouteChildren = {
|
||||
CanvasBrainRoute: CanvasBrainRoute,
|
||||
CanvasDashboardsNameRoute: CanvasDashboardsNameRoute,
|
||||
CanvasFlowsFlowNameRoute: CanvasFlowsFlowNameRoute,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import { BrainView } from "@/components/Flow/BrainView"
|
||||
|
||||
export const Route = createFileRoute("/_canvas/brain")({
|
||||
component: BrainView,
|
||||
head: () => ({ meta: [{ title: "Brain - Fluksio" }] }),
|
||||
})
|
||||
Reference in New Issue
Block a user