Compare commits

...

10 Commits

Author SHA1 Message Date
Pablo F.G
af054f0a40 refactor(ui): replace D3 graph rendering with React Flow
- Rewrite attack-path-graph.tsx from D3 imperative SVG to React Flow declarative components
- Add pure layoutWithDagre() function using @dagrejs/dagre maintained fork
- Create custom node components: FindingNode (hexagon), ResourceNode (pill), InternetNode (globe)
- Implement outer/inner component split (ReactFlowProvider constraint)
- Disable export button temporarily (re-enabled in PR3 with html-to-image)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:28:23 +02:00
Pablo F.G
4c16f2182e refactor(ui): move layout derivation into GraphCanvas inner component
Aligns with design decision D4 — GraphCanvas now owns layoutWithDagre()
and node enrichment (selection, hasFindings), making AttackPathGraph a
thin wrapper around ReactFlowProvider. This prepares PR2 where
GraphCanvas needs setEdges() ownership for hover highlight.

Also removes unused AttackPathGraphRef deprecated alias and isFilteredView
prop from outer component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:12:52 +02:00
Pablo F.G
cb44d9fef1 chore: fix openspec submodule reference after upstream rewrite 2026-04-15 14:54:11 +02:00
Pablo F.G
105bc5fe27 chore: update openspec with PR0 task completion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:07:59 +02:00
Pablo F.G
9b4667bfac fix(ui): add key to Suspense in navbar to prevent stale fallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:07:44 +02:00
Pablo F.G
a443350d8b refactor(ui): normalize graph edge types and remove dead code
- Narrow GraphEdge.source/target from string | object to string
- Remove typeof guards in adapter, graph component, and utilities
- Remove unused panX, panY, zoomLevel fields from graph state store
- Delete orphaned NodeRelationships component (never integrated)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:47:51 +02:00
Pablo F.G
5c981f5683 chore: update openspec with restructured expect-cli tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:49:47 +02:00
Pablo F.G
9922b15391 chore: update openspec with expect-cli validation tasks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:43:48 +02:00
Pablo F.G
6e77abea01 chore: update openspec submodule with react-flow-migration proposal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:31:24 +02:00
Pablo F.G
4d57f3bef1 chore: add prowler-openspec-opensource as git submodule
Adds the openspec repository as a submodule at openspec/ for shared
spec definitions used by SDD tooling across AI coding assistants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:56:09 +02:00
20 changed files with 822 additions and 1389 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "openspec"]
path = openspec
url = https://github.com/prowler-cloud/prowler-openspec-opensource.git

1
openspec Submodule

Submodule openspec added at 8f37437e74

View File

