v9.44: remove all graph code (backend endpoint, frontend SVG/panzoom/CSS, ~536 lines dead code)

This commit is contained in:
Naeel
2026-04-17 08:56:53 +03:00
parent b778f5554c
commit f6d15eb7e8
3 changed files with 3 additions and 536 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.43 image: naeel/cloud-dashboard:v9.44
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8080 - containerPort: 8080
-175
View File
@@ -3,7 +3,6 @@ from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import httpx import httpx
from typing import Annotated from typing import Annotated
import asyncio
import json import json
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -16,8 +15,6 @@ DECK_APIS = {
"test": "https://deck-api-test.ngcloud.ru/api/v1", "test": "https://deck-api-test.ngcloud.ru/api/v1",
} }
DEFAULT_DECK_ENV = "test" DEFAULT_DECK_ENV = "test"
CACHE_TTL_SECONDS = 180
GRAPH_CACHE: dict[str, tuple[float, dict]] = {}
# Centralized backend policy for table-related derived fields. # Centralized backend policy for table-related derived fields.
DELETE_POLICY = { DELETE_POLICY = {
@@ -55,38 +52,6 @@ def _as_text(value) -> str:
return "" return ""
def extract_platform_name(inst: dict | None) -> str:
if not isinstance(inst, dict):
return ""
sources = [
inst.get("parameters"),
inst.get("params"),
inst.get("instanceParameters"),
inst.get("inputParameters"),
]
needles = ("platform", "kuber", "k8s", "cluster", "shturval", "штурвал")
for src in sources:
if isinstance(src, dict):
for key, value in src.items():
key_s = str(key).strip().lower()
if any(n in key_s for n in needles):
txt = _as_text(value)
if txt:
return txt
elif isinstance(src, list):
for item in src:
if not isinstance(item, dict):
continue
key_s = str(item.get("name") or item.get("key") or item.get("code") or "").strip().lower()
if any(n in key_s for n in needles):
txt = _as_text(item.get("value") or item.get("displayValue") or item.get("text"))
if txt:
return txt
return ""
def _parse_ts(value) -> float | None: def _parse_ts(value) -> float | None:
if not value: if not value:
return None return None
@@ -204,146 +169,6 @@ async def list_instances(
return {"instances": all_results, "total": len(all_results)} return {"instances": all_results, "total": len(all_results)}
@app.get("/api/graph")
async def get_graph(
request: Request,
x_deck_token: Annotated[str | None, Header()] = None,
x_deck_env: Annotated[str | None, Header()] = None,
):
if not x_deck_token:
raise HTTPException(status_code=401, detail="Token required")
env, deck_api = resolve_deck_api(x_deck_env)
status_list = request.query_params.getlist("status")
root_uid = request.query_params.get("root_uid")
depth_raw = request.query_params.get("depth", "2")
try:
depth = max(1, min(5, int(depth_raw)))
except ValueError:
depth = 2
status_key = ",".join(sorted(status_list)) if status_list else "__all__"
cache_key = f"{x_deck_token[:24]}::{env}::{status_key}"
now = time.time()
cached = GRAPH_CACHE.get(cache_key)
if cached and (now - cached[0] < CACHE_TTL_SECONDS):
base_graph = cached[1]
else:
all_instances = await fetch_all_instances(x_deck_token, deck_api)
if status_list:
all_instances = [i for i in all_instances if i.get("explainedStatus") in status_list]
uid_to_inst = {
i.get("instanceUid"): i
for i in all_instances
if i.get("instanceUid")
}
allowed_uids = set(uid_to_inst.keys())
detail_map: dict[str, dict] = {}
async with httpx.AsyncClient(timeout=25) as shared_client:
for uid in allowed_uids:
try:
r = await shared_client.get(
f"{deck_api}/index.cfm/instances/{uid}",
headers=auth_headers(x_deck_token),
)
if r.status_code == 401:
raise HTTPException(status_code=401, detail="Invalid token")
if r.status_code >= 400:
continue
inst = r.json().get("instance")
if inst:
detail_map[uid] = inst
except httpx.TimeoutException:
continue
# Categorise singletons by service name
_PLATFORM_PATTERNS = ("виртуальный датацентр", "vdc", "virtual data center", "cloud director", "kubernetes", "k8s")
_SINGLETON_PATTERNS = ("object storage", "s3", "edge", "шлюз периметра", "dns", "container registry", "реестр контейнеров")
def _is_platform_svc(svc_name: str) -> bool:
s = svc_name.strip().lower()
return any(p in s for p in _PLATFORM_PATTERNS)
def _is_singleton_svc(svc_name: str) -> bool:
s = svc_name.strip().lower()
return any(p in s for p in _SINGLETON_PATTERNS)
# Build realm→platform_uid map: instances whose resourceRealmCnt > 0 ARE platforms
realm_to_platform: dict[str, str] = {}
for uid_d, inst_d in detail_map.items():
cnt = inst_d.get("resourceRealmCnt") or 0
realm_name = inst_d.get("resourceRealm") or ""
if cnt and realm_name:
realm_to_platform[realm_name] = uid_d
nodes = []
for uid_i in allowed_uids:
i = uid_to_inst.get(uid_i, {})
detail = detail_map.get(uid_i, {})
svc = i.get("svc") or ""
realm_cnt = detail.get("resourceRealmCnt") or 0
is_aux = bool(detail.get("isAuxiliary"))
# Determine lane by service name
if _is_platform_svc(svc):
lane = "platform"
elif _is_singleton_svc(svc):
lane = "singleton"
else:
lane = "regular"
nodes.append(
{
"id": uid_i,
"label": i.get("displayName") or uid_i,
"status": i.get("explainedStatus") or "unknown",
"svc": svc,
"realm": i.get("resourceRealm") or "",
"lane": lane,
"isAuxiliary": is_aux,
"created": i.get("instanceConfigDtCreated") or i.get("dtCreated") or "",
}
)
# Debug: log lane distribution
import sys
_lane_counts = {}
for _n in nodes:
_l = _n.get("lane", "unknown")
_lane_counts[_l] = _lane_counts.get(_l, 0) + 1
print(f"[GRAPH] Lanes: {_lane_counts}, nodes: {len(nodes)}", flush=True)
for _n in nodes:
if _n["lane"] in ("platform", "singleton"):
print(f" {_n['lane'].upper()}: {_n['label']} | svc={_n['svc']}", flush=True)
base_graph = {"nodes": nodes, "edges": []}
GRAPH_CACHE[cache_key] = (now, base_graph)
# Return full graph fast
if not root_uid:
return {
"nodes": base_graph["nodes"],
"edges": [],
"total_nodes": len(base_graph["nodes"]),
"total_edges": 0,
"cached": bool(cached),
}
node_ids = {n["id"] for n in base_graph["nodes"]}
if root_uid not in node_ids:
return {"nodes": [], "edges": [], "total_nodes": 0, "total_edges": 0, "cached": bool(cached)}
sub_nodes = [n for n in base_graph["nodes"] if n["id"] == root_uid]
return {
"nodes": sub_nodes,
"edges": [],
"total_nodes": len(sub_nodes),
"total_edges": 0,
"cached": bool(cached),
}
@app.get("/api/instances/stream") @app.get("/api/instances/stream")
async def stream_instances( async def stream_instances(
request: Request, request: Request,
+2 -360
View File
@@ -36,12 +36,8 @@
.filter-label input { cursor: pointer; width: 18px; height: 18px; accent-color: #58a6ff; } .filter-label input { cursor: pointer; width: 18px; height: 18px; accent-color: #58a6ff; }
.btn-refresh { padding: 5px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; cursor: pointer; font-size: 12px; } .btn-refresh { padding: 5px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; cursor: pointer; font-size: 12px; }
.btn-refresh:hover { background: #30363d; } .btn-refresh:hover { background: #30363d; }
.btn-graph { padding: 5px 12px; background: #1f6feb; border: 1px solid #3b82f6; border-radius: 6px; color: #fff; cursor: pointer; font-size: 12px; }
.btn-graph:hover { background: #2563eb; }
.btn-theme { padding: 5px 10px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; cursor: pointer; font-size: 14px; line-height: 1; } .btn-theme { padding: 5px 10px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; cursor: pointer; font-size: 14px; line-height: 1; }
.btn-theme:hover { background: #30363d; } .btn-theme:hover { background: #30363d; }
.btn-graph { padding: 5px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; cursor: pointer; font-size: 12px; }
.btn-graph:hover { background: #30363d; }
.header-user { display: flex; align-items: center; gap: 10px; } .header-user { display: flex; align-items: center; gap: 10px; }
.user-email { font-size: 12px; color: #8b949e; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .user-email { font-size: 12px; color: #8b949e; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn-exit { padding: 5px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #f85149; cursor: pointer; font-size: 12px; } .btn-exit { padding: 5px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #f85149; cursor: pointer; font-size: 12px; }
@@ -100,20 +96,6 @@
body.light .panel-history { background: #dcfce7; border-top: 2px solid #4ade80; } body.light .panel-history { background: #dcfce7; border-top: 2px solid #4ade80; }
body.light .panel-deps { background: #fef3c7; border-top: 2px solid #f59e0b; } body.light .panel-deps { background: #fef3c7; border-top: 2px solid #f59e0b; }
.panel.active { display: block; } .panel.active { display: block; }
.inst-graph { height: 420px; border: 1px solid #30363d; border-radius: 8px; background: #0b0f14; }
.graph-hint { color: #8b949e; font-size: 12px; margin-bottom: 8px; }
.graph-loading { display: flex; align-items: center; justify-content: center; height: 100%; color: #9fb3c8; font-size: 13px; }
.graph-loading::before { content: ""; width: 14px; height: 14px; border: 2px solid #3b82f6; border-top-color: transparent; border-radius: 50%; display: inline-block; margin-right: 8px; animation: spin .9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.graph-modal { position: fixed; inset: 0; background: rgba(2, 8, 20, .75); display: none; align-items: center; justify-content: center; z-index: 1200; }
.graph-modal.open { display: flex; }
.graph-modal-box { width: min(98vw, 1700px); height: min(95vh, 1100px); background: #0d1117; border: 1px solid #30363d; border-radius: 12px; display: flex; flex-direction: column; }
.graph-modal-head { padding: 10px 14px; border-bottom: 1px solid #30363d; display: flex; align-items: center; gap: 10px; }
.graph-modal-title { font-size: 14px; font-weight: 600; }
.graph-modal-close { margin-left: auto; padding: 4px 10px; background: #21262d; border: 1px solid #30363d; color: #e1e4e8; border-radius: 6px; cursor: pointer; }
.graph-modal-body { flex: 1; min-height: 0; }
.running-graph { width: 100%; height: 100%; }
.graph-inline-note { color: #8b949e; font-size: 12px; margin-top: 8px; }
.params-grid { display: grid; grid-template-columns: max-content max-content; gap: 14px 68px; max-width: none; margin: 0; justify-content: start; align-content: start; } .params-grid { display: grid; grid-template-columns: max-content max-content; gap: 14px 68px; max-width: none; margin: 0; justify-content: start; align-content: start; }
.params-section { min-width: 0; } .params-section { min-width: 0; }
.params-section h4 { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; margin-bottom: 8px; } .params-section h4 { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; margin-bottom: 8px; }
@@ -169,8 +151,8 @@
body.light .header-logo .logo-text { color: #0f172a; } body.light .header-logo .logo-text { color: #0f172a; }
body.light .header-logo .logo-sub, body.light .user-email { color: #475569; } body.light .header-logo .logo-sub, body.light .user-email { color: #475569; }
body.light .data-ts { color: #64748b; } body.light .data-ts { color: #64748b; }
body.light .btn-refresh, body.light .btn-theme, body.light .btn-exit, body.light .btn-graph { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; } body.light .btn-refresh, body.light .btn-theme, body.light .btn-exit { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; }
body.light .btn-refresh:hover, body.light .btn-theme:hover, body.light .btn-exit:hover, body.light .btn-graph:hover { background: #e2e8f0; } body.light .btn-refresh:hover, body.light .btn-theme:hover, body.light .btn-exit:hover { background: #e2e8f0; }
body.light .main { background: #f3f6fb; } body.light .main { background: #f3f6fb; }
body.light .stat-card { background: #ffffff; border-color: #cbd5e1; } body.light .stat-card { background: #ffffff; border-color: #cbd5e1; }
body.light .stat-card .lbl { color: #64748b; } body.light .stat-card .lbl { color: #64748b; }
@@ -199,10 +181,6 @@
body.light .tabs { border-bottom-color: #b6c6e8; background: #dfe9fa; } body.light .tabs { border-bottom-color: #b6c6e8; background: #dfe9fa; }
body.light .tab { color: #475569; } body.light .tab { color: #475569; }
body.light .tab.active { color: #1d4ed8; border-bottom-color: #1d4ed8; } body.light .tab.active { color: #1d4ed8; border-bottom-color: #1d4ed8; }
body.light .graph-modal { background: rgba(15, 23, 42, .35); }
body.light .graph-modal-box { background: #ffffff; border-color: #cbd5e1; }
body.light .graph-modal-head { border-bottom-color: #cbd5e1; }
body.light .graph-modal-close { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; }
body.light .p-item { border-bottom-color: #e2e8f0; color: #0f172a; } body.light .p-item { border-bottom-color: #e2e8f0; color: #0f172a; }
body.light .p-item:nth-child(odd) { background: rgba(148, 163, 184, .12); } body.light .p-item:nth-child(odd) { background: rgba(148, 163, 184, .12); }
body.light .p-item:nth-child(even) { background: rgba(148, 163, 184, .18); } body.light .p-item:nth-child(even) { background: rgba(148, 163, 184, .18); }
@@ -304,7 +282,6 @@
</div> </div>
</div> </div>
<div id="js-err" style="display:none;position:fixed;bottom:0;left:0;right:0;background:#f85149;color:#fff;padding:10px;font-family:monospace;font-size:12px;z-index:9999"></div> <div id="js-err" style="display:none;position:fixed;bottom:0;left:0;right:0;background:#f85149;color:#fff;padding:10px;font-family:monospace;font-size:12px;z-index:9999"></div>
<script src="https://cdn.jsdelivr.net/npm/panzoom@9.4.3/dist/panzoom.min.js"></script>
<script> <script>
window.onerror=function(m,s,l){var e=document.getElementById("js-err");if(e){e.style.display="block";e.textContent="JS ERROR line "+l+": "+m;}}; window.onerror=function(m,s,l){var e=document.getElementById("js-err");if(e){e.style.display="block";e.textContent="JS ERROR line "+l+": "+m;}};
</script> </script>
@@ -318,11 +295,6 @@ var sortCol = "displayName";
var sortDir = 1; var sortDir = 1;
var expandedUid = null; var expandedUid = null;
var cache = {}; var cache = {};
var graphAllData = null;
var graphRunningData = null;
var graphFullData = null;
var graphAllKey = "";
var graphFetchInFlight = false;
var rawDetail = {}; var rawDetail = {};
var navBackUid = null; var navBackUid = null;
var renderTimer = null; var renderTimer = null;
@@ -435,332 +407,6 @@ function visibleUidSet(){
} }
// ─── 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;
var p = new URLSearchParams();
Object.keys(params || {}).forEach(function(k){
var v = params[k];
if(Array.isArray(v)) v.forEach(function(x){ p.append(k, x); });
else if(v !== undefined && v !== null && v !== "") p.append(k, String(v));
});
return fetch(API + "/graph?" + p.toString(), { headers: apiHeaders() })
.then(function(r){
graphFetchInFlight = false;
if(r.status === 401){ doLogout(); return null; }
if(!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.catch(function(e){
graphFetchInFlight = false;
throw e;
});
}
function ensureAllGraphLoaded(){
var statuses = getChecked().slice().sort();
var key = statuses.join(",");
if(key === graphAllKey && graphAllData) return Promise.resolve(graphAllData);
if(statuses.length === 0){
graphAllKey = key;
graphAllData = {nodes:[], edges:[]};
return Promise.resolve(graphAllData);
}
return fetchGraph({ status: statuses }).then(function(data){
graphAllKey = key;
graphAllData = data || {nodes:[], edges:[]};
return graphAllData;
});
}
function ensureFullGraphLoaded(){
if(graphFullData) return Promise.resolve(graphFullData);
return fetchGraph({}).then(function(data){
graphFullData = data || {nodes:[], edges:[]};
return graphFullData;
});
}
// Build subgraph from rawDetail dependencies (BFS, 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); });
if(!filtered.some(function(n){ return n.id === rootUid; })){
for(var j = 0; j < allInstances.length; j++){
if(allInstances[j].instanceUid === rootUid){
var inst = allInstances[j];
filtered.push({
id: rootUid, label: inst.displayName || rootUid,
status: inst.explainedStatus || "unknown", svc: inst.svc || "",
lane: "regular", created: inst.instanceConfigDtCreated || inst.dtCreated || ""
});
break;
}
}
}
return { nodes: filtered, edges: [] };
}
// ─── MAIN DRAW FUNCTION ────────────────────────────────────────────────────
function drawGraph(containerId, data, focusUid){
var el = document.getElementById(containerId);
if(!el) return;
el.innerHTML = "";
var nodes = data.nodes || [];
if(nodes.length === 0){
el.innerHTML = '<div class="empty-msg" style="padding:20px">Нет данных</div>';
return;
}
var NODE_W = 200;
var NODE_H = 80;
var GAP_X = 30;
var GAP_Y = 50;
var MAX_COLS = 5;
var PAD = 40;
// Sort: platforms first, singletons second, regular last
var sorted = nodes.slice().sort(function(a, b){
var rank = function(n){ return n.lane === "platform" ? 0 : n.lane === "singleton" ? 1 : 2; };
return rank(a) - rank(b);
});
// Assign grid positions
var cols = Math.min(sorted.length, MAX_COLS);
sorted.forEach(function(n, idx){
n._col = idx % cols;
n._row = Math.floor(idx / cols);
});
var rows = Math.ceil(sorted.length / cols);
var svgW = PAD * 2 + cols * NODE_W + (cols - 1) * GAP_X;
var svgH = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y;
// ── Create container with overflow scroll ──
var wrapper = document.createElement("div");
wrapper.style.cssText = "width:100%;height:100%;overflow:hidden;position:relative;background:#0d1117;";
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", svgW);
svg.setAttribute("height", svgH);
svg.setAttribute("viewBox", "0 0 " + svgW + " " + svgH);
svg.style.cssText = "display:block;cursor:grab;user-select:none;";
var g = document.createElementNS("http://www.w3.org/2000/svg", "g");
sorted.forEach(function(n){
var x = PAD + n._col * (NODE_W + GAP_X);
var y = PAD + n._row * (NODE_H + GAP_Y);
var cx = x + NODE_W / 2;
var cy = y + NODE_H / 2;
var bg = nodeBgColor(n.svc || "");
var bc = nodeStatusColor(n.status || "");
var isFocus = focusUid && n.id === focusUid;
// Rect
var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", x);
rect.setAttribute("y", y);
rect.setAttribute("width", NODE_W);
rect.setAttribute("height", NODE_H);
rect.setAttribute("rx", 8);
rect.setAttribute("fill", bg);
rect.setAttribute("stroke", isFocus ? "#facc15" : bc);
rect.setAttribute("stroke-width", isFocus ? 3 : 2);
rect.style.cursor = "pointer";
rect.addEventListener("click", function(e){
e.stopPropagation();
navigateToDep(n.id);
});
g.appendChild(rect);
// Name — wrapped
var nameLines = wrapText(n.label || n.id || "", 20);
var nameFs = nameLines.length > 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(", ") : "все");
var target = document.getElementById("running-graph");
if(target) target.innerHTML = '<div class="graph-loading">Собираю граф...</div>';
ensureAllGraphLoaded()
.then(function(data){ drawGraph("running-graph", data || {nodes:[], edges:[]}, ""); })
.catch(function(e){
var el2 = document.getElementById("running-graph");
if(el2) el2.innerHTML = '<div class="err-msg">' + escHtml(e.message) + '</div>';
});
}
function closeRunningGraph(){
document.getElementById("graph-modal").classList.remove("open");
}
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");
drawGraph("running-graph", data || {nodes:[], edges:[]}, focusUid || "");
}
// ── Instance graph (вкладка Граф в детали) ─────────────────────────────────
function renderInstanceGraph(uid){
var holderId = "g-" + uid;
var holder = document.getElementById(holderId);
if(holder) holder.innerHTML = '<div class="graph-loading">Строю граф...</div>';
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 = '<div class="err-msg">' + escHtml(e.message) + '</div>';
});
}
function getToken(){ function getToken(){
return localStorage.getItem("deck_token") || ""; return localStorage.getItem("deck_token") || "";
} }
@@ -1236,10 +882,6 @@ function scheduleRender(){
allInstances = []; allInstances = [];
cache = {}; cache = {};
rawDetail = {}; rawDetail = {};
graphAllData = null;
graphRunningData = null;
graphFullData = null;
graphAllKey = "";
expandedUid = null; expandedUid = null;
navBackUid = null; navBackUid = null;
var nb = document.getElementById("nav-back"); var nb = document.getElementById("nav-back");