mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(fleet): interactive topology with ReactFlow hub-and-spoke layout (#713)
Replace the static SVG fleet topology with a ReactFlow canvas laid out via dagre. Each node renders as a rack card with status pill, type badge, CPU/MEM/DISK bars, and stack/running counts. Pan, zoom, drag, and minimap are enabled; user-dragged positions persist across the 30-second poll so live metric updates no longer reset layout.
This commit is contained in:
@@ -29,10 +29,10 @@ The top of the page is a status masthead that summarises the state of the entire
|
||||
Below the masthead, switch between two layouts:
|
||||
|
||||
- **Grid** (default): one card per node. The local node is pinned at the top with a cyan accent rail and a ★ Local badge so it is never confused with a remote.
|
||||
- **Topology**: a connection diagram with the local node on the left and remotes radiating to the right. Connector lines colour by link health (cyan when online, amber when critical, dashed rose when offline), and each node chip shows its status dot, CPU and memory readings. Click a node to jump to its details.
|
||||
- **Topology**: an interactive hub-and-spoke diagram with the local node on the left and remotes radiating to the right. Each node renders as a rack card with a status pill (Online, Critical, or Offline), a Local or Remote badge, CPU, memory, and disk bars, and a stack and running-container summary. The local node is framed with a cyan ring; critical nodes show a lightning icon; offline nodes are muted. Connector lines colour by link health (cyan when online, amber when critical, dashed when offline). Pan, zoom, drag nodes, and use the minimap to navigate large fleets. Click a node to jump to its details.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/fleet-view/fleet-topology.png" alt="Fleet Topology view showing the local node on the left with a dashed connector to an offline remote" />
|
||||
<img src="/images/fleet-view/fleet-topology.png" alt="Fleet Topology view showing the local node connected to a remote, each rendered as a rack card with CPU, memory, and disk bars" />
|
||||
</Frame>
|
||||
|
||||
### Tabs
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 117 KiB |
@@ -993,6 +993,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
status: n.status,
|
||||
cpuPercent: getNodeCpu(n),
|
||||
memPercent: getNodeMem(n),
|
||||
diskPercent: getNodeDisk(n),
|
||||
stackCount: n.stacks?.length ?? 0,
|
||||
runningCount: n.stats?.active ?? 0,
|
||||
critical: n.status === 'online' && isCritical(n),
|
||||
})), [processedNodes]);
|
||||
|
||||
|
||||
@@ -1,172 +1,223 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Server } from 'lucide-react';
|
||||
|
||||
interface TopologyNode {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
cpuPercent: number;
|
||||
memPercent: number;
|
||||
critical: boolean;
|
||||
}
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Handle,
|
||||
Position,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Server, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
layoutFleetGraph,
|
||||
type FleetNodeData,
|
||||
type FleetTopologyNode,
|
||||
} from '@/lib/fleet-topology-layout';
|
||||
|
||||
interface FleetTopologyProps {
|
||||
nodes: TopologyNode[];
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
nodes: FleetTopologyNode[];
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
interface Positioned {
|
||||
node: TopologyNode;
|
||||
x: number;
|
||||
y: number;
|
||||
// Raw oklch values for MiniMap coloring (ReactFlow cannot resolve CSS vars
|
||||
// inside inline styles / SVG fills).
|
||||
const MINIMAP_BRAND = 'oklch(0.78 0.11 195)';
|
||||
const MINIMAP_WARNING = 'oklch(0.75 0.14 75)';
|
||||
const MINIMAP_MUTED = 'oklch(0.55 0 0)';
|
||||
|
||||
function dotClass(node: FleetTopologyNode): string {
|
||||
if (node.status !== 'online') return 'bg-destructive';
|
||||
if (node.critical) return 'bg-warning';
|
||||
return 'bg-success';
|
||||
}
|
||||
|
||||
const LOCAL_X = 140;
|
||||
const LOCAL_Y = 260;
|
||||
const REMOTE_X_START = 460;
|
||||
const REMOTE_X_STEP = 220;
|
||||
const REMOTE_Y_AMPLITUDE = 160;
|
||||
const CANVAS_WIDTH = 1180;
|
||||
const CANVAS_HEIGHT = 520;
|
||||
|
||||
function layoutRemotes(remotes: TopologyNode[]): Positioned[] {
|
||||
if (remotes.length === 0) return [];
|
||||
const positioned: Positioned[] = [];
|
||||
const cols = Math.min(3, Math.max(1, Math.ceil(remotes.length / 3)));
|
||||
const perCol = Math.ceil(remotes.length / cols);
|
||||
for (let i = 0; i < remotes.length; i += 1) {
|
||||
const col = Math.floor(i / perCol);
|
||||
const row = i % perCol;
|
||||
const x = REMOTE_X_START + col * REMOTE_X_STEP;
|
||||
const yCenter = LOCAL_Y;
|
||||
const offset = perCol === 1
|
||||
? 0
|
||||
: (row - (perCol - 1) / 2) * (REMOTE_Y_AMPLITUDE / Math.max(1, perCol - 1)) * 2;
|
||||
positioned.push({ node: remotes[i], x, y: yCenter + offset });
|
||||
}
|
||||
return positioned;
|
||||
function barColor(value: number): string {
|
||||
if (value >= 85) return 'bg-destructive';
|
||||
if (value >= 60) return 'bg-warning';
|
||||
return 'bg-brand';
|
||||
}
|
||||
|
||||
function linkClass(node: TopologyNode): string {
|
||||
if (node.status !== 'online') return 'stroke-destructive/40';
|
||||
if (node.critical) return 'stroke-warning/60';
|
||||
return 'stroke-brand/40';
|
||||
}
|
||||
|
||||
function dotClass(node: TopologyNode): string {
|
||||
if (node.status !== 'online') return 'bg-destructive';
|
||||
if (node.critical) return 'bg-warning';
|
||||
return 'bg-success';
|
||||
}
|
||||
|
||||
export function FleetTopology({ nodes, onNodeClick }: FleetTopologyProps) {
|
||||
const local = useMemo(() => nodes.find(n => n.type === 'local') ?? null, [nodes]);
|
||||
const remotes = useMemo(() => layoutRemotes(nodes.filter(n => n.type === 'remote')), [nodes]);
|
||||
|
||||
if (!local && remotes.length === 0) {
|
||||
function MetricBar({ label, value, muted }: { label: string; value: number; muted: boolean }) {
|
||||
const clamped = Math.max(0, Math.min(100, value));
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-10 text-center">
|
||||
<p className="text-sm text-stat-subtitle">No nodes to plot.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
|
||||
<div className="relative overflow-hidden rounded-md" style={{ height: CANVAS_HEIGHT }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}`}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
{local
|
||||
? remotes.map(r => (
|
||||
<line
|
||||
key={`link-${r.node.id}`}
|
||||
x1={LOCAL_X + 48}
|
||||
y1={LOCAL_Y}
|
||||
x2={r.x}
|
||||
y2={r.y}
|
||||
strokeWidth={1}
|
||||
strokeDasharray={r.node.status === 'online' ? undefined : '4 4'}
|
||||
className={linkClass(r.node)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-9 font-mono text-[9px] uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-1 rounded-full bg-muted/60 overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-[width] duration-300', muted ? 'bg-muted-foreground/40' : barColor(value))}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</svg>
|
||||
|
||||
{local ? (
|
||||
<NodeChip
|
||||
node={local}
|
||||
x={LOCAL_X}
|
||||
y={LOCAL_Y}
|
||||
size="lg"
|
||||
canvasWidth={CANVAS_WIDTH}
|
||||
canvasHeight={CANVAS_HEIGHT}
|
||||
onClick={onNodeClick}
|
||||
/>
|
||||
) : null}
|
||||
{remotes.map(r => (
|
||||
<NodeChip
|
||||
key={r.node.id}
|
||||
node={r.node}
|
||||
x={r.x}
|
||||
y={r.y}
|
||||
size="md"
|
||||
canvasWidth={CANVAS_WIDTH}
|
||||
canvasHeight={CANVAS_HEIGHT}
|
||||
onClick={onNodeClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
<span className="w-8 text-right font-mono text-[10px] tabular-nums text-stat-value">
|
||||
{Math.round(clamped)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NodeChipProps {
|
||||
node: TopologyNode;
|
||||
x: number;
|
||||
y: number;
|
||||
size: 'md' | 'lg';
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
onClick?: (nodeId: number) => void;
|
||||
function FleetNodeCard({ data, selected }: { data: FleetNodeData; selected?: boolean }) {
|
||||
const node = data.node;
|
||||
const isLocal = node.type === 'local';
|
||||
const isOffline = node.status !== 'online';
|
||||
const stackLabel = node.stackCount === 1 ? 'stack' : 'stacks';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-[240px] rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors',
|
||||
'hover:border-t-card-border-hover cursor-pointer',
|
||||
isLocal && 'ring-1 ring-brand/40',
|
||||
isOffline && 'opacity-70',
|
||||
selected && 'ring-1 ring-brand',
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
|
||||
<span aria-hidden="true" className={cn('h-2 w-2 rounded-full shrink-0', dotClass(node))} />
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{isOffline ? 'Offline' : node.critical ? 'Critical' : 'Online'}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
{node.critical && !isOffline ? (
|
||||
<Zap className="h-3 w-3 text-warning" strokeWidth={2} aria-label="Critical" />
|
||||
) : null}
|
||||
<span className={cn(
|
||||
'font-mono text-[9px] uppercase tracking-[0.22em]',
|
||||
isLocal ? 'text-brand' : 'text-muted-foreground',
|
||||
)}>
|
||||
{isLocal ? 'Local' : 'Remote'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
|
||||
<Server className="h-3.5 w-3.5 text-stat-icon shrink-0" strokeWidth={1.5} />
|
||||
<span className="text-xs font-medium text-stat-value truncate">{node.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 space-y-1 border-b border-card-border">
|
||||
<MetricBar label="CPU" value={node.cpuPercent} muted={isOffline} />
|
||||
<MetricBar label="MEM" value={node.memPercent} muted={isOffline} />
|
||||
<MetricBar label="DISK" value={node.diskPercent} muted={isOffline} />
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-1.5 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{node.stackCount} {stackLabel} · {node.runningCount} running
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeChip({ node, x, y, size, canvasWidth, canvasHeight, onClick }: NodeChipProps) {
|
||||
const isLg = size === 'lg';
|
||||
const width = isLg ? 200 : 180;
|
||||
const height = isLg ? 96 : 80;
|
||||
const isLocal = node.type === 'local';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(node.id)}
|
||||
className={`absolute flex flex-col items-start gap-1 rounded-lg border border-card-border border-t-card-border-top bg-card px-3 py-2 text-left shadow-card-bevel transition-colors hover:border-t-card-border-hover ${isLocal ? 'ring-1 ring-brand/40' : ''}`}
|
||||
style={{
|
||||
left: `${((x - width / 2) / canvasWidth) * 100}%`,
|
||||
top: `${((y - height / 2) / canvasHeight) * 100}%`,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden="true" className={`h-2 w-2 rounded-full ${dotClass(node)}`} />
|
||||
<Server className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
|
||||
<span className="font-mono text-xs text-stat-value truncate">{node.name}</span>
|
||||
{isLocal ? (
|
||||
<span className="ml-auto font-mono text-[9px] uppercase tracking-[0.22em] text-brand">Local</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 font-mono text-[11px] tabular-nums text-stat-subtitle">
|
||||
<span>CPU {node.cpuPercent.toFixed(0)}%</span>
|
||||
<span className="text-stat-icon">·</span>
|
||||
<span>MEM {node.memPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle/80">
|
||||
{node.status === 'online' ? (node.critical ? 'critical' : 'online') : 'offline'}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
const nodeTypes: NodeTypes = {
|
||||
fleetNode: FleetNodeCard,
|
||||
};
|
||||
|
||||
export function FleetTopology({ nodes: fleetNodes, onNodeClick }: FleetTopologyProps) {
|
||||
const onNodeClickRef = useRef(onNodeClick);
|
||||
onNodeClickRef.current = onNodeClick;
|
||||
|
||||
// Only re-layout when the topology *shape* changes (nodes added/removed,
|
||||
// type or status flips). Metric value changes alone must not snap
|
||||
// user-dragged nodes back to the dagre-computed positions on every poll.
|
||||
const shapeKey = useMemo(
|
||||
() => fleetNodes.map(n => `${n.id}:${n.type}:${n.status}`).sort().join('|'),
|
||||
[fleetNodes],
|
||||
);
|
||||
|
||||
const [flowNodes, setFlowNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const { nodes: nextNodes, edges: nextEdges } = layoutFleetGraph(fleetNodes);
|
||||
setFlowNodes(nextNodes);
|
||||
setFlowEdges(nextEdges);
|
||||
// fleetNodes is intentionally excluded: we only relayout on shape changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shapeKey, setFlowNodes, setFlowEdges]);
|
||||
|
||||
// Update live metrics on every poll without resetting positions.
|
||||
useEffect(() => {
|
||||
setFlowNodes(current => current.map(flowNode => {
|
||||
const next = fleetNodes.find(n => String(n.id) === flowNode.id);
|
||||
if (!next) return flowNode;
|
||||
const existing = (flowNode.data as FleetNodeData | undefined)?.node;
|
||||
if (existing && existing.cpuPercent === next.cpuPercent
|
||||
&& existing.memPercent === next.memPercent
|
||||
&& existing.diskPercent === next.diskPercent
|
||||
&& existing.stackCount === next.stackCount
|
||||
&& existing.runningCount === next.runningCount
|
||||
&& existing.critical === next.critical) {
|
||||
return flowNode;
|
||||
}
|
||||
return { ...flowNode, data: { node: next } satisfies FleetNodeData };
|
||||
}));
|
||||
}, [fleetNodes, setFlowNodes]);
|
||||
|
||||
const handleNodeClick = useCallback((_event: React.MouseEvent, flowNode: Node) => {
|
||||
const id = Number(flowNode.id);
|
||||
if (!Number.isNaN(id)) {
|
||||
onNodeClickRef.current?.(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const miniMapNodeColor = useCallback((n: Node) => {
|
||||
const data = n.data as FleetNodeData | undefined;
|
||||
const topo = data?.node;
|
||||
if (!topo || topo.status !== 'online') return MINIMAP_MUTED;
|
||||
if (topo.critical) return MINIMAP_WARNING;
|
||||
return MINIMAP_BRAND;
|
||||
}, []);
|
||||
|
||||
if (fleetNodes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-10 text-center">
|
||||
<p className="text-sm text-muted-foreground">No nodes to plot.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="h-[560px] w-full">
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodesConnectable={false}
|
||||
className="bg-background"
|
||||
>
|
||||
<Background gap={20} size={1} className="opacity-30" />
|
||||
<Controls
|
||||
className="!bg-card !border-card-border !shadow-card-bevel [&>button]:!bg-card [&>button]:!border-card-border [&>button]:!text-foreground [&>button:hover]:!bg-muted"
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
className="!bg-card !border-card-border !shadow-card-bevel"
|
||||
nodeColor={miniMapNodeColor}
|
||||
maskColor="oklch(0 0 0 / 0.2)"
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import dagre from '@dagrejs/dagre';
|
||||
import type { Edge, Node } from '@xyflow/react';
|
||||
|
||||
export interface FleetTopologyNode {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
cpuPercent: number;
|
||||
memPercent: number;
|
||||
diskPercent: number;
|
||||
stackCount: number;
|
||||
runningCount: number;
|
||||
critical: boolean;
|
||||
}
|
||||
|
||||
export interface FleetNodeData extends Record<string, unknown> {
|
||||
node: FleetTopologyNode;
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 240;
|
||||
const NODE_HEIGHT = 150;
|
||||
|
||||
// ReactFlow inline styles cannot resolve CSS custom properties, so raw oklch
|
||||
// values are used. Values match the design tokens in frontend/src/index.css.
|
||||
const EDGE_BRAND = 'oklch(0.78 0.11 195)';
|
||||
const EDGE_WARNING = 'oklch(0.75 0.14 75)';
|
||||
const EDGE_MUTED = 'oklch(0.55 0 0)';
|
||||
|
||||
function edgeStyle(remote: FleetTopologyNode): {
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
strokeDasharray?: string;
|
||||
} {
|
||||
if (remote.status !== 'online') {
|
||||
return { stroke: EDGE_MUTED, strokeWidth: 1, strokeDasharray: '4 4' };
|
||||
}
|
||||
if (remote.critical) {
|
||||
return { stroke: EDGE_WARNING, strokeWidth: 1.5 };
|
||||
}
|
||||
return { stroke: EDGE_BRAND, strokeWidth: 1.5 };
|
||||
}
|
||||
|
||||
export function layoutFleetGraph(
|
||||
fleetNodes: FleetTopologyNode[],
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
if (fleetNodes.length === 0) return { nodes: [], edges: [] };
|
||||
|
||||
const local = fleetNodes.find(n => n.type === 'local') ?? null;
|
||||
const remotes = fleetNodes.filter(n => n.type !== 'local');
|
||||
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir: 'LR', ranksep: 160, nodesep: 32, marginx: 20, marginy: 20 });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
for (const n of fleetNodes) {
|
||||
g.setNode(String(n.id), { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
}
|
||||
|
||||
if (local) {
|
||||
for (const r of remotes) {
|
||||
g.setEdge(String(local.id), String(r.id));
|
||||
}
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const flowNodes: Node[] = fleetNodes.map(n => {
|
||||
const pos = g.node(String(n.id));
|
||||
return {
|
||||
id: String(n.id),
|
||||
type: 'fleetNode',
|
||||
position: { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 },
|
||||
data: { node: n } satisfies FleetNodeData,
|
||||
draggable: true,
|
||||
};
|
||||
});
|
||||
|
||||
const flowEdges: Edge[] = local
|
||||
? remotes.map(r => ({
|
||||
id: `edge-${local.id}-${r.id}`,
|
||||
source: String(local.id),
|
||||
target: String(r.id),
|
||||
style: edgeStyle(r),
|
||||
animated: false,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return { nodes: flowNodes, edges: flowEdges };
|
||||
}
|
||||
Reference in New Issue
Block a user