@@ -131,27 +131,16 @@ export function adaptQueryResultToGraphData(
// Populate findings and resources based on HAS_FINDING edges
edges.forEach((edge) => {
if (edge.type === "HAS_FINDING") {
const sourceId =
typeof edge.source === "string"
? edge.source
: (edge.source as { id?: string })?.id;
const targetId =
typeof edge.target === "string"
? edge.target
: (edge.target as { id?: string })?.id;
// Add finding to source node (resource -> finding)
const sourceNode = normalizedNodes.find((n) => n.id === edge.source);
if (sourceNode) {
sourceNode.findings.push(edge.target);
}
if (sourceId && targetId) {
// Add finding to source node (resource -> finding)
const sourceNode = normalizedNodes.find((n) => n.id === sourceId);
if (sourceNode) {
sourceNode.findings.push(targetId);
}
// Add resource to target node (finding <- resource)
const targetNode = normalizedNodes.find((n) => n.id === targetId);
if (targetNode) {
targetNode.resources.push(sourceId);
}
// Add resource to target node (finding <- resource)
const targetNode = normalizedNodes.find((n) => n.id === edge.target);
if (targetNode) {
targetNode.resources.push(edge.source);
}
}
});

View File

@@ -14,7 +14,7 @@ interface GraphControlsProps {
onZoomIn: () => void;
onZoomOut: () => void;
onFitToScreen: () => void;
onExport: () => void;
onExport?: () => void;
}
/**
@@ -38,6 +38,7 @@ export const GraphControls = ({
size="sm"
onClick={onZoomIn}
className="h-8 w-8 p-0"
aria-label="Zoom in"
>
<ZoomIn size={18} />
</Button>
@@ -52,6 +53,7 @@ export const GraphControls = ({
size="sm"
onClick={onZoomOut}
className="h-8 w-8 p-0"
aria-label="Zoom out"
>
<ZoomOut size={18} />
</Button>
@@ -66,6 +68,7 @@ export const GraphControls = ({
size="sm"
onClick={onFitToScreen}
className="h-8 w-8 p-0"
aria-label="Fit graph to view"
>
<Minimize2 size={18} />
</Button>
@@ -79,12 +82,16 @@ export const GraphControls = ({
variant="ghost"
size="sm"
onClick={onExport}
disabled={!onExport}
className="h-8 w-8 p-0"
aria-label={onExport ? "Export graph" : "Export available soon"}
>
<Download size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>Export graph</TooltipContent>
<TooltipContent>
{onExport ? "Export graph" : "Export available soon"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>

View File

@@ -1,4 +1,4 @@
export type { AttackPathGraphRef } from "./attack-path-graph";
export type { GraphHandle } from "./attack-path-graph";
export { AttackPathGraph } from "./attack-path-graph";
export { GraphControls } from "./graph-controls";
export { GraphLegend } from "./graph-legend";

View File

@@ -0,0 +1,86 @@
"use client";
import { Handle, type NodeProps, Position } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import {
getNodeBorderColor,
getNodeColor,
GRAPH_EDGE_HIGHLIGHT_COLOR,
} from "../../../_lib";
interface FindingNodeData {
graphNode: GraphNode;
[key: string]: unknown;
}
const HEXAGON_WIDTH = 200;
const HEXAGON_HEIGHT = 55;
export const FindingNode = ({ data, selected }: NodeProps) => {
const { graphNode } = data as FindingNodeData;
const fillColor = getNodeColor(graphNode.labels, graphNode.properties);
const borderColor = selected
? GRAPH_EDGE_HIGHLIGHT_COLOR
: getNodeBorderColor(graphNode.labels, graphNode.properties);
const title = String(
graphNode.properties?.check_title ||
graphNode.properties?.name ||
graphNode.properties?.id ||
"Finding",
);
const maxChars = 24;
const displayTitle =
title.length > maxChars ? title.substring(0, maxChars) + "..." : title;
// Hexagon SVG path
const w = HEXAGON_WIDTH;
const h = HEXAGON_HEIGHT;
const sideInset = w * 0.15;
const hexPath = `
M ${sideInset} 0
L ${w - sideInset} 0
L ${w} ${h / 2}
L ${w - sideInset} ${h}
L ${sideInset} ${h}
L 0 ${h / 2}
Z
`;
return (
<>
<Handle type="target" position={Position.Left} className="invisible" />
<svg
width={w}
height={h}
className="overflow-visible"
style={{ filter: selected ? undefined : "url(#glow)" }}
>
<path
d={hexPath}
fill={fillColor}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={selected ? 4 : 2}
className={selected ? "selected-node" : undefined}
/>
<text
x={w / 2}
y={h / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
fontSize="11px"
fontWeight="600"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayTitle}
</text>
</svg>
<Handle type="source" position={Position.Right} className="invisible" />
</>
);
};

View File

@@ -0,0 +1,84 @@
"use client";
import { Handle, type NodeProps, Position } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import {
getNodeBorderColor,
getNodeColor,
GRAPH_EDGE_HIGHLIGHT_COLOR,
} from "../../../_lib";
interface InternetNodeData {
graphNode: GraphNode;
[key: string]: unknown;
}
const RADIUS = 40; // NODE_HEIGHT * 0.8
const DIAMETER = RADIUS * 2;
export const InternetNode = ({ data, selected }: NodeProps) => {
const { graphNode } = data as InternetNodeData;
const fillColor = getNodeColor(graphNode.labels, graphNode.properties);
const borderColor = selected
? GRAPH_EDGE_HIGHLIGHT_COLOR
: getNodeBorderColor(graphNode.labels, graphNode.properties);
const strokeWidth = selected ? 4 : 1.5;
return (
<>
<Handle type="target" position={Position.Left} className="invisible" />
<svg width={DIAMETER} height={DIAMETER} className="overflow-visible">
{/* Main circle */}
<circle
cx={RADIUS}
cy={RADIUS}
r={RADIUS}
fill={fillColor}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={strokeWidth}
className={selected ? "selected-node" : undefined}
/>
{/* Horizontal ellipse (equator) */}
<ellipse
cx={RADIUS}
cy={RADIUS}
rx={RADIUS}
ry={RADIUS * 0.35}
fill="none"
stroke={borderColor}
strokeWidth={1}
strokeOpacity={0.5}
/>
{/* Vertical ellipse (meridian) */}
<ellipse
cx={RADIUS}
cy={RADIUS}
rx={RADIUS * 0.35}
ry={RADIUS}
fill="none"
stroke={borderColor}
strokeWidth={1}
strokeOpacity={0.5}
/>
{/* Label */}
<text
x={RADIUS}
y={RADIUS}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
fontSize="11px"
fontWeight="600"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
Internet
</text>
</svg>
<Handle type="source" position={Position.Right} className="invisible" />
</>
);
};

