From 56c9b0120fb4182739f65ebfadb6e31b2edf5bdc Mon Sep 17 00:00:00 2001 From: Naeel Date: Thu, 16 Apr 2026 18:44:56 +0300 Subject: [PATCH] v9.19: rewrite graph with pure SVG + panzoom (pan/zoom), remove cytoscape/heavy code --- k8s/deployment.yaml | 2 +- static/index.html | 728 +++++++++++++++----------------------------- 2 files changed, 249 insertions(+), 481 deletions(-) diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index e705831..3020997 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -15,7 +15,7 @@ spec: spec: containers: - name: cloud-dashboard - image: naeel/cloud-dashboard:v9.18 + image: naeel/cloud-dashboard:v9.19 imagePullPolicy: Always ports: - containerPort: 8080 diff --git a/static/index.html b/static/index.html index 1c4951c..a303ec0 100644 --- a/static/index.html +++ b/static/index.html @@ -249,6 +249,7 @@ + @@ -266,8 +267,6 @@ var graphAllData = null; var graphRunningData = null; var graphFullData = null; var graphAllKey = ""; -var graphWarmupScheduled = false; -var graphDrawing = false; var graphFetchInFlight = false; var rawDetail = {}; var navBackUid = null; @@ -295,6 +294,53 @@ function statusColor(s){ return m[s] || "#8b949e"; } + +// ─── GRAPH: SVG + panzoom ───────────────────────────────────────────────── + +// Status color for node border +function nodeStatusColor(status){ + var m = { + "running": "#238636", "suspended": "#e3b341", "deleting": "#f85149", + "deleted": "#6e7681", "not created": "#8b949e", "pending": "#a371f7", + "creating": "#1f6feb" + }; + return m[status] || "#8b949e"; +} + +// Service → background color +function nodeBgColor(svc){ + var s = (svc || "").toLowerCase(); + if(/postgres|sql/.test(s)) return "#0c1e36"; + if(/kubernetes|k8s|шт[уy]рвал/.test(s)) return "#0f1e0f"; + if(/s3|object storage|бакет/.test(s)) return "#1a1200"; + if(/edge|шлюз/.test(s)) return "#200c1a"; + if(/vm|виртуальн/.test(s)) return "#1a1014"; + if(/redis/.test(s)) return "#200a0a"; + if(/rabbit/.test(s)) return "#0a1a10"; + if(/dns/.test(s)) return "#0a1220"; + if(/cloud director|vdc|датацентр/.test(s)) return "#001430"; + return "#161b22"; +} + +// Break text into lines fitting maxWidth (chars-based estimate) +function wrapText(text, maxChars){ + var words = text.split(/(?=[_\-])/); // split on _ and - keeping delimiter + var lines = []; + var cur = ""; + for(var i = 0; i < words.length; i++){ + var w = words[i]; + if((cur + w).length > maxChars && cur.length > 0){ + lines.push(cur); + cur = w; + } else { + cur += w; + } + } + if(cur) lines.push(cur); + return lines.length ? lines : [text]; +} + +// Fetch graph data from backend function fetchGraph(params){ if(graphFetchInFlight) return Promise.resolve(null); graphFetchInFlight = true; @@ -317,351 +363,6 @@ function fetchGraph(params){ }); } -function normGraphText(v){ - return String(v || "").trim().toLowerCase(); -} - -function isUserSingletonNode(n){ - var t = [n.label, n.svc, n.id].map(normGraphText).join(" "); - return /cloud director|cloud_director|(^|\W)s3(\W|$)|(^|\W)edge(\W|$)/.test(t); -} - -function isPlatformNode(n, platformNames){ - var label = normGraphText(n.label); - var id = normGraphText(n.id); - var svc = normGraphText(n.svc); - if(platformNames.size > 0){ - if(platformNames.has(label) || platformNames.has(id)) return true; - var matched = false; - platformNames.forEach(function(p){ - if(matched || !p) return; - if(label.indexOf(p) >= 0 || id.indexOf(p) >= 0) matched = true; - }); - if(matched) return true; - } - return /platform|kuber|k8s|cluster|shturval|штурвал/.test(svc + " " + label); -} - -function buildRowLayout(data, containerWidth, forceShowAux){ - var MAX_PER_ROW = 5; - var nodes = data.nodes || []; - var showAux = document.getElementById("chk-auxiliary"); - var hideAux = forceShowAux ? false : (showAux ? !showAux.checked : true); - - var filtered = hideAux ? nodes.filter(function(n){ return !n.isAuxiliary; }) : nodes; - if(filtered.length === 0) filtered = nodes; - - var platforms = []; - var singletons = []; - var rest = []; - - filtered.forEach(function(n){ - var lane = n.lane || "regular"; - if(lane === "platform"){ platforms.push(n); return; } - if(lane === "singleton"){ singletons.push(n); return; } - rest.push(n); - }); - - // Helper: split array into chunks of max N - function chunkArray(arr, n){ - var res = []; - for(var i=0; i= 1){ - var d10 = days % 10, d100 = days % 100; - var w = (d100 >= 11 && d100 <= 19) ? "дней" : d10 === 1 ? "день" : (d10 >= 2 && d10 <= 4) ? "дня" : "дней"; - return days + " " + w; - } - if(hrs >= 1) return hrs + " ч"; - return mins + " мин"; -} - -function enrichNodesWithMetadata(data, focusUid, laneById){ - (data.nodes || []).forEach(function(n){ - var name = (n.label || n.id || ""); - var svc = n.svc || ""; - var age = humanAge(n.created); - var nodeFixedW = 200; - var textMaxWidth = nodeFixedW - 24; - var fontSize = age ? (name.length > 36 ? 10 : name.length > 24 ? 11 : 12) : (name.length > 36 ? 11 : name.length > 24 ? 12 : 13); - var nameFs = Math.round(fontSize * 1.25); - var charsPerLine = Math.max(8, Math.floor(textMaxWidth / (nameFs * 0.6))); - var nameLines = Math.max(1, Math.ceil(name.length / charsPerLine)); - var nameH = nameLines * Math.round(nameFs * 1.5); - var svcH = svc ? Math.round((fontSize - 1) * 1.5) + 6 : 0; - var ageH = age ? 9 * 2 + 12 : 0; - var height = Math.max(80, 16 + nameH + svcH + ageH); - n.nodeWidth = nodeFixedW; - n.nodeHeight = height; - n.fontSize = fontSize; - n.bgColor = svcBgColor(svc); - n.statusColor = statusColor(n.status || ""); - n.age = age; - }); - return data; -} - -function graphElements(data, focusUid, laneById){ - var elems = []; - (data.nodes || []).forEach(function(n){ - var name = (n.label || n.id || ""); - var svc = n.svc || ""; - var age = humanAge(n.created); - // Build label: name + svc + (2 blank lines) + age (small font) - var label = name; - if(svc) label += "\n" + svc; - if(age) label += "\n\n\u23f1 " + age; - // Fixed node width; text wraps inside - var nodeFixedW = 200; - var textMaxWidth = nodeFixedW - 24; - var fontSize = age ? (name.length > 36 ? 10 : name.length > 24 ? 11 : 12) : (name.length > 36 ? 11 : name.length > 24 ? 12 : 13); - // Estimate height: nameFs = fontSize*1.25, ~0.6 px per char at that size - var nameFs = Math.round(fontSize * 1.25); - var charsPerLine = Math.max(8, Math.floor(textMaxWidth / (nameFs * 0.6))); - var nameLines = Math.max(1, Math.ceil(name.length / charsPerLine)); - var nameH = nameLines * Math.round(nameFs * 1.5); - var svcH = svc ? Math.round((fontSize - 1) * 1.5) + 6 : 0; - var ageH = age ? 9 * 2 + 12 : 0; - var height = Math.max(80, 16 + nameH + svcH + ageH); - var bgColor = svcBgColor(svc); - elems.push({ - data: { - id: n.id, - label: n.label || n.id, - fullLabel: n.label || n.id, - status: n.status || "unknown", - color: statusColor(n.status || ""), - bgColor: bgColor, - width: nodeFixedW, - height: height, - fontSize: fontSize, - textMaxWidth: textMaxWidth, - svc: svc, - age: age - }, - classes: (focusUid && n.id === focusUid) ? "focus" : "" - }); - }); - return elems; -} - -function drawGraph(containerId, data, focusUid, forceShowAux){ - if(graphDrawing) return; - graphDrawing = true; - var el = document.getElementById(containerId); - if(!el) { graphDrawing = false; return; } - el.innerHTML = ""; - if((data.nodes || []).length === 0){ - el.innerHTML = '
Нет узлов для отображения
'; - graphDrawing = false; - return; - } - var layoutMeta = buildRowLayout(data, 1200, forceShowAux); - var posMap = layoutMeta.positions; - var visibleNodes = data.nodes || []; - - // Calculate container bounds - var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - visibleNodes.forEach(function(n){ - var pos = posMap[n.id]; - if(!pos) return; - var w = n.nodeWidth || 200; - var h = n.nodeHeight || 80; - minX = Math.min(minX, pos.x - w/2); - maxX = Math.max(maxX, pos.x + w/2); - minY = Math.min(minY, pos.y - h/2); - maxY = Math.max(maxY, pos.y + h/2); - }); - - var padding = 40; - var containerW = maxX - minX + 2*padding; - var containerH = maxY - minY + 2*padding; - var offsetX = -minX + padding; - var offsetY = -minY + padding; - - // Create scrollable container - var scrollContainer = document.createElement("div"); - scrollContainer.style.width = "100%"; - scrollContainer.style.height = "100%"; - scrollContainer.style.overflow = "auto"; - scrollContainer.style.backgroundColor = "#0d1117"; - - // Create SVG - var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("width", containerW); - svg.setAttribute("height", containerH); - svg.setAttribute("viewBox", "0 0 " + containerW + " " + containerH); - svg.style.display = "block"; - svg.style.minWidth = containerW + "px"; - svg.style.minHeight = containerH + "px"; - - var g = document.createElementNS("http://www.w3.org/2000/svg", "g"); - - // Draw nodes with text wrapping via tspan - visibleNodes.forEach(function(n){ - var pos = posMap[n.id]; - if(!pos) return; - var x = pos.x + offsetX; - var y = pos.y + offsetY; - var w = n.nodeWidth || 200; - var h = n.nodeHeight || 80; - var bgColor = svcBgColor(n.svc || ""); - var borderColor = statusColor(n.status || ""); - var isFocus = focusUid && n.id === focusUid; - - // Rectangle - var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - rect.setAttribute("x", x - w/2); - rect.setAttribute("y", y - h/2); - rect.setAttribute("width", w); - rect.setAttribute("height", h); - rect.setAttribute("rx", 6); - rect.setAttribute("fill", bgColor); - rect.setAttribute("stroke", isFocus ? "#facc15" : borderColor); - rect.setAttribute("stroke-width", isFocus ? 3 : 2.4); - rect.style.cursor = "pointer"; - rect.addEventListener("click", function(){ - if(n.id) navigateToDep(n.id); - }); - g.appendChild(rect); - - // Text with wrapping: name + svc + age - var nameFs = Math.round((n.fontSize || 13) * 1.25); - var txtX = x; - var txtY = y - h/4; - var lineH = nameFs * 1.5; - var lines = []; - - // Split name into lines (rough estimate) - var charsPerLine = Math.max(8, Math.floor((w - 20) / (nameFs * 0.6))); - var nameLines = []; - for(var i = 0; i < n.label.length; i += charsPerLine){ - nameLines.push(n.label.substring(i, i + charsPerLine)); - } - nameLines.forEach(function(line){ lines.push({text: line, size: nameFs, weight: "bold", style: "normal", color: "#dbeafe"}); }); - - if(n.svc){ - lines.push({text: n.svc, size: nameFs - 2, weight: "normal", style: "italic", color: "#93c5fd"}); - } - - if(n.age){ - lines.push({text: "⏱ " + n.age, size: 9, weight: "normal", style: "italic", color: "#9ca3af"}); - } - - // Render text lines - var textG = document.createElementNS("http://www.w3.org/2000/svg", "g"); - var currentY = txtY - (lines.length * lineH / 2); - lines.forEach(function(line){ - var text = document.createElementNS("http://www.w3.org/2000/svg", "text"); - text.setAttribute("x", txtX); - text.setAttribute("y", currentY); - text.setAttribute("text-anchor", "middle"); - text.setAttribute("font-size", line.size); - text.setAttribute("font-weight", line.weight); - text.setAttribute("font-style", line.style); - text.setAttribute("fill", line.color); - text.setAttribute("font-family", "system-ui, sans-serif"); - text.textContent = line.text; - text.style.pointerEvents = "none"; - textG.appendChild(text); - currentY += lineH; - }); - g.appendChild(textG); - }); - - svg.appendChild(g); - scrollContainer.appendChild(svg); - el.appendChild(scrollContainer); - - setTimeout(function(){ graphDrawing = false; }, 50); -} - function ensureAllGraphLoaded(){ var statuses = getChecked().slice().sort(); var key = statuses.join(","); @@ -686,99 +387,206 @@ function ensureFullGraphLoaded(){ }); } -function renderInstanceGraph(uid){ - var holderId = "g-" + uid; - var holder = document.getElementById(holderId); - if(holder) holder.innerHTML = '
Строю граф зависимостей...
'; - var run = function(){ - ensureFullGraphLoaded().then(function(){ - var g = buildDepSubgraphFromDetail(uid); - enrichNodesWithMetadata(g); - if((g.nodes || []).length > 2){ - if(holder) holder.innerHTML = '
Граф открыт почти на весь экран для удобства чтения.
'; - showGraphModal("Граф зависимостей: " + escHtml(instanceName(uid)), g, uid, true); - } else { - drawGraph(holderId, g, uid, true); - } - }).catch(function(e){ - var el = document.getElementById(holderId); - if(el) el.innerHTML = '
' + escHtml(e.message) + '
'; - }); - }; - requestAnimationFrame(function(){ requestAnimationFrame(run); }); -} - -function instanceName(uid){ - for(var i=0;i 2 ? 10 : nameLines.length > 1 ? 11 : 12; + var lineH = nameFs * 1.5; + var totalTextH = nameLines.length * lineH + (n.svc ? lineH : 0); + var startY = cy - totalTextH / 2 + nameFs; + + nameLines.forEach(function(line, li){ + var t = document.createElementNS("http://www.w3.org/2000/svg", "text"); + t.setAttribute("x", cx); + t.setAttribute("y", startY + li * lineH); + t.setAttribute("text-anchor", "middle"); + t.setAttribute("font-size", nameFs); + t.setAttribute("font-weight", "bold"); + t.setAttribute("fill", "#dbeafe"); + t.setAttribute("font-family", "system-ui,sans-serif"); + t.style.pointerEvents = "none"; + t.textContent = line; + g.appendChild(t); + }); + + // Service + if(n.svc){ + var st = document.createElementNS("http://www.w3.org/2000/svg", "text"); + st.setAttribute("x", cx); + st.setAttribute("y", startY + nameLines.length * lineH); + st.setAttribute("text-anchor", "middle"); + st.setAttribute("font-size", 9); + st.setAttribute("font-style", "italic"); + st.setAttribute("fill", "#93c5fd"); + st.setAttribute("font-family", "system-ui,sans-serif"); + st.style.pointerEvents = "none"; + st.textContent = n.svc; + g.appendChild(st); + } + + // Age + var age = humanAge(n.created || ""); + if(age){ + var at = document.createElementNS("http://www.w3.org/2000/svg", "text"); + at.setAttribute("x", cx); + at.setAttribute("y", y + NODE_H - 6); + at.setAttribute("text-anchor", "middle"); + at.setAttribute("font-size", 9); + at.setAttribute("fill", "#9ca3af"); + at.setAttribute("font-family", "system-ui,sans-serif"); + at.style.pointerEvents = "none"; + at.textContent = "\u23f1 " + age; + g.appendChild(at); + } + }); + + svg.appendChild(g); + wrapper.appendChild(svg); + el.appendChild(wrapper); + + // ── panzoom: pan + scroll-zoom ── + if(window.panzoom){ + var instance = window.panzoom(g, { + smoothScroll: false, + bounds: false, + zoomDoubleClickSpeed: 1, + minZoom: 0.2, + maxZoom: 3 + }); + svg.addEventListener("dblclick", function(){ + instance.moveTo(0, 0); + instance.zoomAbs(0, 0, 1); + }); + } +} + +// ── Open / Close running graph modal ────────────────────────────────────── function openRunningGraph(){ var modal = document.getElementById("graph-modal"); modal.classList.add("open"); var titleEl = document.getElementById("graph-modal-title"); var statuses = getChecked(); - if(titleEl) titleEl.textContent = "Общий граф зависимостей" + (statuses.length ? ": " + statuses.join(", ") : ": все"); + if(titleEl) titleEl.textContent = "Общий граф зависимостей: " + (statuses.length ? statuses.join(", ") : "все"); var target = document.getElementById("running-graph"); if(target) target.innerHTML = '
Собираю граф...
'; ensureAllGraphLoaded() - .then(function(data){ enrichNodesWithMetadata(data || {nodes:[], edges:[]}, ""); drawGraph("running-graph", data || {nodes:[], edges:[]}, ""); }) + .then(function(data){ drawGraph("running-graph", data || {nodes:[], edges:[]}, ""); }) .catch(function(e){ - var el = document.getElementById("running-graph"); - if(el) el.innerHTML = '
' + escHtml(e.message) + '
'; + var el2 = document.getElementById("running-graph"); + if(el2) el2.innerHTML = '
' + escHtml(e.message) + '
'; }); } @@ -786,32 +594,32 @@ function closeRunningGraph(){ document.getElementById("graph-modal").classList.remove("open"); } -function showGraphModal(title, data, focusUid, forceShowAux){ +function showGraphModal(title, data, focusUid){ var modal = document.getElementById("graph-modal"); var titleEl = document.getElementById("graph-modal-title"); if(titleEl) titleEl.textContent = title; modal.classList.add("open"); - enrichNodesWithMetadata(data || {nodes:[], edges:[]}, focusUid); - drawGraph("running-graph", data || {nodes:[], edges:[]}, focusUid || "", forceShowAux); + drawGraph("running-graph", data || {nodes:[], edges:[]}, focusUid || ""); } -function scheduleGraphWarmup(){ - if(graphWarmupScheduled) return; - graphWarmupScheduled = true; - var warm = function(){ - // optional background warm-up: should never block UI - fetchGraph({ status: ["running"] }).then(function(data){ - graphRunningData = data || {nodes:[], edges:[]}; - }).catch(function(){ /* ignore */ }); - }; - if(window.requestIdleCallback){ - requestIdleCallback(warm, { timeout: 3000 }); - } else { - setTimeout(warm, 3000); - } +// ── Instance graph (вкладка Граф в детали) ───────────────────────────────── +function renderInstanceGraph(uid){ + var holderId = "g-" + uid; + var holder = document.getElementById(holderId); + if(holder) holder.innerHTML = '
Строю граф...
'; + ensureFullGraphLoaded().then(function(){ + var g = buildDepSubgraphFromDetail(uid); + if((g.nodes || []).length > 2){ + showGraphModal("Граф зависимостей: " + escHtml(instanceName(uid)), g, uid); + } else { + drawGraph(holderId, g, uid); + } + }).catch(function(e){ + var el = document.getElementById(holderId); + if(el) el.innerHTML = '
' + escHtml(e.message) + '
'; + }); } -function getToken(){ return localStorage.getItem("deck_token") || ""; } function getDeckEnv(){ return localStorage.getItem("deck_env") || "test"; @@ -1265,10 +1073,9 @@ function initApp(){ document.getElementById("chk-auxiliary").addEventListener("change", function(){ cache = {}; render(filteredInstances()); - if(graphRunningData){ graphDrawing = false; drawGraph("running-graph", graphRunningData, ""); } + if(graphRunningData){ drawGraph("running-graph", graphRunningData, ""); } }); loadAllDetails(); - scheduleGraphWarmup(); } document.getElementById("btn-login").addEventListener("click", doLogin); @@ -1292,45 +1099,6 @@ if(getToken()) initApp(); window.navigateToDep = navigateToDep; window.navBack = navBack; -// Build instance subgraph using rawDetail dependencies (no edges needed) -function buildDepSubgraphFromDetail(rootUid){ - var seen = new Set([rootUid]); - var frontier = new Set([rootUid]); - for(var i=0;i<3;i++){ - var nxt = new Set(); - frontier.forEach(function(u){ - var det = rawDetail[u]; - if(!det) return; - (det.dependencies || []).forEach(function(dep){ - var duid = depUid(dep); - if(duid && !seen.has(duid)) nxt.add(duid); - }); - (det.dependentInstances || []).forEach(function(dep){ - var duid = depUid(dep); - if(duid && !seen.has(duid)) nxt.add(duid); - }); - }); - if(nxt.size === 0) break; - nxt.forEach(function(x){ seen.add(x); }); - frontier = nxt; - } - var allNodes = (graphFullData && graphFullData.nodes) || []; - var filtered = allNodes.filter(function(n){ return seen.has(n.id); }); - // Ensure root is present even if not in graphFullData - if(!filtered.some(function(n){ return n.id === rootUid; })){ - var inst = null; - for(var j=0;j