// ═══════════════════════════════════════════════════════════════ // Attack Chain DAG Visualization — vis-network wrapper // Obsidian Control theme // ═══════════════════════════════════════════════════════════════ (function () { "use strict"; // Status color palette matching Obsidian Control var STATUS_COLORS = { completed: { bg: "#16a34a", border: "#12873c", font: "#060a13" }, running: { bg: "#d97706", border: "#b56205", font: "#060a13" }, failed: { bg: "#dc2626", border: "#b91c1c", font: "#ffffff" }, pending: { bg: "#5e7291", border: "#3d506b", font: "#e4eaf4" }, skipped: { bg: "#374151", border: "#1f2937", font: "#e4eaf4" }, }; var EDGE_COLOR = "rgba(94, 114, 145, 0.5)"; var network = null; var nodesDataset = null; var edgesDataset = null; var rawNodesMap = {}; function getStatusColor(status) { return STATUS_COLORS[status] || STATUS_COLORS.pending; } function truncate(str, maxLen) { if (!str) return ""; return str.length > maxLen ? str.substring(0, maxLen) + "…" : str; } function buildTooltip(node) { var lines = []; lines.push("Tool: " + (node.tool_name || "unknown")); lines.push("Status: " + (node.status || "pending")); if (node.llm_reasoning) { lines.push("Reasoning: " + truncate(node.llm_reasoning, 200)); } var findingsCount = node.findings_produced ? node.findings_produced.length : 0; lines.push("Findings: " + findingsCount); lines.push("Risk: " + (node.risk_score != null ? node.risk_score : "N/A")); return lines.join("\n"); } function toVisNode(node) { var color = getStatusColor(node.status); // Scale node size by risk_score: min 12, max 40 var risk = typeof node.risk_score === "number" ? node.risk_score : 0; var size = Math.max(12, Math.min(40, 12 + (risk / 100) * 28)); return { id: node.node_id, label: node.tool_name || "unknown", title: buildTooltip(node), size: size, color: { background: color.bg, border: color.border, highlight: { background: color.bg, border: "#ffffff" }, hover: { background: color.bg, border: "#ffffff" }, }, font: { color: color.font, size: 11, face: "'JetBrains Mono', monospace", strokeWidth: 2, strokeColor: "#060a13", }, borderWidth: 1, borderWidthSelected: 3, shape: "dot", _raw: node, }; } function buildEdges(nodes) { var edges = []; var seen = {}; nodes.forEach(function (node) { if (!node.parent_node_ids) return; node.parent_node_ids.forEach(function (parentId) { var key = parentId + "|" + node.node_id; if (seen[key]) return; seen[key] = true; edges.push({ from: parentId, to: node.node_id, color: { color: EDGE_COLOR, highlight: "#ffffff", hover: EDGE_COLOR, }, width: 2, arrows: { to: { enabled: true, scaleFactor: 0.5 }, }, smooth: { enabled: true, type: "cubicBezier", roundness: 0.5, forceDirection: "vertical", }, }); }); }); return edges; } /** * Load and render an attack chain DAG. * Called from Rust via eval(). * @param {Array} nodes - Array of AttackChainNode objects */ window.__loadAttackChain = function (nodes) { var container = document.getElementById("attack-chain-canvas"); if (!container) { console.error("[attack-chain-viz] #attack-chain-canvas not found"); return; } // Build lookup map rawNodesMap = {}; nodes.forEach(function (n) { rawNodesMap[n.node_id] = n; }); var visNodes = nodes.map(toVisNode); var visEdges = buildEdges(nodes); nodesDataset = new vis.DataSet(visNodes); edgesDataset = new vis.DataSet(visEdges); var options = { nodes: { font: { color: "#e4eaf4", size: 11 }, scaling: { min: 12, max: 40 }, }, edges: { font: { color: "#5e7291", size: 9, strokeWidth: 0 }, selectionWidth: 3, }, physics: { enabled: false, }, layout: { hierarchical: { enabled: true, direction: "UD", sortMethod: "directed", levelSeparation: 120, nodeSpacing: 160, treeSpacing: 200, blockShifting: true, edgeMinimization: true, parentCentralization: true, }, }, interaction: { hover: true, tooltipDelay: 200, hideEdgesOnDrag: false, hideEdgesOnZoom: false, multiselect: false, navigationButtons: false, keyboard: { enabled: true }, }, }; // Destroy previous instance if (network) { network.destroy(); } network = new vis.Network( container, { nodes: nodesDataset, edges: edgesDataset }, options ); // Click handler — sends data to Rust network.on("click", function (params) { if (params.nodes.length > 0) { var nodeId = params.nodes[0]; var visNode = nodesDataset.get(nodeId); if (visNode && visNode._raw && window.__onAttackNodeClick) { window.__onAttackNodeClick(JSON.stringify(visNode._raw)); } } }); console.log( "[attack-chain-viz] Loaded " + nodes.length + " nodes, " + visEdges.length + " edges" ); }; /** * Callback placeholder for Rust to set. * Called with JSON string of the clicked node's data. */ window.__onAttackNodeClick = null; /** * Fit entire attack chain DAG in view. */ window.__fitAttackChain = function () { if (!network) return; network.fit({ animation: { duration: 400, easingFunction: "easeInOutQuad" }, }); }; /** * Select and focus on a specific node by node_id. */ window.__highlightAttackNode = function (nodeId) { if (!network || !nodesDataset) return; var node = nodesDataset.get(nodeId); if (!node) return; network.selectNodes([nodeId]); network.focus(nodeId, { scale: 1.5, animation: { duration: 500, easingFunction: "easeInOutQuad" }, }); // Trigger click callback too if (node._raw && window.__onAttackNodeClick) { window.__onAttackNodeClick(JSON.stringify(node._raw)); } }; })();