v9.49: simple grid layout, max 5 cols, straight arrows

This commit is contained in:
Naeel
2026-04-17 10:05:53 +03:00
parent 3a70038194
commit da06f64baf
2 changed files with 128 additions and 336 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ spec:
spec: spec:
containers: containers:
- name: cloud-dashboard - name: cloud-dashboard
image: naeel/cloud-dashboard:v9.48 image: naeel/cloud-dashboard:v9.49
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8080 - containerPort: 8080
+127 -335
View File
@@ -1,18 +1,14 @@
// graph.js — Progressive dependency graph visualization // graph.js — Simple grid layout, max 5 columns, arrows between deps
// Uses dagre (layout) + panzoom (pan/zoom) from CDN
// Appended to index.html as separate script — reads globals: allInstances, rawDetail, getToken, getDeckEnv
(function () { (function () {
"use strict"; "use strict";
// ── Constants ──────────────────────────────────────────────────────────── var COLS = 5;
var NODE_W = 160; var NODE_W = 170;
var NODE_H = 52; var NODE_H = 58;
var REALM_W = 180; var GAP_X = 30;
var REALM_H = 44; var GAP_Y = 80;
var RANK_SEP = 90; var PAD = 40;
var NODE_SEP = 36;
// Color palette per svc keyword (substring match, case-insensitive)
var SVC_COLORS = [ var SVC_COLORS = [
["s3", "#1d4e6e", "#38bdf8"], ["s3", "#1d4e6e", "#38bdf8"],
["postgresql", "#1a3d2b", "#4ade80"], ["postgresql", "#1a3d2b", "#4ade80"],
@@ -29,264 +25,147 @@
["pgadmin", "#1a3d2b", "#6ee7b7"], ["pgadmin", "#1a3d2b", "#6ee7b7"],
["edge", "#1e293b", "#94a3b8"], ["edge", "#1e293b", "#94a3b8"],
["vdc", "#1e293b", "#94a3b8"], ["vdc", "#1e293b", "#94a3b8"],
["vapp", "#1e293b", "#94a3b8"],
["cloud direct","#0c2340", "#93c5fd"],
["dns", "#1a2040", "#818cf8"], ["dns", "#1a2040", "#818cf8"],
["ip", "#1e293b", "#cbd5e1"],
["http", "#1a2e1a", "#bbf7d0"],
["nodered", "#3b1500", "#fdba74"] ["nodered", "#3b1500", "#fdba74"]
]; ];
// Edge color by source svc keyword function svcStyle(svc) {
var EDGE_COLORS = [ var s = (svc || "").toLowerCase();
["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++) { for (var i = 0; i < SVC_COLORS.length; i++) {
if (s.indexOf(SVC_COLORS[i][0]) >= 0) { if (s.indexOf(SVC_COLORS[i][0]) >= 0)
return { bg: SVC_COLORS[i][1], border: SVC_COLORS[i][2] }; return { bg: SVC_COLORS[i][1], border: SVC_COLORS[i][2] };
}
} }
return { bg: "#1e293b", border: "#475569" }; return { bg: "#1e293b", border: "#475569" };
} }
function edgeColor(svcStr) { function statusColor(status) {
var s = (svcStr || "").toLowerCase(); var m = { running: "#56d364", suspended: "#e3b341", deleted: "#f85149",
for (var i = 0; i < EDGE_COLORS.length; i++) { deleting: "#f85149", pending: "#58a6ff", creating: "#58a6ff" };
if (s.indexOf(EDGE_COLORS[i][0]) >= 0) return EDGE_COLORS[i][1]; return m[(status || "").toLowerCase()] || "#6e7681";
} }
return "#475569";
function esc(v) {
return String(v == null ? "" : v)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function trunc(s, n) {
if (!s) return "";
return s.length > n ? s.slice(0, n - 1) + "…" : s;
} }
// ── State ──────────────────────────────────────────────────────────────── // ── State ────────────────────────────────────────────────────────────────
var graphCache = null; // built SVG string, null = not built yet var graphCache = null;
var building = false; // loading in progress flag var building = false;
// ── DOM helpers ──────────────────────────────────────────────────────────
function getOverlay() { return document.getElementById("graph-overlay"); } function getOverlay() { return document.getElementById("graph-overlay"); }
function getCanvas() { return document.getElementById("graph-canvas"); } function getCanvas() { return document.getElementById("graph-canvas"); }
function getStatus() { return document.getElementById("graph-status"); } function getStatus() { return document.getElementById("graph-status"); }
function setStatus(m) { var el = getStatus(); if (el) el.textContent = m; }
// ── Open / Close ─────────────────────────────────────────────────────────
function openGraph() { function openGraph() {
var overlay = getOverlay(); var ov = getOverlay();
if (!overlay) { console.error("[graph] #graph-overlay not found"); return; } if (!ov) return;
overlay.style.display = "flex"; ov.style.display = "flex";
if (graphCache) { if (graphCache) { getCanvas().innerHTML = graphCache; attachPanzoom(); }
showCached(); else if (!building) buildGraph();
} else if (!building) {
buildGraph();
}
} }
function closeGraph() { function closeGraph() {
var overlay = getOverlay(); var ov = getOverlay();
if (overlay) overlay.style.display = "none"; if (ov) ov.style.display = "none";
} }
function refreshGraph() { function refreshGraph() {
graphCache = null; graphCache = null; building = false;
building = false; var c = getCanvas(); if (c) c.innerHTML = "";
var canvas = getCanvas();
if (canvas) canvas.innerHTML = "";
buildGraph(); buildGraph();
} }
function showCached() { // ── Grid layout ──────────────────────────────────────────────────────────
var canvas = getCanvas(); function gridPos(idx) {
if (!canvas) return; var col = idx % COLS;
canvas.innerHTML = graphCache; var row = Math.floor(idx / COLS);
attachPanzoom(); return {
setStatus(""); x: PAD + col * (NODE_W + GAP_X),
y: PAD + row * (NODE_H + GAP_Y),
cx: PAD + col * (NODE_W + GAP_X) + NODE_W / 2,
cy: PAD + row * (NODE_H + GAP_Y) + NODE_H / 2
};
} }
function setStatus(msg) { // ── SVG render ───────────────────────────────────────────────────────────
var el = getStatus(); function renderSVG(instances, edges) {
if (el) el.textContent = msg; var n = instances.length;
} var cols = Math.min(n, COLS);
var rows = Math.ceil(n / COLS);
var W = PAD * 2 + cols * NODE_W + (cols - 1) * GAP_X;
var H = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y;
// ── Layout helpers ─────────────────────────────────────────────────────── // index map: uid → index
var idx = {};
instances.forEach(function (inst, i) { idx[inst.instanceUid] = i; });
// Simple dagre-based layout using the global dagre object from CDN var parts = [
function layoutGraph(nodes, edges) { '<svg id="graph-svg" xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '">',
var g = new dagre.graphlib.Graph(); '<defs>',
g.setGraph({ rankdir: "TB", ranksep: RANK_SEP, nodesep: NODE_SEP, marginx: 40, marginy: 40 }); '<marker id="arr" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">',
g.setDefaultEdgeLabel(function () { return {}; }); '<path d="M0,0 L0,6 L8,3 z" fill="#64748b"/>',
'</marker>',
nodes.forEach(function (n) { '</defs>'
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function truncate(str, maxLen) {
if (!str) return "";
return str.length > maxLen ? str.slice(0, maxLen - 1) + "…" : str;
}
function renderArrowMarker(color, id) {
return '<marker id="' + escX(id) + '" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">' +
'<path d="M0,0 L0,6 L8,3 z" fill="' + escX(color) + '"/>' +
'</marker>';
}
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 = '<defs>';
Object.keys(markerColors).forEach(function (c) {
var mid = "arr-" + c.replace(/[^a-zA-Z0-9]/g, "");
defs += renderArrowMarker(c, mid);
});
defs += '</defs>';
var svgParts = [
'<svg id="graph-svg" xmlns="http://www.w3.org/2000/svg"',
' width="' + svgW + '" height="' + svgH + '"',
' viewBox="0 0 ' + svgW + ' ' + svgH + '">',
defs
]; ];
// Draw edges first (under nodes) // Draw arrows first (under nodes)
nodesMeta.edges.forEach(function (e) { edges.forEach(function (e) {
var pts = pointsOnEdge(g, e.from, e.to); var fi = idx[e.from], ti = idx[e.to];
if (!pts) return; if (fi === undefined || ti === undefined) return;
var c = e.color || "#475569"; var fp = gridPos(fi), tp = gridPos(ti);
var mid = "arr-" + c.replace(/[^a-zA-Z0-9]/g, ""); // line from bottom-center of source to top-center of target (or center-to-center)
svgParts.push( parts.push(
'<polyline points="' + polyline(pts) + '"' + '<line x1="' + fp.cx + '" y1="' + fp.cy + '" x2="' + tp.cx + '" y2="' + tp.cy + '"' +
' fill="none" stroke="' + escX(c) + '" stroke-width="1.5" opacity="0.75"' + ' stroke="#64748b" stroke-width="1.5" opacity="0.7" marker-end="url(#arr)"/>'
' marker-end="url(#' + mid + ')"/>'
); );
}); });
// Draw nodes // Draw nodes
g.nodes().forEach(function (id) { instances.forEach(function (inst, i) {
var pos = g.node(id); var p = gridPos(i);
if (!pos) return; var st = svcStyle(inst.svc);
var meta = nodesMeta.map[id]; var dot = statusColor(inst.explainedStatus);
if (!meta) return; var lbl = trunc(inst.displayName || inst.instanceUid, 18);
var x = pos.x - pos.width / 2; var svc = trunc(inst.svc || "", 24);
var y = pos.y - pos.height / 2;
var w = pos.width;
var h = pos.height;
if (meta.type === "realm") { parts.push(
// Platform node — dashed border, distinct style '<rect x="' + p.x + '" y="' + p.y + '" width="' + NODE_W + '" height="' + NODE_H + '"' +
svgParts.push( ' rx="6" fill="' + esc(st.bg) + '" stroke="' + esc(st.border) + '" stroke-width="1.5"/>'
'<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '"' + );
' rx="6" fill="#0f172a" stroke="#64748b" stroke-width="1.5" stroke-dasharray="5,3"/>' parts.push(
); '<circle cx="' + (p.x + 12) + '" cy="' + (p.y + NODE_H / 2) + '" r="4" fill="' + esc(dot) + '"/>'
svgParts.push( );
'<text x="' + (x + w / 2) + '" y="' + (y + h / 2 - 6) + '"' + parts.push(
' text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="600"' + '<text x="' + (p.x + 24) + '" y="' + (p.y + NODE_H / 2 - 6) + '"' +
' fill="#94a3b8">ПЛАТФОРМА</text>' ' font-family="sans-serif" font-size="12" font-weight="600" fill="#f1f5f9">' + esc(lbl) + '</text>'
); );
svgParts.push( parts.push(
'<text x="' + (x + w / 2) + '" y="' + (y + h / 2 + 10) + '"' + '<text x="' + (p.x + 24) + '" y="' + (p.y + NODE_H / 2 + 11) + '"' +
' text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700"' + ' font-family="sans-serif" font-size="10" fill="' + esc(st.border) + '">' + esc(svc) + '</text>'
' fill="#e2e8f0">' + escX(truncate(meta.label, 22)) + '</text>' );
);
} else {
// Instance node
var st = svcStyle(meta.svc);
svgParts.push(
'<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '"' +
' rx="6" fill="' + escX(st.bg) + '" stroke="' + escX(st.border) + '" stroke-width="1.5"/>'
);
// Status dot
var dotColor = meta.statusColor || "#6e7681";
svgParts.push(
'<circle cx="' + (x + 12) + '" cy="' + (y + h / 2) + '" r="4" fill="' + escX(dotColor) + '"/>'
);
// Name
svgParts.push(
'<text x="' + (x + 24) + '" y="' + (y + h / 2 - 5) + '"' +
' font-family="sans-serif" font-size="12" font-weight="600"' +
' fill="#f1f5f9">' + escX(truncate(meta.label, 18)) + '</text>'
);
// Svc
svgParts.push(
'<text x="' + (x + 24) + '" y="' + (y + h / 2 + 11) + '"' +
' font-family="sans-serif" font-size="10"' +
' fill="' + escX(st.border) + '">' + escX(truncate(meta.svc, 22)) + '</text>'
);
}
}); });
svgParts.push('</svg>'); parts.push('</svg>');
return svgParts.join("\n"); return parts.join("\n");
} }
// ── Status color helper ────────────────────────────────────────────────── // ── Panzoom ──────────────────────────────────────────────────────────────
function statusDotColor(status) { function attachPanzoom() {
var m = { var svg = document.getElementById("graph-svg");
running: "#56d364", if (!svg || typeof panzoom === "undefined") return;
suspended: "#e3b341", if (svg._pz) { try { svg._pz.dispose(); } catch(e) {} }
deleted: "#f85149", svg._pz = panzoom(svg, { maxZoom: 4, minZoom: 0.1, smoothScroll: false });
deleting: "#f85149",
pending: "#58a6ff",
creating: "#58a6ff",
"not created": "#6e7681"
};
return m[(status || "").toLowerCase()] || "#6e7681";
} }
// ── Main build pipeline ────────────────────────────────────────────────── // ── Build ────────────────────────────────────────────────────────────────
function buildGraph() { function buildGraph() {
if (building) return; if (building) return;
building = true; building = true;
@@ -295,85 +174,35 @@
var canvas = getCanvas(); var canvas = getCanvas();
if (!canvas) return; if (!canvas) return;
canvas.innerHTML = ""; canvas.innerHTML = "";
setStatus("Строю схему: загрузка инстансов…"); setStatus("Загрузка…");
// Step 1: Use allInstances exposed from IIFE via window._getInstances()
var instances = (typeof window._getInstances === "function") ? window._getInstances() : []; var instances = (typeof window._getInstances === "function") ? window._getInstances() : [];
if (!instances || !instances.length) { if (!instances || !instances.length) {
setStatus("Нет данных. Нажмите «Обновить» в таблице, затем откройте схему снова."); setStatus("Нет данных. Обновите таблицу и откройте схему снова.");
building = false; building = false;
return; return;
} }
// Step 2: Collect unique realms // Render grid immediately without edges
var realmSet = {}; canvas.innerHTML = renderSVG(instances, []);
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(); attachPanzoom();
setStatus("Загрузка зависимостей…");
// Step 5: Load dependencies for each instance one by one // Load all deps, then re-render once
var uidList = instances.map(function (i) { return i.instanceUid; }).filter(Boolean); var edges = [];
var total = uidList.length; var total = instances.length;
var done = 0; var done = 0;
function loadNext(idx) { function loadNext(idx) {
if (idx >= uidList.length) { if (idx >= instances.length) {
// All done — re-render with all edges canvas.innerHTML = renderSVG(instances, edges);
setStatus("Строю финальную схему…");
var gFinal = layoutGraph(nodes, nodesMeta.edges);
var finalSvg = renderSVG(gFinal, nodesMeta);
canvas.innerHTML = finalSvg;
attachPanzoom(); attachPanzoom();
graphCache = canvas.innerHTML; graphCache = canvas.innerHTML;
building = false; building = false;
setStatus(""); setStatus("");
return; return;
} }
var uid = instances[idx].instanceUid;
var uid = uidList[idx];
fetch("/dashboard/api/instances/" + uid, { fetch("/dashboard/api/instances/" + uid, {
headers: { headers: {
"X-Deck-Token": localStorage.getItem("deck_token") || "", "X-Deck-Token": localStorage.getItem("deck_token") || "",
@@ -383,65 +212,32 @@
.then(function (r) { return r.ok ? r.json() : null; }) .then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) { .then(function (data) {
done++; done++;
setStatus("Загружаю зависимости: " + done + " / " + total); setStatus("Зависимости: " + done + "/" + total);
if (data) { if (data) {
var inst = data.instance || data; var inst = data.instance || data;
var deps = inst.dependencies || inst.deps || []; var deps = [].concat(inst.dependencies || [], inst.deps || []);
var depInstances = inst.dependentInstances || inst.dependsOn || []; deps.forEach(function (d) {
var to = d.uid || d.instanceUid || d.dependencyUid;
deps.forEach(function (dep) { if (to) edges.push({ from: uid, to: to });
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 });
}
}); });
var dInst = [].concat(inst.dependentInstances || [], inst.dependsOn || []);
depInstances.forEach(function (dep) { dInst.forEach(function (d) {
var fromUid = dep.uid || dep.instanceUid || dep.dependencyUid; var from = d.uid || d.instanceUid || d.dependencyUid;
if (fromUid && nodesMeta.map[fromUid]) { if (from) edges.push({ from: from, to: uid });
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();
}
} }
setTimeout(function () { loadNext(idx + 1); }, 20);
// Small delay to avoid hammering the API
setTimeout(function () { loadNext(idx + 1); }, 30);
}) })
.catch(function () { .catch(function () {
done++; done++;
setTimeout(function () { loadNext(idx + 1); }, 30); setTimeout(function () { loadNext(idx + 1); }, 20);
}); });
} }
loadNext(0); loadNext(0);
} }
// ── Panzoom attachment ─────────────────────────────────────────────────── // ── Init ─────────────────────────────────────────────────────────────────
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() { function init() {
var btnOpen = document.getElementById("btn-graph-open"); var btnOpen = document.getElementById("btn-graph-open");
var btnClose = document.getElementById("btn-graph-close"); var btnClose = document.getElementById("btn-graph-close");
@@ -454,8 +250,6 @@
if (overlay) overlay.addEventListener("click", function (e) { if (overlay) overlay.addEventListener("click", function (e) {
if (e.target === overlay) closeGraph(); if (e.target === overlay) closeGraph();
}); });
// Keyboard Escape
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeGraph(); if (e.key === "Escape") closeGraph();
}); });
@@ -467,7 +261,5 @@
init(); init();
} }
// Expose for manual testing
window.graphModule = { open: openGraph, close: closeGraph, refresh: refreshGraph }; window.graphModule = { open: openGraph, close: closeGraph, refresh: refreshGraph };
})(); })();