View File

@@ -0,0 +1,102 @@
"use client";
import { Handle, type NodeProps, Position } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import {
getNodeBorderColor,
getNodeColor,
GRAPH_ALERT_BORDER_COLOR,
GRAPH_EDGE_HIGHLIGHT_COLOR,
} from "../../../_lib";
import { formatNodeLabel } from "../../../_lib/format";
interface ResourceNodeData {
graphNode: GraphNode;
hasFindings?: boolean;
[key: string]: unknown;
}
const NODE_WIDTH = 180;
const NODE_HEIGHT = 50;
const NODE_RADIUS = 25;
export const ResourceNode = ({ data, selected }: NodeProps) => {
const { graphNode, hasFindings } = data as ResourceNodeData;
const fillColor = getNodeColor(graphNode.labels, graphNode.properties);
const defaultBorder = getNodeBorderColor(
graphNode.labels,
graphNode.properties,
);
const borderColor = hasFindings
? GRAPH_ALERT_BORDER_COLOR
: selected
? GRAPH_EDGE_HIGHLIGHT_COLOR
: defaultBorder;
const strokeWidth = selected ? 4 : hasFindings ? 2.5 : 1.5;
const name = String(
graphNode.properties?.name ||
graphNode.properties?.id ||
(graphNode.labels.length > 0
? formatNodeLabel(graphNode.labels[0])
: "Unknown"),
);
const maxChars = 22;
const displayName =
name.length > maxChars ? name.substring(0, maxChars) + "..." : name;
const typeLabel =
graphNode.labels.length > 0 ? formatNodeLabel(graphNode.labels[0]) : "";
return (
<>
<Handle type="target" position={Position.Left} className="invisible" />
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
<rect
x={0}
y={0}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx={NODE_RADIUS}
ry={NODE_RADIUS}
fill={fillColor}
fillOpacity={0.85}
stroke={borderColor}
strokeWidth={strokeWidth}
className={selected ? "selected-node" : undefined}
/>
<text
x={NODE_WIDTH / 2}
y={NODE_HEIGHT / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
<tspan
x={NODE_WIDTH / 2}
dy="-0.3em"
fontSize="11px"
fontWeight="600"
>
{displayName}
</tspan>
{typeLabel && (
<tspan
x={NODE_WIDTH / 2}
dy="1.3em"
fontSize="9px"
fill="rgba(255,255,255,0.8)"
>
{typeLabel}
</tspan>
)}
</text>
</svg>
<Handle type="source" position={Position.Right} className="invisible" />
</>
);
};

View File

@@ -1,4 +1,3 @@
export { NodeDetailContent, NodeDetailPanel } from "./node-detail-panel";
export { NodeOverview } from "./node-overview";
export { NodeRelationships } from "./node-relationships";
export { NodeRemediation } from "./node-remediation";

View File

@@ -1,105 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import type { GraphEdge } from "@/types/attack-paths";
interface NodeRelationshipsProps {
incomingEdges: GraphEdge[];
outgoingEdges: GraphEdge[];
}
/**
* Format edge type to human-readable label
* e.g., "HAS_FINDING" -> "Has Finding"
*/
function formatEdgeType(edgeType: string): string {
return edgeType
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
interface EdgeItemProps {
edge: GraphEdge;
isOutgoing: boolean;
}
/**
* Reusable edge item component
*/
function EdgeItem({ edge, isOutgoing }: EdgeItemProps) {
const targetId =
typeof edge.target === "string" ? edge.target : String(edge.target);
const sourceId =
typeof edge.source === "string" ? edge.source : String(edge.source);
const displayId = (isOutgoing ? targetId : sourceId).substring(0, 30);
return (
<div
key={edge.id}
className="border-border-neutral-tertiary dark:border-border-neutral-tertiary flex items-center justify-between rounded border p-2"
>
<code className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
{displayId}
</code>
<span
className={cn(
"rounded px-2 py-1 text-xs font-medium",
isOutgoing
? "bg-bg-data-info text-text-neutral-primary dark:text-text-neutral-primary"
: "bg-bg-pass-primary text-text-neutral-primary dark:text-text-neutral-primary",
)}
>
{formatEdgeType(edge.type)}
</span>
</div>
);
}
/**
* Node relationships section showing incoming and outgoing edges
*/
export const NodeRelationships = ({
incomingEdges,
outgoingEdges,
}: NodeRelationshipsProps) => {
return (
<div className="flex flex-col gap-6">
{/* Outgoing Relationships */}
<div>
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
Outgoing Relationships ({outgoingEdges.length})
</h4>
{outgoingEdges.length > 0 ? (
<div className="space-y-2">
{outgoingEdges.map((edge) => (
<EdgeItem key={edge.id} edge={edge} isOutgoing />
))}
</div>
) : (
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
No outgoing relationships
</p>
)}
</div>
{/* Incoming Relationships */}
<div className="border-border-neutral-tertiary dark:border-border-neutral-tertiary border-t pt-6">
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
Incoming Relationships ({incomingEdges.length})
</h4>
{incomingEdges.length > 0 ? (
<div className="space-y-2">
{incomingEdges.map((edge) => (
<EdgeItem key={edge.id} edge={edge} isOutgoing={false} />
))}
</div>
) : (
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
No incoming relationships
</p>
)}
</div>
</div>
);
};

View File

@@ -21,8 +21,6 @@ interface GraphStore extends GraphState, FilteredViewState {
setSelectedNodeId: (nodeId: string | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setZoom: (zoomLevel: number) => void;
setPan: (panX: number, panY: number) => void;
setFilteredView: (
isFiltered: boolean,
nodeId: string | null,
@@ -37,9 +35,6 @@ const initialState: GraphState & FilteredViewState = {
selectedNodeId: null,
loading: false,
error: null,
zoomLevel: 1,
panX: 0,
panY: 0,
isFilteredView: false,
filteredNodeId: null,
fullData: null,
@@ -58,8 +53,6 @@ const useGraphStore = create<GraphStore>((set) => ({
setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
setZoom: (zoomLevel) => set({ zoomLevel }),
setPan: (panX, panY) => set({ panX, panY }),
setFilteredView: (isFiltered, nodeId, filteredData, fullData) =>
set({
isFilteredView: isFiltered,
@@ -106,11 +99,6 @@ export const useGraphState = () => {
store.setError(error);
};
const updateZoomAndPan = (zoomLevel: number, panX: number, panY: number) => {
store.setZoom(zoomLevel);
store.setPan(panX, panY);
};
const resetGraph = () => {
store.reset();
};
@@ -162,9 +150,6 @@ export const useGraphState = () => {
selectedNode: getSelectedNode(),
loading: store.loading,
error: store.error,
zoomLevel: store.zoomLevel,
panX: store.panX,
panY: store.panY,
isFilteredView: store.isFilteredView,
filteredNodeId: store.filteredNodeId,
filteredNode: getFilteredNode(),
@@ -173,7 +158,6 @@ export const useGraphState = () => {
startLoading,
stopLoading,
setError,
updateZoomAndPan,
resetGraph,
clearGraph,
enterFilteredView,

View File

@@ -4,23 +4,6 @@
import type { AttackPathGraphData } from "@/types/attack-paths";
/**
* Type for edge node reference - can be a string ID or an object with id property
* Note: We use `object` to match GraphEdge type from attack-paths.ts
*/
export type EdgeNodeRef = string | object;
/**
* Helper to get edge source/target ID from string or object
*/
export const getEdgeNodeId = (nodeRef: EdgeNodeRef): string => {
if (typeof nodeRef === "string") {
return nodeRef;
}
// Edge node references are objects with an id property
return (nodeRef as { id: string }).id;
};
/**
* Compute a filtered subgraph containing only the path through the target node.
* This follows the directed graph structure of attack paths:
@@ -44,10 +27,8 @@ export const computeFilteredSubgraph = (
});
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
forwardEdges.get(sourceId)?.add(targetId);
backwardEdges.get(targetId)?.add(sourceId);
forwardEdges.get(edge.source)?.add(edge.target);
backwardEdges.get(edge.target)?.add(edge.source);
});
const visibleNodeIds = new Set<string>();
@@ -85,10 +66,8 @@ export const computeFilteredSubgraph = (
// Also include findings directly connected to the selected node
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
const sourceNode = nodes.find((n) => n.id === sourceId);
const targetNode = nodes.find((n) => n.id === targetId);
const sourceNode = nodes.find((n) => n.id === edge.source);
const targetNode = nodes.find((n) => n.id === edge.target);
const sourceIsFinding = sourceNode?.labels.some((l) =>
l.toLowerCase().includes("finding"),
@@ -98,21 +77,20 @@ export const computeFilteredSubgraph = (
);
// Include findings connected to the selected node
if (sourceId === targetNodeId && targetIsFinding) {
visibleNodeIds.add(targetId);
if (edge.source === targetNodeId && targetIsFinding) {
visibleNodeIds.add(edge.target);
}
if (targetId === targetNodeId && sourceIsFinding) {
visibleNodeIds.add(sourceId);
if (edge.target === targetNodeId && sourceIsFinding) {
visibleNodeIds.add(edge.source);
}
});
// Filter nodes and edges to only include visible ones
const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id));
const filteredEdges = edges.filter((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
});
const filteredEdges = edges.filter(
(edge) =>
visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target),
);
return {
nodes: filteredNodes,

View File

@@ -15,9 +15,5 @@ export {
GRAPH_NODE_COLORS,
GRAPH_SELECTION_COLOR,
} from "./graph-colors";
export {
computeFilteredSubgraph,
type EdgeNodeRef,
getEdgeNodeId,
getPathEdges,
} from "./graph-utils";
export { computeFilteredSubgraph, getPathEdges } from "./graph-utils";
export { layoutWithDagre } from "./layout";

View File

@@ -0,0 +1,165 @@
/**
* Pure Dagre layout adapter for React Flow
* Converts normalized GraphNode[] + GraphEdge[] to positioned RF nodes
*
* Note: Uses dynamic import of @dagrejs/dagre to avoid type conflicts
* with @types/dagre (which will be removed in PR3 when old dagre is removed).
*/
const dagreModule = require("@dagrejs/dagre");
const DagreGraph = dagreModule.Graph as new () => {
setGraph: (opts: Record<string, unknown>) => void;
setDefaultEdgeLabel: (fn: () => Record<string, unknown>) => void;
setNode: (id: string, label: Record<string, unknown>) => void;
setEdge: (
source: string,
target: string,
label: Record<string, unknown>,
) => void;
node: (id: string) => { x: number; y: number };
edges: () => Array<{ v: string; w: string }>;
edge: (e: { v: string; w: string }) => Record<string, unknown>;
};
const dagreLayout = dagreModule.layout as (g: unknown) => void;
import type { Edge, Node } from "@xyflow/react";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
// Node dimensions matching the original D3 implementation
const NODE_WIDTH = 180;
const NODE_HEIGHT = 50;
const HEXAGON_WIDTH = 200;
const HEXAGON_HEIGHT = 55;
const INTERNET_DIAMETER = 80; // NODE_HEIGHT * 0.8 * 2
// Container relationships that get reversed for proper hierarchy
const CONTAINER_RELATIONS = new Set([
"RUNS_IN",
"BELONGS_TO",
"LOCATED_IN",
"PART_OF",
]);
interface NodeData extends Record<string, unknown> {
graphNode: GraphNode;
}
const NODE_TYPE = {
FINDING: "finding",
INTERNET: "internet",
RESOURCE: "resource",
} as const;
type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE];
const getNodeType = (labels: string[]): NodeType => {
if (labels.some((l) => l.toLowerCase().includes("finding")))
return NODE_TYPE.FINDING;
if (labels.some((l) => l.toLowerCase() === "internet"))
return NODE_TYPE.INTERNET;
return NODE_TYPE.RESOURCE;
};
const getNodeDimensions = (
type: NodeType,
): { width: number; height: number } => {
if (type === NODE_TYPE.FINDING)
return { width: HEXAGON_WIDTH, height: HEXAGON_HEIGHT };
if (type === NODE_TYPE.INTERNET)
return { width: INTERNET_DIAMETER, height: INTERNET_DIAMETER };
return { width: NODE_WIDTH, height: NODE_HEIGHT };
};
/**
* Pure layout function: computes positioned React Flow nodes from graph data.
* Deterministic — same inputs always produce same outputs.
*/
export const layoutWithDagre = (
nodes: GraphNode[],
edges: GraphEdge[],
): { rfNodes: Node<NodeData>[]; rfEdges: Edge[] } => {
const g = new DagreGraph();
g.setGraph({
rankdir: "LR",
nodesep: 80,
ranksep: 150,
marginx: 50,
marginy: 50,
});
g.setDefaultEdgeLabel(() => ({}));
// Add nodes with type-based dimensions
nodes.forEach((node) => {
const type = getNodeType(node.labels);
const { width, height } = getNodeDimensions(type);
g.setNode(node.id, { label: node.id, width, height });
});
// Add edges, reversing container relationships for proper hierarchy
edges.forEach((edge) => {
let sourceId = edge.source;
let targetId = edge.target;
if (CONTAINER_RELATIONS.has(edge.type)) {
[sourceId, targetId] = [targetId, sourceId];
}
if (sourceId && targetId) {
g.setEdge(sourceId, targetId, {
originalSource: edge.source,
originalTarget: edge.target,
});
}
});
dagreLayout(g);
// Build RF nodes from layout
const rfNodes: Node<NodeData>[] = nodes.map((node) => {
const dagreNode = g.node(node.id);
const type = getNodeType(node.labels);
const { width, height } = getNodeDimensions(type);
return {
id: node.id,
type,
position: {
x: dagreNode.x - width / 2,
y: dagreNode.y - height / 2,
},
data: { graphNode: node },
width,
height,
};
});
// Build RF edges from dagre edges (using layout order, not original)
const rfEdges: Edge[] = g.edges().map((e: { v: string; w: string }) => {
const edgeData = g.edge(e) as {
originalSource: string;
originalTarget: string;
};
// Check if either end is a finding node
const sourceNode = nodes.find((n) => n.id === e.v);
const targetNode = nodes.find((n) => n.id === e.w);
const hasFinding =
sourceNode?.labels.some((l) => l.toLowerCase().includes("finding")) ||
targetNode?.labels.some((l) => l.toLowerCase().includes("finding"));
return {
id: `${e.v}-${e.w}`,
source: e.v,
target: e.w,
animated: hasFinding,
className: hasFinding ? "finding-edge" : "resource-edge",
data: {
originalSource: edgeData.originalSource,
originalTarget: edgeData.originalTarget,
},
};
});
return { rfNodes, rfEdges };
};

View File

@@ -52,10 +52,21 @@ import {
QuerySelector,
ScanListTable,
} from "./_components";
import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph";
import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
import { exportGraphAsSVG, formatNodeLabel } from "./_lib";
import { formatNodeLabel } from "./_lib";
const getNodeDisplayTitle = (node: GraphNode): string => {
const isFinding = node.labels.some((l) =>
l.toLowerCase().includes("finding"),
);
return String(
isFinding
? node.properties?.check_title || node.properties?.id || "Unknown Finding"
: node.properties?.name || node.properties?.id || "Unknown Resource",
);
};
/**
* Attack Paths
@@ -72,8 +83,8 @@ export default function AttackPathsPage() {
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const graphRef = useRef<AttackPathGraphRef>(null);
const fullscreenGraphRef = useRef<AttackPathGraphRef>(null);
const graphRef = useRef<GraphHandle>(null);
const fullscreenGraphRef = useRef<GraphHandle>(null);
const hasResetRef = useRef(false);
const nodeDetailsRef = useRef<HTMLDivElement>(null);
const graphContainerRef = useRef<HTMLDivElement>(null);
@@ -304,28 +315,6 @@ export default function AttackPathsPage() {
graphState.selectNode(null);
};
const handleGraphExport = (svgElement: SVGSVGElement | null) => {
try {
if (svgElement) {
exportGraphAsSVG(svgElement, "attack-path-graph.svg");
toast({
title: "Success",
description: "Graph exported as SVG",
variant: "default",
});
} else {
throw new Error("Could not find graph element");
}
} catch (error) {
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Failed to export graph",
variant: "destructive",
});
}
};
return (
<div className="flex flex-col gap-6">
{/* Auto-refresh scans when there's an executing scan */}
@@ -490,11 +479,6 @@ export default function AttackPathsPage() {
onZoomIn={() => graphRef.current?.zoomIn()}
onZoomOut={() => graphRef.current?.zoomOut()}
onFitToScreen={() => graphRef.current?.resetZoom()}
onExport={() =>
handleGraphExport(
graphRef.current?.getSVGElement() || null,
)
}
/>
{/* Fullscreen button */}
@@ -528,28 +512,21 @@ export default function AttackPathsPage() {
onFitToScreen={() =>
fullscreenGraphRef.current?.resetZoom()
}
onExport={() =>
handleGraphExport(
fullscreenGraphRef.current?.getSVGElement() ||
null,
)
}
/>
</div>
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
<div className="flex flex-1 flex-col gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6 lg:flex-row">
<div className="flex flex-1 items-center justify-center">
<AttackPathGraph
ref={fullscreenGraphRef}
data={graphState.data}
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
/>
</div>
{/* Node Detail Panel - Side by side */}
{graphState.selectedNode && (
<section aria-labelledby="node-details-heading">
<Card className="w-96 overflow-y-auto">
<Card className="w-full overflow-y-auto lg:w-96">
<CardContent className="p-4">
<div className="mb-4 flex items-center justify-between">
<h3
@@ -569,22 +546,9 @@ export default function AttackPathsPage() {
</Button>
</div>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mb-4 text-xs">
{graphState.selectedNode?.labels.some(
(label) =>
label
.toLowerCase()
.includes("finding"),
)
? graphState.selectedNode?.properties
?.check_title ||
graphState.selectedNode?.properties
?.id ||
"Unknown Finding"
: graphState.selectedNode?.properties
?.name ||
graphState.selectedNode?.properties
?.id ||
"Unknown Resource"}
{getNodeDisplayTitle(
graphState.selectedNode,
)}
</p>
<div className="flex flex-col gap-4">
<div>
@@ -619,12 +583,11 @@ export default function AttackPathsPage() {
data={graphState.data}
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
/>
</div>
{/* Legend below */}
<div className="hidden justify-center lg:flex">
<div className="flex justify-center overflow-x-auto">
<GraphLegend data={graphState.data} />
</div>
</>
@@ -642,17 +605,7 @@ export default function AttackPathsPage() {
<div className="flex-1">
<h3 className="text-lg font-semibold">Node Details</h3>
<p className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-1 text-sm">
{String(
graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
)
? graphState.selectedNode.properties?.check_title ||
graphState.selectedNode.properties?.id ||
"Unknown Finding"
: graphState.selectedNode.properties?.name ||
graphState.selectedNode.properties?.id ||
"Unknown Resource",
)}
{getNodeDisplayTitle(graphState.selectedNode)}
</p>
</div>
<div className="flex items-center gap-2">

View File

@@ -15,7 +15,7 @@ export function Navbar({ title, icon }: NavbarProps) {
title={title}
icon={icon}
feedsSlot={
<Suspense fallback={<FeedsLoadingFallback />}>
<Suspense key="feeds" fallback={<FeedsLoadingFallback />}>
<FeedsServer limit={15} />
</Suspense>
}

View File

@@ -34,6 +34,7 @@
"@codemirror/language": "6.12.2",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.40.0",
"@dagrejs/dagre": "3.0.0",
"@extractus/feed-extractor": "7.1.7",
"@heroui/react": "2.8.4",
"@hookform/resolvers": "5.2.2",
@@ -75,6 +76,7 @@
"@types/dagre": "0.7.53",
"@types/js-yaml": "4.0.9",
"@uiw/react-codemirror": "4.25.8",
"@xyflow/react": "12.10.2",
"ai": "5.0.109",
"alert": "6.0.2",
"class-variance-authority": "0.7.1",

138
ui/pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
'@codemirror/view':
specifier: 6.40.0
version: 6.40.0
'@dagrejs/dagre':
specifier: 3.0.0
version: 3.0.0
'@extractus/feed-extractor':
specifier: 7.1.7
version: 7.1.7
@@ -176,6 +179,9 @@ importers:
'@uiw/react-codemirror':
specifier: 4.25.8
version: 4.25.8(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@6.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@xyflow/react':
specifier: 12.10.2
version: 12.10.2(@types/react@19.2.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ai:
specifier: 5.0.109
version: 5.0.109(zod@4.1.11)
@@ -993,6 +999,12 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@dagrejs/dagre@3.0.0':
resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==}
'@dagrejs/graphlib@4.0.1':
resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
@@ -1895,155 +1907,183 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@@ -2267,24 +2307,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
@@ -4135,66 +4179,79 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -4663,24 +4720,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -5102,41 +5163,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -5268,6 +5337,15 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
'@xyflow/react@12.10.2':
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@xyflow/system@0.0.76':
resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -5588,6 +5666,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@@ -7291,24 +7372,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -9479,6 +9564,21 @@ packages:
zod@4.1.11:
resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
zustand@5.0.8:
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
engines: {node: '>=12.20.0'}
@@ -10688,6 +10788,12 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@dagrejs/dagre@3.0.0':
dependencies:
'@dagrejs/graphlib': 4.0.1
'@dagrejs/graphlib@4.0.1': {}
'@date-fns/tz@1.4.1': {}
'@dotenvx/dotenvx@1.51.4':
@@ -16218,6 +16324,29 @@ snapshots:
'@xtuc/long@4.2.2': {}
'@xyflow/react@12.10.2(@types/react@19.2.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@xyflow/system': 0.0.76
classcat: 5.0.5
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
zustand: 4.5.7(@types/react@19.2.8)(react@19.2.4)
transitivePeerDependencies:
- '@types/react'
- immer
'@xyflow/system@0.0.76':
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -16562,6 +16691,8 @@ snapshots:
dependencies:
clsx: 2.1.1
classcat@5.0.5: {}
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
@@ -21146,6 +21277,13 @@ snapshots:
zod@4.1.11: {}
zustand@4.5.7(@types/react@19.2.8)(react@19.2.4):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.8
react: 19.2.4
zustand@5.0.8(@types/react@19.2.8)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
optionalDependencies:
'@types/react': 19.2.8

View File

@@ -180,8 +180,8 @@ export interface GraphNode {
export interface GraphEdge {
id: string;
source: string | object;
target: string | object;
source: string;
target: string;
type: string;
properties?: GraphNodeProperties;
}
@@ -268,9 +268,6 @@ export interface GraphState {
selectedNodeId: string | null;
loading: boolean;
error: string | null;
zoomLevel: number;
panX: number;
panY: number;
}
// Provider Integration