diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml
index f2ae31b..361d304 100644
--- a/k8s/deployment.yaml
+++ b/k8s/deployment.yaml
@@ -15,7 +15,7 @@ spec:
spec:
containers:
- name: cloud-dashboard
- image: naeel/cloud-dashboard:v9.45
+ image: naeel/cloud-dashboard:v9.46
imagePullPolicy: Always
ports:
- containerPort: 8080
diff --git a/static/graph.js b/static/graph.js
new file mode 100644
index 0000000..dc26c27
--- /dev/null
+++ b/static/graph.js
@@ -0,0 +1,475 @@
+// 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
+
+ // ── Modal DOM ────────────────────────────────────────────────────────────
+ function getModal() { return document.getElementById("graph-modal"); }
+ function getCanvas() { return document.getElementById("graph-canvas"); }
+ function getStatus() { return document.getElementById("graph-status"); }
+
+ // ── Open / Close ─────────────────────────────────────────────────────────
+ function openGraph() {
+ var modal = getModal();
+ if (!modal) return;
+ modal.style.display = "flex";
+ if (graphCache) {
+ showCached();
+ } else if (!building) {
+ buildGraph();
+ }
+ }
+
+ function closeGraph() {
+ var modal = getModal();
+ if (modal) modal.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) {
+ 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 = [
+ '');
+ 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 already loaded in memory
+ var instances = (typeof allInstances !== "undefined") ? allInstances : [];
+ if (!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];
+ var token = (typeof getToken === "function") ? getToken() : (window.getToken ? window.getToken() : "");
+ var env = (typeof getDeckEnv === "function") ? getDeckEnv() : (window.getDeckEnv ? window.getDeckEnv() : "test");
+
+ fetch("/dashboard/api/instances/" + uid, {
+ headers: {
+ "X-Deck-Token": token,
+ "X-Deck-Env": env
+ }
+ })
+ .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 };
+
+})();
diff --git a/static/index.html b/static/index.html
index da7bd9f..ab9393c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -212,7 +212,27 @@
.params-grid, .deps-grid { grid-template-columns: 1fr; }
.user-email { display: none; }
}
+
+ /* ── Graph Modal ──────────────────────────────────────────────────── */
+ #graph-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 2000; display: none; align-items: center; justify-content: center; }
+ #graph-modal { background: #161b22; border: 1px solid #30363d; border-radius: 12px; width: 96vw; max-width: 1400px; height: 90vh; display: flex; flex-direction: column; overflow: hidden; }
+ #graph-toolbar { padding: 10px 16px; border-bottom: 1px solid #30363d; display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
+ #graph-toolbar h2 { font-size: 14px; font-weight: 700; color: #e1e4e8; flex: 1; }
+ #graph-status { font-size: 12px; color: #8b949e; flex: 1; }
+ #graph-canvas { flex: 1; overflow: hidden; position: relative; background: #0d1117; }
+ #graph-canvas svg { display: block; }
+ .btn-graph-ctrl { padding: 5px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; cursor: pointer; font-size: 12px; }
+ .btn-graph-ctrl:hover { background: #30363d; }
+ .btn-graph-close { color: #f85149; }
+ .btn-graph-close:hover { background: #3d1a1a !important; border-color: #f85149 !important; }
+ body.light #graph-modal { background: #f6f8fa; border-color: #d0d7de; }
+ body.light #graph-toolbar { border-color: #d0d7de; }
+ body.light #graph-canvas { background: #edf2f7; }
+ body.light .btn-graph-ctrl { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; }
+ body.light .btn-graph-ctrl:hover { background: #e2e8f0; }
+
+
@@ -251,6 +271,7 @@
+