v9.44: remove all graph code (backend endpoint, frontend SVG/panzoom/CSS, ~536 lines dead code)
This commit is contained in:
+1
-1
@@ -15,7 +15,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: cloud-dashboard
|
||||
image: naeel/cloud-dashboard:v9.43
|
||||
image: naeel/cloud-dashboard:v9.44
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
|
||||
@@ -3,7 +3,6 @@ from fastapi.responses import StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import httpx
|
||||
from typing import Annotated
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
@@ -16,8 +15,6 @@ DECK_APIS = {
|
||||
"test": "https://deck-api-test.ngcloud.ru/api/v1",
|
||||
}
|
||||
DEFAULT_DECK_ENV = "test"
|
||||
CACHE_TTL_SECONDS = 180
|
||||
GRAPH_CACHE: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
# Centralized backend policy for table-related derived fields.
|
||||
DELETE_POLICY = {
|
||||
@@ -55,38 +52,6 @@ def _as_text(value) -> str:
|
||||
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:
|
||||
if not value:
|
||||
return None
|
||||
@@ -204,146 +169,6 @@ async def list_instances(
|
||||
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")
|
||||
async def stream_instances(
|
||||
request: Request,
|
||||
|
||||
+2
-360
@@ -36,12 +36,8 @@
|
||||
.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: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: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; }
|
||||
.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; }
|
||||
@@ -100,20 +96,6 @@
|
||||
body.light .panel-history { background: #dcfce7; border-top: 2px solid #4ade80; }
|
||||
body.light .panel-deps { background: #fef3c7; border-top: 2px solid #f59e0b; }
|
||||
.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-section { min-width: 0; }
|
||||
.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-sub, body.light .user-email { color: #475569; }
|
||||
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:hover, body.light .btn-theme:hover, body.light .btn-exit:hover, body.light .btn-graph:hover { background: #e2e8f0; }
|
||||
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 { background: #e2e8f0; }
|
||||
body.light .main { background: #f3f6fb; }
|
||||
body.light .stat-card { background: #ffffff; border-color: #cbd5e1; }
|
||||
body.light .stat-card .lbl { color: #64748b; }
|
||||
@@ -199,10 +181,6 @@
|
||||
body.light .tabs { border-bottom-color: #b6c6e8; background: #dfe9fa; }
|
||||
body.light .tab { color: #475569; }
|
||||
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:nth-child(odd) { background: rgba(148, 163, 184, .12); }
|
||||
body.light .p-item:nth-child(even) { background: rgba(148, 163, 184, .18); }
|
||||
@@ -304,7 +282,6 @@
|
||||
</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>
|
||||
<script src="https://cdn.jsdelivr.net/npm/panzoom@9.4.3/dist/panzoom.min.js"></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;}};
|
||||
</script>
|
||||
@@ -318,11 +295,6 @@ var sortCol = "displayName";
|
||||
var sortDir = 1;
|
||||
var expandedUid = null;
|
||||
var cache = {};
|
||||
var graphAllData = null;
|
||||
var graphRunningData = null;
|
||||
var graphFullData = null;
|
||||
var graphAllKey = "";
|
||||
var graphFetchInFlight = false;
|
||||
var rawDetail = {};
|
||||
var navBackUid = 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(){
|
||||
return localStorage.getItem("deck_token") || "";
|
||||
}
|
||||
@@ -1236,10 +882,6 @@ function scheduleRender(){
|
||||
allInstances = [];
|
||||
cache = {};
|
||||
rawDetail = {};
|
||||
graphAllData = null;
|
||||
graphRunningData = null;
|
||||
graphFullData = null;
|
||||
graphAllKey = "";
|
||||
expandedUid = null;
|
||||
navBackUid = null;
|
||||
var nb = document.getElementById("nav-back");
|
||||
|
||||
Reference in New Issue
Block a user