// graph.js — Progressive dependency graph visualization // Uses dagre (layout) + panzoom (pan/zoom) from CDN // Appended to index.html as separate script — reads globals: allInstances, rawDetail, getToken, getDeckEnv (function () { "use strict"; // ── Constants ──────────────────────────────────────────────────────────── var NODE_W = 160; var NODE_H = 52; var REALM_W = 180; var REALM_H = 44; var RANK_SEP = 90; var NODE_SEP = 36; // Color palette per svc keyword (substring match, case-insensitive) var SVC_COLORS = [ ["s3", "#1d4e6e", "#38bdf8"], ["postgresql", "#1a3d2b", "#4ade80"], ["postgres", "#1a3d2b", "#4ade80"], ["redis", "#4a1c1c", "#f87171"], ["kubernetes", "#1a2e4a", "#60a5fa"], ["штурвал", "#1a2e4a", "#60a5fa"], ["rabbitmq", "#3b2000", "#fb923c"], ["nodejs", "#1a3b20", "#86efac"], ["flask", "#1a3b20", "#86efac"], ["lucee", "#2d1b4a", "#c084fc"], ["mongodb", "#1a3a1a", "#4ade80"], ["grafana", "#2d1000", "#fdba74"], ["pgadmin", "#1a3d2b", "#6ee7b7"], ["edge", "#1e293b", "#94a3b8"], ["vdc", "#1e293b", "#94a3b8"], ["vapp", "#1e293b", "#94a3b8"], ["cloud direct","#0c2340", "#93c5fd"], ["dns", "#1a2040", "#818cf8"], ["ip", "#1e293b", "#cbd5e1"], ["http", "#1a2e1a", "#bbf7d0"], ["nodered", "#3b1500", "#fdba74"] ]; // Edge color by source svc keyword var EDGE_COLORS = [ ["s3", "#38bdf8"], ["postgresql", "#4ade80"], ["postgres", "#4ade80"], ["redis", "#f87171"], ["kubernetes", "#60a5fa"], ["штурвал", "#60a5fa"], ["rabbitmq", "#fb923c"], ["nodejs", "#86efac"], ["flask", "#86efac"], ["lucee", "#c084fc"], ["dns", "#818cf8"], ["edge", "#94a3b8"] ]; function svcStyle(svcStr) { var s = (svcStr || "").toLowerCase(); for (var i = 0; i < SVC_COLORS.length; i++) { if (s.indexOf(SVC_COLORS[i][0]) >= 0) { return { bg: SVC_COLORS[i][1], border: SVC_COLORS[i][2] }; } } return { bg: "#1e293b", border: "#475569" }; } function edgeColor(svcStr) { var s = (svcStr || "").toLowerCase(); for (var i = 0; i < EDGE_COLORS.length; i++) { if (s.indexOf(EDGE_COLORS[i][0]) >= 0) return EDGE_COLORS[i][1]; } return "#475569"; } // ── State ──────────────────────────────────────────────────────────────── var graphCache = null; // built SVG string, null = not built yet var building = false; // loading in progress flag // ── DOM helpers ────────────────────────────────────────────────────────── function getOverlay() { return document.getElementById("graph-overlay"); } function getCanvas() { return document.getElementById("graph-canvas"); } function getStatus() { return document.getElementById("graph-status"); } // ── Open / Close ───────────────────────────────────────────────────────── function openGraph() { var overlay = getOverlay(); if (!overlay) { console.error("[graph] #graph-overlay not found"); return; } overlay.style.display = "flex"; if (graphCache) { showCached(); } else if (!building) { buildGraph(); } } function closeGraph() { var overlay = getOverlay(); if (overlay) overlay.style.display = "none"; } function refreshGraph() { graphCache = null; building = false; var canvas = getCanvas(); if (canvas) canvas.innerHTML = ""; buildGraph(); } function showCached() { var canvas = getCanvas(); if (!canvas) return; canvas.innerHTML = graphCache; attachPanzoom(); setStatus(""); } function setStatus(msg) { var el = getStatus(); if (el) el.textContent = msg; } // ── Layout helpers ─────────────────────────────────────────────────────── // Simple dagre-based layout using the global dagre object from CDN function layoutGraph(nodes, edges) { var g = new dagre.graphlib.Graph(); g.setGraph({ rankdir: "TB", ranksep: RANK_SEP, nodesep: NODE_SEP, marginx: 40, marginy: 40 }); g.setDefaultEdgeLabel(function () { return {}; }); nodes.forEach(function (n) { g.setNode(n.id, { width: n.w || NODE_W, height: n.h || NODE_H, label: n.id }); }); edges.forEach(function (e) { if (g.hasNode(e.from) && g.hasNode(e.to)) g.setEdge(e.from, e.to); }); dagre.layout(g); return g; } // ── SVG rendering ──────────────────────────────────────────────────────── function escX(v) { return String(v == null ? "" : v) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function truncate(str, maxLen) { if (!str) return ""; return str.length > maxLen ? str.slice(0, maxLen - 1) + "…" : str; } function renderArrowMarker(color, id) { return '' + '' + ''; } function pointsOnEdge(g, fromId, toId) { var edge = g.edge(fromId, toId); if (!edge || !edge.points || edge.points.length < 2) { // fallback: straight line between node centers var nf = g.node(fromId), nt = g.node(toId); if (!nf || !nt) return null; return [{ x: nf.x, y: nf.y + (nf.height || NODE_H) / 2 }, { x: nt.x, y: nt.y - (nt.height || NODE_H) / 2 }]; } return edge.points; } function polyline(pts) { return pts.map(function (p) { return p.x + "," + p.y; }).join(" "); } function renderSVG(g, nodesMeta) { var gw = g.graph().width || 800; var gh = g.graph().height || 600; var svgW = gw + 80; var svgH = gh + 80; // Collect unique edge colors for markers var markerColors = {}; nodesMeta.edges.forEach(function (e) { var c = e.color || "#475569"; markerColors[c] = true; }); var defs = ''; Object.keys(markerColors).forEach(function (c) { var mid = "arr-" + c.replace(/[^a-zA-Z0-9]/g, ""); defs += renderArrowMarker(c, mid); }); defs += ''; var svgParts = [ '', defs ]; // Draw edges first (under nodes) nodesMeta.edges.forEach(function (e) { var pts = pointsOnEdge(g, e.from, e.to); if (!pts) return; var c = e.color || "#475569"; var mid = "arr-" + c.replace(/[^a-zA-Z0-9]/g, ""); svgParts.push( '' ); }); // Draw nodes g.nodes().forEach(function (id) { var pos = g.node(id); if (!pos) return; var meta = nodesMeta.map[id]; if (!meta) return; var x = pos.x - pos.width / 2; var y = pos.y - pos.height / 2; var w = pos.width; var h = pos.height; if (meta.type === "realm") { // Platform node — dashed border, distinct style svgParts.push( '' ); svgParts.push( 'ПЛАТФОРМА' ); svgParts.push( '' + escX(truncate(meta.label, 22)) + '' ); } else { // Instance node var st = svcStyle(meta.svc); svgParts.push( '' ); // Status dot var dotColor = meta.statusColor || "#6e7681"; svgParts.push( '' ); // Name svgParts.push( '' + escX(truncate(meta.label, 18)) + '' ); // Svc svgParts.push( '' + escX(truncate(meta.svc, 22)) + '' ); } }); svgParts.push(''); return svgParts.join("\n"); } // ── Status color helper ────────────────────────────────────────────────── function statusDotColor(status) { var m = { running: "#56d364", suspended: "#e3b341", deleted: "#f85149", deleting: "#f85149", pending: "#58a6ff", creating: "#58a6ff", "not created": "#6e7681" }; return m[(status || "").toLowerCase()] || "#6e7681"; } // ── Main build pipeline ────────────────────────────────────────────────── function buildGraph() { if (building) return; building = true; graphCache = null; var canvas = getCanvas(); if (!canvas) return; canvas.innerHTML = ""; setStatus("Строю схему: загрузка инстансов…"); // Step 1: Use allInstances exposed from IIFE via window._getInstances() var instances = (typeof window._getInstances === "function") ? window._getInstances() : []; if (!instances || !instances.length) { setStatus("Нет данных. Нажмите «Обновить» в таблице, затем откройте схему снова."); building = false; return; } // Step 2: Collect unique realms var realmSet = {}; instances.forEach(function (inst) { var r = inst.resourceRealm; if (r) realmSet[r] = true; }); // Build node list: realm nodes + instance nodes var nodes = []; var nodesMeta = { map: {}, edges: [] }; Object.keys(realmSet).forEach(function (r) { var id = "realm:" + r; nodes.push({ id: id, w: REALM_W, h: REALM_H }); nodesMeta.map[id] = { type: "realm", label: r }; }); instances.forEach(function (inst) { var id = inst.instanceUid; if (!id) return; nodes.push({ id: id, w: NODE_W, h: NODE_H }); nodesMeta.map[id] = { type: "instance", label: inst.displayName || id, svc: inst.svc || "", statusColor: statusDotColor(inst.explainedStatus) }; }); // Step 3: Realm → instance edges (data already available) instances.forEach(function (inst) { var r = inst.resourceRealm; if (r && inst.instanceUid) { nodesMeta.edges.push({ from: "realm:" + r, to: inst.instanceUid, color: "#334155" }); } }); // Step 4: Draw nodes + realm edges immediately setStatus("Строю схему: расстановка узлов…"); var g = layoutGraph(nodes, nodesMeta.edges); var partialSvg = renderSVG(g, nodesMeta); canvas.innerHTML = partialSvg; attachPanzoom(); // Step 5: Load dependencies for each instance one by one var uidList = instances.map(function (i) { return i.instanceUid; }).filter(Boolean); var total = uidList.length; var done = 0; function loadNext(idx) { if (idx >= uidList.length) { // All done — re-render with all edges setStatus("Строю финальную схему…"); var gFinal = layoutGraph(nodes, nodesMeta.edges); var finalSvg = renderSVG(gFinal, nodesMeta); canvas.innerHTML = finalSvg; attachPanzoom(); graphCache = canvas.innerHTML; building = false; setStatus(""); return; } var uid = uidList[idx]; fetch("/dashboard/api/instances/" + uid, { headers: { "X-Deck-Token": localStorage.getItem("deck_token") || "", "X-Deck-Env": localStorage.getItem("deck_env") || "test" } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { done++; setStatus("Загружаю зависимости: " + done + " / " + total); if (data) { var inst = data.instance || data; var deps = inst.dependencies || inst.deps || []; var depInstances = inst.dependentInstances || inst.dependsOn || []; deps.forEach(function (dep) { var toUid = dep.uid || dep.instanceUid || dep.dependencyUid; if (toUid && nodesMeta.map[toUid]) { var c = edgeColor(dep.svc || ""); nodesMeta.edges.push({ from: uid, to: toUid, color: c }); } }); depInstances.forEach(function (dep) { var fromUid = dep.uid || dep.instanceUid || dep.dependencyUid; if (fromUid && nodesMeta.map[fromUid]) { var c = edgeColor(dep.svc || ""); nodesMeta.edges.push({ from: fromUid, to: uid, color: c }); } }); // Re-render SVG incrementally every 10 instances if (done % 10 === 0) { var gNow = layoutGraph(nodes, nodesMeta.edges); canvas.innerHTML = renderSVG(gNow, nodesMeta); attachPanzoom(); } } // Small delay to avoid hammering the API setTimeout(function () { loadNext(idx + 1); }, 30); }) .catch(function () { done++; setTimeout(function () { loadNext(idx + 1); }, 30); }); } loadNext(0); } // ── Panzoom attachment ─────────────────────────────────────────────────── function attachPanzoom() { var svg = document.getElementById("graph-svg"); if (!svg || typeof panzoom === "undefined") return; // Destroy previous instance if any if (svg._panzoom) { try { svg._panzoom.dispose(); } catch (e) {} } svg._panzoom = panzoom(svg, { maxZoom: 4, minZoom: 0.1, smoothScroll: false }); } // ── Wire up after DOM ready ────────────────────────────────────────────── function init() { var btnOpen = document.getElementById("btn-graph-open"); var btnClose = document.getElementById("btn-graph-close"); var btnRefresh = document.getElementById("btn-graph-refresh"); var overlay = document.getElementById("graph-overlay"); if (btnOpen) btnOpen.addEventListener("click", openGraph); if (btnClose) btnClose.addEventListener("click", closeGraph); if (btnRefresh) btnRefresh.addEventListener("click", refreshGraph); if (overlay) overlay.addEventListener("click", function (e) { if (e.target === overlay) closeGraph(); }); // Keyboard Escape document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeGraph(); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } // Expose for manual testing window.graphModule = { open: openGraph, close: closeGraph, refresh: refreshGraph }; })();