Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function getGraphData(): GraphData {
serviceFlow: EMPTY_VIEW,
dataFlow: EMPTY_VIEW,
functionFlow: EMPTY_VIEW,
containerDiagram: EMPTY_VIEW,
},
};
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/GraphTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"use client";

export type ViewTab = "serviceFlow" | "dataFlow" | "functionFlow";
export type ViewTab = "serviceFlow" | "dataFlow" | "functionFlow" | "containerDiagram";

const TABS: { id: ViewTab; label: string; description: string }[] = [
{ id: "serviceFlow", label: "Service Flow", description: "Service dependencies" },
{ id: "dataFlow", label: "Data Flow", description: "DTO contracts" },
{ id: "functionFlow", label: "Function Flow", description: "Intra-service method call graph" },
{ id: "containerDiagram", label: "Container", description: "C4-style container diagram with infrastructure" },
];

interface Props {
Expand Down
175 changes: 161 additions & 14 deletions apps/web/src/components/GraphView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useCallback } from "react";
import { useEffect, useCallback, useMemo } from "react";
import {
ReactFlow,
Background,
Expand All @@ -20,15 +20,132 @@ import { buildKindMap } from "@/lib/classify";
import ServiceNode from "./nodes/ServiceNode";
import DataTypeNode from "./nodes/DataTypeNode";
import FunctionNode from "./nodes/FunctionNode";
import DatabaseNode from "./nodes/DatabaseNode";
import QueueNode from "./nodes/QueueNode";
import CacheNode from "./nodes/CacheNode";
import ExternalNode from "./nodes/ExternalNode";
import ServiceSearch from "./ServiceSearch";
import { applyDagreLayout } from "@/lib/layout";
import { getLayoutedElements } from "@/lib/layout";

const nodeTypes = {
serviceNode: ServiceNode,
dataTypeNode: DataTypeNode,
functionNode: FunctionNode,
databaseNode: DatabaseNode,
queueNode: QueueNode,
cacheNode: CacheNode,
externalNode: ExternalNode,
};

// ─── Edge routing helpers ─────────────────────────────────────────────────────

/**
* Returns the absolute canvas center of a node. For child nodes (parentId set)
* the parent top-left is added so all positions share the same coordinate space.
*/
function getAbsoluteCenter(
nodeId: string,
nodeMap: Map<string, any>
): { x: number; y: number } {
const n = nodeMap.get(nodeId);
if (!n) return { x: 0, y: 0 };
const w = n.measured?.width ?? 260;
const h = n.measured?.height ?? 160;
let x = (n.position?.x ?? 0) + w / 2;
let y = (n.position?.y ?? 0) + h / 2;
if (n.parentId) {
const parent = nodeMap.get(n.parentId);
if (parent) {
x += parent.position?.x ?? 0;
y += parent.position?.y ?? 0;
}
}
return { x, y };
}

/**
* Assigns sourceHandle / targetHandle and enforces smoothstep routing so edges
* travel through the channels between nodes rather than crossing node boxes.
*
* A per-node per-handle occupancy counter distributes multiple edges that leave
* the same node on the same side to different dock points instead of stacking.
*
* Priority order for each direction:
* going right → right, bottom, top, left
* going left → left, bottom, top, right
* going down → bottom, right, left, top
* going up → top, right, left, bottom
*/
function routeEdges(edges: any[], nodeMap: Map<string, any>): any[] {
const usage = new Map<string, number>(); // `${nodeId}:${handleId}` → count

function inc(nodeId: string, handleId: string) {
const key = `${nodeId}:${handleId}`;
usage.set(key, (usage.get(key) ?? 0) + 1);
}

function pickHandle(nodeId: string, prefs: string[]): string {
let best = prefs[0];
let bestCount = usage.get(`${nodeId}:${best}`) ?? 0;
for (let i = 1; i < prefs.length; i++) {
const c = usage.get(`${nodeId}:${prefs[i]}`) ?? 0;
if (c < bestCount) { bestCount = c; best = prefs[i]; }
}
return best;
}

return edges.map((e) => {
const src = getAbsoluteCenter(e.source, nodeMap);
const tgt = getAbsoluteCenter(e.target, nodeMap);
const dx = tgt.x - src.x;
const dy = tgt.y - src.y;

let srcPrefs: string[];
let tgtPrefs: string[];

if (Math.abs(dx) >= Math.abs(dy)) {
if (dx >= 0) {
srcPrefs = ["source-right", "source-bottom", "source-top", "source-left"];
tgtPrefs = ["target-left", "target-bottom", "target-top", "target-right"];
} else {
srcPrefs = ["source-left", "source-bottom", "source-top", "source-right"];
tgtPrefs = ["target-right", "target-bottom", "target-top", "target-left"];
}
} else {
if (dy >= 0) {
srcPrefs = ["source-bottom", "source-right", "source-left", "source-top"];
tgtPrefs = ["target-top", "target-right", "target-left", "target-bottom"];
} else {
srcPrefs = ["source-top", "source-right", "source-left", "source-bottom"];
tgtPrefs = ["target-bottom", "target-right", "target-left", "target-top"];
}
}

const sourceHandle = pickHandle(e.source, srcPrefs);
const targetHandle = pickHandle(e.target, tgtPrefs);
inc(e.source, sourceHandle);
inc(e.target, targetHandle);

// markerEnd: accept both string "arrow" (from graph-builder) and already-
// converted { type: "arrow" } objects (when re-routing after auto-layout).
const markerEnd = e.markerEnd
? typeof e.markerEnd === "string" ? { type: e.markerEnd } : e.markerEnd
: undefined;

return {
...e,
// smoothstep produces orthogonal (right-angle) paths that travel through
// the channels between nodes instead of cutting diagonally across them.
type: e.type ?? "smoothstep",
sourceHandle,
targetHandle,
...(markerEnd ? { markerEnd } : {}),
};
});
}

// ─── Canvas component ─────────────────────────────────────────────────────────

interface Props {
view: GraphViewData;
viewType: ViewTab;
Expand All @@ -49,7 +166,7 @@ function GraphCanvas({
onSelectedServiceChange,
onDrillIn,
}: Omit<Props, "serviceCount">) {
// Compute filtered nodes/edges for functionFlow
// ── 1. Filter nodes and edges ─────────────────────────────────────────────
const serviceFilteredNodes =
viewType === "functionFlow" && selectedServiceId
? view.nodes.filter((n) => (n.data as any).serviceId === selectedServiceId)
Expand All @@ -64,15 +181,14 @@ function GraphCanvas({
)
: view.edges;

// Filter orphan nodes in functionFlow
const filteredNodes = viewType === "functionFlow"
? (() => {
const connectedIds = new Set(filteredEdges.flatMap((e) => [e.source, e.target]));
return serviceFilteredNodes.filter((n) => connectedIds.has(n.id));
})()
: serviceFilteredNodes;

// Enrich function nodes with kind metadata
// ── 2. Enrich and assign z-index ──────────────────────────────────────────
const enrichedNodes = viewType === "functionFlow"
? (() => {
const kindMap = buildKindMap(filteredNodes, allServices);
Expand All @@ -83,20 +199,49 @@ function GraphCanvas({
})()
: filteredNodes;

const [nodes, setNodes, onNodesChange] = useNodesState(enrichedNodes as any);
const [edges, setEdges, onEdgesChange] = useEdgesState(filteredEdges as any);
// ── 3. Dagre layout (memoised — re-runs only when view data changes) ───────
// Applying layout before routing means handle selection is based on the
// final node positions, so edge dock assignments are accurate.
// On first render node.measured is undefined; dagre uses the hardcoded
// defaults (260×160). The Auto Layout button re-runs with real dimensions.
const laidNodes = useMemo(() => {
const { nodes } = getLayoutedElements(enrichedNodes as any, filteredEdges as any);
return nodes;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, viewType, selectedServiceId]);

// ── 4. Route edges using laid-out positions ───────────────────────────────
const rfEdges = useMemo(() => {
const map = new Map((laidNodes as any[]).map((n: any) => [n.id, n]));
return routeEdges(filteredEdges, map);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [laidNodes]);

// ── 5. React Flow state ───────────────────────────────────────────────────
const [nodes, setNodes, onNodesChange] = useNodesState(laidNodes as any);
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges as any);
const { fitView } = useReactFlow();

// Sync state whenever the laid-out data changes (view switch, filter change)
useEffect(() => {
setNodes(enrichedNodes as any);
setEdges(filteredEdges as any);
}, [view, viewType, selectedServiceId, setNodes, setEdges]); // eslint-disable-line react-hooks/exhaustive-deps
setNodes(laidNodes as any);
setEdges(rfEdges as any);
setTimeout(() => fitView({ duration: 300 }), 50);
}, [laidNodes, rfEdges, setNodes, setEdges, fitView]);

// Manual re-layout: uses measured dimensions from current state and re-routes
// edges from the original (unprocessed) filteredEdges to avoid double-
// converting markerEnd.
const handleAutoLayout = useCallback(() => {
const laid = applyDagreLayout(nodes as any, edges as any);
// Re-run with actual measured dimensions (available after first render).
const { nodes: laid } = getLayoutedElements(nodes as any, filteredEdges as any);
const nm = new Map(laid.map((n: any) => [n.id, n]));
const rerouted = routeEdges(filteredEdges, nm);
setNodes(laid as any);
setEdges(rerouted as any);
setTimeout(() => fitView({ duration: 400 }), 50);
}, [nodes, edges, setNodes, fitView]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodes, filteredEdges, setNodes, setEdges, fitView]);

return (
<ReactFlow
Expand Down Expand Up @@ -150,6 +295,8 @@ function GraphCanvas({
);
}

// ─── Public component ─────────────────────────────────────────────────────────

export default function GraphView({
view,
viewType,
Expand All @@ -164,15 +311,13 @@ export default function GraphView({
return (
<div className="w-full h-full flex items-center justify-center text-gray-500">
<div className="text-center">
<div className="text-4xl mb-4">📡</div>
<p className="text-lg font-medium text-gray-400">No graph data yet</p>
<p className="text-sm mt-1">Run the scanner to generate your architecture map</p>
</div>
</div>
);
}

// Function flow empty state when no service selected
if (viewType === "functionFlow" && !selectedServiceId) {
return (
<div className="w-full h-full flex flex-col items-center justify-center text-gray-500 gap-4">
Expand Down Expand Up @@ -201,6 +346,8 @@ export default function GraphView({
? "No DTOs/data types detected"
: viewType === "functionFlow"
? "No functions detected in scanned services"
: viewType === "containerDiagram"
? "No infrastructure declared in archmap.yml files"
: "No services detected"}
</p>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ServiceSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export default function ServiceSearch({ services, selectedId, onChange }: Props)
className="text-gray-400 hover:text-gray-200 shrink-0"
aria-label="Clear selection"
>
x
</button>
)}
</div>
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/components/nodes/CacheNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";

import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";

interface InfraNodeData {
name: string;
technology?: string;
description?: string;
ref?: string;
}

export default function CacheNode({ data }: NodeProps) {
const d = data as unknown as InfraNodeData;
const refLabel = d.ref
? d.ref.includes("?ref=") || (d.ref.includes("/") && !d.ref.startsWith("./"))
? d.ref.replace(/^([^/]+)\/.*\?ref=(.+)$/, "$1@$2")
: d.ref
: null;

return (
<>
<Handle type="target" position={Position.Top} id="target-top" />
<Handle type="target" position={Position.Left} id="target-left" />
<Handle type="target" position={Position.Right} id="target-right" />
<Handle type="target" position={Position.Bottom} id="target-bottom" />
<div className="bg-gray-900 border border-amber-700 rounded-lg p-3 min-w-[160px] max-w-[220px] shadow-lg">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs bg-amber-900 text-amber-300 px-1.5 py-0.5 rounded font-mono">CACHE</span>
<span className="text-white font-semibold text-sm truncate">{d.name}</span>
</div>
{d.technology && (
<p className="text-xs text-amber-400 truncate">{d.technology}</p>
)}
{d.description && (
<p className="text-xs text-gray-400 mt-1 line-clamp-2">{d.description}</p>
)}
{refLabel && (
<p className="text-xs text-gray-600 mt-1 truncate" title={d.ref}>{refLabel}</p>
)}
</div>
<Handle type="source" position={Position.Top} id="source-top" />
<Handle type="source" position={Position.Left} id="source-left" />
<Handle type="source" position={Position.Right} id="source-right" />
<Handle type="source" position={Position.Bottom} id="source-bottom" />
</>
);
}
10 changes: 8 additions & 2 deletions apps/web/src/components/nodes/DataTypeNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export default function DataTypeNode({ data }: NodeProps) {

return (
<>
<Handle type="target" position={Position.Top} />
<Handle type="target" position={Position.Top} id="target-top" />
<Handle type="target" position={Position.Left} id="target-left" />
<Handle type="target" position={Position.Right} id="target-right" />
<Handle type="target" position={Position.Bottom} id="target-bottom" />
<div className="bg-gray-900 border border-purple-800 rounded-lg p-3 min-w-[160px] max-w-[220px] shadow-lg">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs bg-purple-900 text-purple-300 px-1.5 py-0.5 rounded">DTO</span>
Expand All @@ -36,7 +39,10 @@ export default function DataTypeNode({ data }: NodeProps) {
</ul>
)}
</div>
<Handle type="source" position={Position.Bottom} />
<Handle type="source" position={Position.Top} id="source-top" />
<Handle type="source" position={Position.Left} id="source-left" />
<Handle type="source" position={Position.Right} id="source-right" />
<Handle type="source" position={Position.Bottom} id="source-bottom" />
</>
);
}
Loading
Loading