sync: save local state before v9.7 work

This commit is contained in:
Naeel
2026-04-16 13:23:04 +03:00
parent 82979e71df
commit 0d83ea4c1e
5 changed files with 502 additions and 100 deletions
+14
View File
@@ -85,3 +85,17 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
Если есть сомнение, где выполнять команду: Если есть сомнение, где выполнять команду:
- ОСТАНОВИСЬ и спроси оператора. - ОСТАНОВИСЬ и спроси оператора.
---
🔁 VERSION & SYNC DISCIPLINE (ОБЯЗАТЕЛЬНО)
После КАЖДОГО изменения кода (любой файл с логикой/UI/API):
- ОБЯЗАТЕЛЬНО повысить версию для build/push (минимум patch).
- Перед build/push проверить, что новая версия действительно изменилась относительно предыдущей.
- Перед деплоем и после деплоя проверить, что используется именно новая версия, а не старая.
Проверка синхронизации локальной папки и ВМ:
- Считать, что `/home/naeel/remote_dev/dashboard` должен быть синхронизирован с `~/terra/dashboard` на ВМ.
- Перед build/push обязательно делать проверку синхронизации (например, сверка `sha256sum` ключевых файлов/артефактов между локальным путём и путём на ВМ).
- Если есть расхождение — ОСТАНОВИТЬСЯ, сначала устранить рассинхрон, только потом продолжать.
+1 -1
View File
@@ -15,7 +15,7 @@ spec:
spec: spec:
containers: containers:
- name: cloud-dashboard - name: cloud-dashboard
image: naeel/cloud-dashboard:v1 image: naeel/cloud-dashboard:v9.7
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8080 - containerPort: 8080
+169 -21
View File
@@ -1,8 +1,10 @@
from fastapi import FastAPI, Header, HTTPException, Request from fastapi import FastAPI, Header, HTTPException, Request
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 asyncio
import json
import time import time
app = FastAPI() app = FastAPI()
@@ -28,6 +30,53 @@ def resolve_deck_api(env_raw: str | None) -> tuple[str, str]:
return env, DECK_APIS[env] return env, DECK_APIS[env]
def _as_text(value) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, (int, float, bool)):
return str(value).strip()
if isinstance(value, dict):
for key in ("value", "displayValue", "name", "label", "id", "text"):
nested = _as_text(value.get(key))
if nested:
return nested
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 ""
async def fetch_all_instances(token: str, deck_api: str): async def fetch_all_instances(token: str, deck_api: str):
params: dict = {"page": 1, "size": 200} params: dict = {"page": 1, "size": 200}
all_results = [] all_results = []
@@ -103,11 +152,11 @@ async def get_graph(
} }
allowed_uids = set(uid_to_inst.keys()) allowed_uids = set(uid_to_inst.keys())
sem = asyncio.Semaphore(16) sem = asyncio.Semaphore(20)
async def fetch_detail(uid: str): async def fetch_detail(uid: str, client: httpx.AsyncClient):
async with sem: async with sem:
async with httpx.AsyncClient(timeout=20) as client: try:
r = await client.get( r = await client.get(
f"{deck_api}/index.cfm/instances/{uid}", f"{deck_api}/index.cfm/instances/{uid}",
headers=auth_headers(x_deck_token), headers=auth_headers(x_deck_token),
@@ -117,38 +166,86 @@ async def get_graph(
if r.status_code >= 400: if r.status_code >= 400:
return uid, None return uid, None
return uid, r.json().get("instance") return uid, r.json().get("instance")
except httpx.TimeoutException:
return uid, None
details = await asyncio.gather(*[fetch_detail(uid) for uid in allowed_uids]) async with httpx.AsyncClient(timeout=25) as shared_client:
details = await asyncio.gather(*[fetch_detail(uid, shared_client) for uid in allowed_uids])
detail_map = {uid: inst for uid, inst in details if inst} detail_map = {uid: inst for uid, inst in details if inst}
edge_set = set() # 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 dep_uid(dep_obj: dict): def _is_platform_svc(svc_name: str) -> bool:
return dep_obj.get("uid") or dep_obj.get("instanceUid") s = svc_name.strip().lower()
return any(p in s for p in _PLATFORM_PATTERNS)
for uid, inst in detail_map.items(): def _is_singleton_svc(svc_name: str) -> bool:
for dep in inst.get("dependencies", []) or []: s = svc_name.strip().lower()
src = dep_uid(dep) return any(p in s for p in _SINGLETON_PATTERNS)
if src and src in allowed_uids:
edge_set.add((src, uid)) # Build realm→platform_uid map: instances whose resourceRealmCnt > 0 ARE platforms
for dep_on in inst.get("dependentInstances", []) or []: realm_to_platform: dict[str, str] = {}
dst = dep_uid(dep_on) for uid_d, inst_d in detail_map.items():
if dst and dst in allowed_uids: cnt = inst_d.get("resourceRealmCnt") or 0
edge_set.add((uid, dst)) realm_name = inst_d.get("resourceRealm") or ""
if cnt and realm_name:
realm_to_platform[realm_name] = uid_d
# Build directed edges from dependencies/dependentInstances
# Arrow points TO the dependency (what you depend on)
edge_set: set[tuple[str, str]] = set()
for uid_d, inst_d in detail_map.items():
if uid_d not in allowed_uids:
continue
for dep in inst_d.get("dependencies", []) or []:
src = dep.get("uid") or dep.get("instanceUid") or ""
if src and src in allowed_uids and src != uid_d:
edge_set.add((uid_d, src)) # uid_d depends on src, arrow uid_d→src
for dep_on in inst_d.get("dependentInstances", []) or []:
dst = dep_on.get("uid") or dep_on.get("instanceUid") or ""
if dst and dst in allowed_uids and dst != uid_d:
edge_set.add((dst, uid_d)) # dst depends on uid_d, arrow dst→uid_d
nodes = [] nodes = []
for uid in allowed_uids: for uid_i in allowed_uids:
i = uid_to_inst.get(uid, {}) 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( nodes.append(
{ {
"id": uid, "id": uid_i,
"label": i.get("displayName") or uid, "label": i.get("displayName") or uid_i,
"status": i.get("explainedStatus") or "unknown", "status": i.get("explainedStatus") or "unknown",
"svc": i.get("svc") or "", "svc": svc,
"realm": i.get("resourceRealm") or "", "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)}, edges: {len(edge_set)}", flush=True)
for _n in nodes:
if _n["lane"] in ("platform", "singleton"):
print(f" {_n['lane'].upper()}: {_n['label']} | svc={_n['svc']}", flush=True)
edges = [{"source": s, "target": t} for s, t in sorted(edge_set)] edges = [{"source": s, "target": t} for s, t in sorted(edge_set)]
base_graph = {"nodes": nodes, "edges": edges} base_graph = {"nodes": nodes, "edges": edges}
GRAPH_CACHE[cache_key] = (now, base_graph) GRAPH_CACHE[cache_key] = (now, base_graph)
@@ -197,6 +294,57 @@ async def get_graph(
} }
@app.get("/api/instances/stream")
async def stream_instances(
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")
_, deck_api = resolve_deck_api(x_deck_env)
all_instances = await fetch_all_instances(x_deck_token, deck_api)
uid_to_inst = {
i.get("instanceUid"): i
for i in all_instances
if i.get("instanceUid")
}
async def generate():
sem = asyncio.Semaphore(20)
queue: asyncio.Queue = asyncio.Queue()
total = len(uid_to_inst)
async def fetch_one(uid: str, client: httpx.AsyncClient):
async with sem:
try:
r = await client.get(
f"{deck_api}/index.cfm/instances/{uid}",
headers=auth_headers(x_deck_token),
)
detail = None if r.status_code >= 400 else r.json().get("instance")
except Exception:
detail = None
await queue.put((uid, detail))
async with httpx.AsyncClient(timeout=25) as shared_client:
tasks = [
asyncio.create_task(fetch_one(uid, shared_client))
for uid in uid_to_inst
]
for _ in range(total):
uid, detail = await queue.get()
row = dict(uid_to_inst.get(uid, {}))
if detail:
row["_detail"] = detail
yield json.dumps(row, ensure_ascii=False) + "\n"
yield json.dumps({"_done": True, "total": total}, ensure_ascii=False) + "\n"
return StreamingResponse(generate(), media_type="application/x-ndjson")
@app.get("/api/instances/{uid}") @app.get("/api/instances/{uid}")
async def get_instance( async def get_instance(
request: Request, request: Request,
+307 -75
View File
@@ -33,6 +33,12 @@
.filters { display: flex; gap: 10px; flex-wrap: wrap; } .filters { display: flex; gap: 10px; flex-wrap: wrap; }
.filter-label { display: flex; align-items: center; gap: 5px; cursor: pointer; font-size: 12px; } .filter-label { display: flex; align-items: center; gap: 5px; cursor: pointer; font-size: 12px; }
.filter-label input { cursor: pointer; width: 18px; height: 18px; accent-color: #58a6ff; } .filter-label input { cursor: pointer; width: 18px; height: 18px; accent-color: #58a6ff; }
.data-ts { font-size: 11px; color: #8b949e; white-space: nowrap; margin-right: 8px; }
.nav-back { padding: 6px 16px; background: #161b22; color: #8b949e; font-size: 12px; border-bottom: 1px solid #30363d; }
.nav-back a { color: #58a6ff; text-decoration: none; }
.nav-back a:hover { text-decoration: underline; }
.dep-item { cursor: pointer; border-radius: 4px; transition: background .15s; }
.dep-item:hover { background: #21262d; }
.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 { padding: 5px 12px; background: #1f6feb; border: 1px solid #3b82f6; border-radius: 6px; color: #fff; cursor: pointer; font-size: 12px; }
@@ -179,10 +185,12 @@
<label class="filter-label"><input type="checkbox" value="deleted"> <span class="status-badge s-deleted">deleted</span></label> <label class="filter-label"><input type="checkbox" value="deleted"> <span class="status-badge s-deleted">deleted</span></label>
<label class="filter-label"><input type="checkbox" value="pending"> <span class="status-badge s-pending">pending</span></label> <label class="filter-label"><input type="checkbox" value="pending"> <span class="status-badge s-pending">pending</span></label>
<label class="filter-label"><input type="checkbox" value="not created"> <span class="status-badge s-default">not created</span></label> <label class="filter-label"><input type="checkbox" value="not created"> <span class="status-badge s-default">not created</span></label>
<label class="filter-label" style="margin-left:12px;border-left:1px solid #555;padding-left:12px"><input type="checkbox" id="chk-auxiliary"> <span style="font-size:12px;color:#8b949e">Служебные</span></label>
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<span id="data-ts" style="font-size:11px;color:#8b949e;"></span>
<button class="btn-refresh" id="btn-refresh">&#8635; Обновить</button> <button class="btn-refresh" id="btn-refresh">&#8635; Обновить</button>
<button class="btn-graph" id="btn-running-graph">Граф running</button> <button class="btn-graph" id="btn-running-graph">Граф зависимостей</button>
<button class="btn-theme" id="btn-theme" title="Сменить тему">🌙</button> <button class="btn-theme" id="btn-theme" title="Сменить тему">🌙</button>
<div class="header-user"> <div class="header-user">
<span class="user-email" id="user-email"></span> <span class="user-email" id="user-email"></span>
@@ -191,6 +199,7 @@
</header> </header>
<div class="main"> <div class="main">
<div class="stats" id="stats"></div> <div class="stats" id="stats"></div>
<div class="nav-back" id="nav-back" style="display:none"></div>
<div class="table-wrap" id="table-wrap"> <div class="table-wrap" id="table-wrap">
<table> <table>
<thead> <thead>
@@ -227,6 +236,7 @@
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>
<script src="https://unpkg.com/cytoscape@3.29.2/dist/cytoscape.min.js"></script> <script src="https://unpkg.com/cytoscape@3.29.2/dist/cytoscape.min.js"></script>
<script src="https://unpkg.com/cytoscape-node-html-label@1.2.2/dist/cytoscape-node-html-label.min.js"></script>
<script> <script>
(function(){ (function(){
"use strict"; "use strict";
@@ -238,8 +248,11 @@ var expandedUid = null;
var cache = {}; var cache = {};
var graphAllData = null; var graphAllData = null;
var graphRunningData = null; var graphRunningData = null;
var graphFullData = null;
var graphAllKey = ""; var graphAllKey = "";
var graphWarmupScheduled = false; var graphWarmupScheduled = false;
var graphDrawing = false;
var graphFetchInFlight = false;
function escHtml(v){ function escHtml(v){
return String(v == null ? "" : v) return String(v == null ? "" : v)
@@ -264,6 +277,8 @@ function statusColor(s){
} }
function fetchGraph(params){ function fetchGraph(params){
if(graphFetchInFlight) return Promise.resolve(null);
graphFetchInFlight = true;
var p = new URLSearchParams(); var p = new URLSearchParams();
Object.keys(params || {}).forEach(function(k){ Object.keys(params || {}).forEach(function(k){
var v = params[k]; var v = params[k];
@@ -272,113 +287,303 @@ function fetchGraph(params){
}); });
return fetch(API + "/graph?" + p.toString(), { headers: apiHeaders() }) return fetch(API + "/graph?" + p.toString(), { headers: apiHeaders() })
.then(function(r){ .then(function(r){
graphFetchInFlight = false;
if(r.status === 401){ doLogout(); return null; } if(r.status === 401){ doLogout(); return null; }
if(!r.ok) throw new Error("HTTP " + r.status); if(!r.ok) throw new Error("HTTP " + r.status);
return r.json(); return r.json();
})
.catch(function(e){
graphFetchInFlight = false;
throw e;
}); });
} }
function graphElements(data, focusUid){ 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<arr.length; i+=n) res.push(arr.slice(i, i+n));
return res;
}
// Build visual rows: each row is an array of nodes, max MAX_PER_ROW per row
// Category label rows are tracked for separators
var allRows = []; // each entry: {nodes:[], category:string}
var platRows = chunkArray(platforms, MAX_PER_ROW);
for(var p=0; p<platRows.length; p++) allRows.push({nodes: platRows[p], category: "platform"});
var singleRows = chunkArray(singletons, MAX_PER_ROW);
for(var s=0; s<singleRows.length; s++) allRows.push({nodes: singleRows[s], category: "singleton"});
var restRows = chunkArray(rest, MAX_PER_ROW);
for(var rr=0; rr<restRows.length; rr++) allRows.push({nodes: restRows[rr], category: "regular"});
if(allRows.length === 0) allRows = [{nodes: nodes.slice(0, MAX_PER_ROW), category: "regular"}];
var usableWidth = Math.max(860, containerWidth || 0);
var positions = {};
var laneById = {};
var nodeW = 200;
var gapX = 50;
var gapY = 160;
var categoryGap = 80; // extra gap between categories
var currentY = 60;
var prevCategory = "";
for(var r=0; r<allRows.length; r++){
var row = allRows[r];
// Add extra gap when switching category
if(prevCategory && row.category !== prevCategory) currentY += categoryGap;
prevCategory = row.category;
var items = row.nodes;
var totalW = items.length * nodeW + (items.length - 1) * gapX;
var xStart = Math.max(40, (usableWidth - totalW) / 2);
for(var i=0; i<items.length; i++){
positions[items[i].id] = { x: xStart + i * (nodeW + gapX), y: currentY };
laneById[items[i].id] = r;
}
currentY += gapY;
}
data._filteredNodes = filtered;
return { positions: positions, laneById: laneById };
}
function edgeColorForRows(sourceRow, targetRow, edgeIndex){
var palette = ["#e11d48", "#0ea5e9", "#22c55e", "#f59e0b", "#a855f7", "#14b8a6", "#f97316"];
var s = Number.isFinite(sourceRow) ? sourceRow : 0;
var t = Number.isFinite(targetRow) ? targetRow : 0;
var idx = Math.abs((s * 11) + (t * 17) + edgeIndex) % palette.length;
return palette[idx];
}
// Service-based background colors for graph nodes
var SVC_COLORS = {
"cloud director": "#1e3a5f",
"kubernetes": "#2d4a22",
"object storage": "#4a3728",
"s3": "#4a3728",
"dns": "#3b2d50",
"container registry": "#2a3f4f",
"apache nifi": "#4a3020",
"postgresql": "#1a3550",
"rabbitmq": "#4a3040",
"grafana": "#3a2a10",
"keycloak": "#2a3a3a",
"terraform": "#2a2050",
"fission": "#3a2040",
"edge gateway": "#1a4040"
};
function svcBgColor(svc){
var s = (svc || "").trim().toLowerCase();
for(var k in SVC_COLORS){ if(s.indexOf(k) !== -1) return SVC_COLORS[k]; }
// Hash fallback
var h = 0;
for(var i=0; i<s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
var hue = ((h % 360) + 360) % 360;
return "hsl(" + hue + ",30%,18%)";
}
// Compute human-readable age from ISO date string
function humanAge(dtStr){
if(!dtStr) return "";
var d;
try { d = new Date(dtStr); } catch(e){ return ""; }
if(isNaN(d.getTime())) return "";
var diff = Date.now() - d.getTime();
if(diff < 0) return "";
var mins = Math.floor(diff / 60000);
var hrs = Math.floor(mins / 60);
var days = Math.floor(hrs / 24);
var months = Math.floor(days / 30);
var years = Math.floor(days / 365);
if(years >= 1) return years + "г " + (months % 12) + "м";
if(months >= 1) return months + "м " + (days % 30) + "д";
if(days >= 1) return days + "д";
if(hrs >= 1) return hrs + "ч";
return mins + "мин";
}
function graphElements(data, focusUid, laneById){
var elems = []; var elems = [];
(data.nodes || []).forEach(function(n){ (data.nodes || []).forEach(function(n){
var label = (n.label || n.id || ""); var name = (n.label || n.id || "");
var labelLen = label.length; var svc = n.svc || "";
var textMaxWidth = Math.max(170, Math.min(320, labelLen * 8)); var age = humanAge(n.created);
var width = Math.max(190, Math.min(360, textMaxWidth + 26)); // Build label: name + svc + (2 blank lines) + age (small font)
var lineChars = Math.max(10, Math.floor(textMaxWidth / 8)); var label = name;
var lines = Math.max(1, Math.min(4, Math.ceil(labelLen / lineChars))); if(svc) label += "\n" + svc;
var height = Math.max(64, Math.min(150, 36 + (lines * 20))); if(age) label += "\n\n\u23f1 " + age;
var fontSize = labelLen > 48 ? 14 : labelLen > 32 ? 16 : 18; // Fixed node width; text wraps inside
var nodeFixedW = 200;
var textMaxWidth = nodeFixedW - 20;
// Estimate lines needed for name wrapping
var charsPerLine = Math.floor(textMaxWidth / 8);
var nameLines = Math.max(1, Math.ceil(name.length / charsPerLine));
var totalLines = nameLines + (svc ? 1 : 0) + (age ? 3 : 0);
var height = Math.max(50, 16 + totalLines * 20);
var fontSize = age ? (name.length > 36 ? 10 : name.length > 24 ? 11 : 12) : (name.length > 36 ? 11 : name.length > 24 ? 12 : 13);
var bgColor = svcBgColor(svc);
elems.push({ elems.push({
data: { data: {
id: n.id, id: n.id,
label: label, label: n.label || n.id,
fullLabel: n.label || n.id, fullLabel: n.label || n.id,
status: n.status || "unknown", status: n.status || "unknown",
color: statusColor(n.status || ""), color: statusColor(n.status || ""),
width: width, bgColor: bgColor,
width: nodeFixedW,
height: height, height: height,
fontSize: fontSize, fontSize: fontSize,
textMaxWidth: textMaxWidth textMaxWidth: textMaxWidth,
svc: svc,
age: age
}, },
classes: (focusUid && n.id === focusUid) ? "focus" : "" classes: (focusUid && n.id === focusUid) ? "focus" : ""
}); });
}); });
(data.edges || []).forEach(function(e, i){ (data.edges || []).forEach(function(e, i){
elems.push({ data: { id: "e" + i + "-" + e.source + "-" + e.target, source: e.source, target: e.target } }); var sourceRow = Number.isFinite(laneById[e.source]) ? laneById[e.source] : 0;
var targetRow = Number.isFinite(laneById[e.target]) ? laneById[e.target] : 0;
elems.push({
data: {
id: "e" + i + "-" + e.source + "-" + e.target,
source: e.source,
target: e.target,
sourceRow: sourceRow,
targetRow: targetRow,
color: edgeColorForRows(sourceRow, targetRow, i)
}
});
}); });
return elems; return elems;
} }
function drawGraph(containerId, data, focusUid){ function drawGraph(containerId, data, focusUid, forceShowAux){
if(graphDrawing) return;
graphDrawing = true;
var el = document.getElementById(containerId); var el = document.getElementById(containerId);
if(!el || !window.cytoscape) return; if(!el || !window.cytoscape) { graphDrawing = false; return; }
el.innerHTML = ""; el.innerHTML = "";
if((data.nodes || []).length === 0){ if((data.nodes || []).length === 0){
el.innerHTML = '<div class="empty-msg" style="padding:14px">Связей не найдено</div>'; el.innerHTML = '<div class="empty-msg" style="padding:14px">Загрузка графа... Если долго — обновите страницу.</div>';
graphDrawing = false;
return; return;
} }
var nCount = (data.nodes || []).length; var layoutMeta = buildRowLayout(data, el.clientWidth || 1200, forceShowAux);
var layoutCfg; var posMap = layoutMeta.positions;
if(nCount <= 2){ // Use filtered nodes if available
layoutCfg = { var visibleNodes = data._filteredNodes || data.nodes || [];
name: "grid", var visibleIds = new Set(visibleNodes.map(function(n){ return n.id; }));
fit: true, var filteredData = {
padding: 12, nodes: visibleNodes,
avoidOverlap: true, edges: (data.edges || []).filter(function(e){ return visibleIds.has(e.source) && visibleIds.has(e.target); })
rows: 1,
cols: nCount
}; };
} else { var layoutCfg = {
var cols = nCount <= 8 ? 3 : nCount <= 18 ? 4 : 5; name: "preset",
layoutCfg = {
name: "grid",
fit: true, fit: true,
padding: 12, padding: 24,
avoidOverlap: true, positions: function(node){ return posMap[node.id()] || {x: 0, y: 0}; },
avoidOverlapPadding: 18, animate: false
nodeDimensionsIncludeLabels: true,
animate: false,
condense: true,
cols: cols
}; };
}
var cy = cytoscape({ var cy = cytoscape({
container: el, container: el,
elements: graphElements(data, focusUid), elements: graphElements(filteredData, focusUid, layoutMeta.laneById),
autoungrabify: true,
style: [ style: [
{ selector: "node", style: { { selector: "node", style: {
"shape": "round-rectangle", "shape": "round-rectangle",
"background-color": "data(color)", "background-color": "data(bgColor)",
"label": "data(label)", "label": "",
"font-size": "data(fontSize)",
"font-weight": 600,
"color": "#e6edf3",
"text-wrap": "wrap",
"text-max-width": "data(textMaxWidth)",
"text-valign": "center",
"text-halign": "center",
"width": "data(width)", "width": "data(width)",
"height": "data(height)", "height": "data(height)",
"padding": "10px", "padding": "10px",
"border-color": "#0d1117", "border-color": "data(color)",
"border-width": 2 "border-width": 2.4
}}, }},
{ selector: "node.focus", style: { "border-color": "#facc15", "border-width": 3 }}, { selector: "node.focus", style: { "border-color": "#facc15", "border-width": 3 }},
{ selector: "edge", style: { { selector: "edge", style: {
"line-color": "#9fc2ff", "line-color": "data(color)",
"target-arrow-color": "#9fc2ff", "target-arrow-color": "data(color)",
"target-arrow-shape": "triangle", "target-arrow-shape": "triangle",
"curve-style": "bezier", "curve-style": "taxi",
"width": 3.2, "taxi-direction": "auto",
"arrow-scale": 1.8, "taxi-turn": "50px",
"opacity": 1 "taxi-turn-min-distance": "10px",
"width": 2.4,
"arrow-scale": 1.3,
"opacity": 0.9
}} }}
], ],
layout: layoutCfg layout: layoutCfg
}); });
// HTML labels: render name+svc normally, age as small italic
if(cy.nodeHtmlLabel){
cy.nodeHtmlLabel([{
query: 'node',
halign: 'center',
valign: 'center',
halignBox: 'center',
valignBox: 'center',
tpl: function(d){
var fs = d.fontSize || 13;
var html = '<div style="text-align:center;color:#dbeafe;font-family:system-ui,sans-serif;padding:4px 6px;">';
html += '<div style="font-size:'+fs+'px;font-weight:600;word-break:break-word;white-space:normal;">' + (d.label||'') + '</div>';
if(d.svc) html += '<div style="font-size:'+(fs-1)+'px;margin-top:2px;">' + d.svc + '</div>';
if(d.age) html += '<div style="font-size:9px;font-style:italic;color:#9ca3af;margin-top:14px;">⏱ ' + d.age + '</div>';
html += '</div>';
return html;
}
}]);
}
// Ensure proper viewport after tab activation/layout changes. // Ensure proper viewport after tab activation/layout changes.
var refit = function(){ var refit = function(){
try { cy.resize(); cy.fit(undefined, 24); } catch(e) { /* noop */ } try { cy.resize(); cy.fit(undefined, 24); } catch(e) { /* noop */ }
@@ -386,6 +591,8 @@ function drawGraph(containerId, data, focusUid){
setTimeout(refit, 0); setTimeout(refit, 0);
setTimeout(refit, 120); setTimeout(refit, 120);
cy.on("layoutstop", refit); cy.on("layoutstop", refit);
// Release draw guard after layout settles.
setTimeout(function(){ graphDrawing = false; }, 300);
cy.on("tap", "node", function(evt){ cy.on("tap", "node", function(evt){
var n = evt.target.data(); var n = evt.target.data();
@@ -410,23 +617,30 @@ function ensureAllGraphLoaded(){
}); });
} }
function ensureFullGraphLoaded(){
if(graphFullData) return Promise.resolve(graphFullData);
return fetchGraph({}).then(function(data){
graphFullData = data || {nodes:[], edges:[]};
return graphFullData;
});
}
function renderInstanceGraph(uid){ function renderInstanceGraph(uid){
var holderId = "g-" + uid; var holderId = "g-" + uid;
var holder = document.getElementById(holderId); var holder = document.getElementById(holderId);
if(holder) holder.innerHTML = '<div class="graph-loading">Строю граф зависимостей...</div>'; if(holder) holder.innerHTML = '<div class="graph-loading">Строю граф зависимостей...</div>';
// Wait one frame so the graph panel becomes visible and gets real dimensions.
var run = function(){ var run = function(){
ensureAllGraphLoaded().then(function(){ ensureFullGraphLoaded().then(function(){
var g = buildClientSubgraph(graphAllData || {nodes:[], edges:[]}, uid, 3); var g = buildClientSubgraph(graphFullData || {nodes:[], edges:[]}, uid, 3);
if((g.nodes || []).length <= 1 && graphAllData){ if((g.nodes || []).length <= 1 && graphFullData){
var near = buildClientSubgraph(graphAllData, uid, 1); var near = buildClientSubgraph(graphFullData, uid, 1);
if((near.nodes || []).length > (g.nodes || []).length) g = near; if((near.nodes || []).length > (g.nodes || []).length) g = near;
} }
if((g.nodes || []).length > 2){ if((g.nodes || []).length > 2){
if(holder) holder.innerHTML = '<div class="graph-inline-note">Граф открыт почти на весь экран для удобства чтения.</div>'; if(holder) holder.innerHTML = '<div class="graph-inline-note">Граф открыт почти на весь экран для удобства чтения.</div>';
showGraphModal("Граф зависимостей: " + escHtml(instanceName(uid)), g, uid); showGraphModal("Граф зависимостей: " + escHtml(instanceName(uid)), g, uid, true);
} else { } else {
drawGraph(holderId, g, uid); drawGraph(holderId, g, uid, true);
} }
}).catch(function(e){ }).catch(function(e){
var el = document.getElementById(holderId); var el = document.getElementById(holderId);
@@ -487,13 +701,12 @@ function openRunningGraph(){
var modal = document.getElementById("graph-modal"); var modal = document.getElementById("graph-modal");
modal.classList.add("open"); modal.classList.add("open");
var titleEl = document.getElementById("graph-modal-title"); var titleEl = document.getElementById("graph-modal-title");
if(titleEl) titleEl.textContent = "Общий граф зависимостей: running"; var statuses = getChecked();
if(titleEl) titleEl.textContent = "Общий граф зависимостей" + (statuses.length ? ": " + statuses.join(", ") : ": все");
var target = document.getElementById("running-graph"); var target = document.getElementById("running-graph");
if(target) target.innerHTML = '<div class="graph-loading">Собираю общий граф running...</div>'; if(target) target.innerHTML = '<div class="graph-loading">Собираю граф...</div>';
var run = function(data){ drawGraph("running-graph", data || {nodes:[], edges:[]}, ""); }; ensureAllGraphLoaded()
if(graphRunningData) { run(graphRunningData); return; } .then(function(data){ drawGraph("running-graph", data || {nodes:[], edges:[]}, ""); })
fetchGraph({ status: ["running"] })
.then(function(data){ graphRunningData = data || {nodes:[], edges:[]}; run(graphRunningData); })
.catch(function(e){ .catch(function(e){
var el = document.getElementById("running-graph"); var el = document.getElementById("running-graph");
if(el) el.innerHTML = '<div class="err-msg">' + escHtml(e.message) + '</div>'; if(el) el.innerHTML = '<div class="err-msg">' + escHtml(e.message) + '</div>';
@@ -504,12 +717,12 @@ function closeRunningGraph(){
document.getElementById("graph-modal").classList.remove("open"); document.getElementById("graph-modal").classList.remove("open");
} }
function showGraphModal(title, data, focusUid){ function showGraphModal(title, data, focusUid, forceShowAux){
var modal = document.getElementById("graph-modal"); var modal = document.getElementById("graph-modal");
var titleEl = document.getElementById("graph-modal-title"); var titleEl = document.getElementById("graph-modal-title");
if(titleEl) titleEl.textContent = title; if(titleEl) titleEl.textContent = title;
modal.classList.add("open"); modal.classList.add("open");
drawGraph("running-graph", data || {nodes:[], edges:[]}, focusUid || ""); drawGraph("running-graph", data || {nodes:[], edges:[]}, focusUid || "", forceShowAux);
} }
function scheduleGraphWarmup(){ function scheduleGraphWarmup(){
@@ -522,9 +735,9 @@ function scheduleGraphWarmup(){
}).catch(function(){ /* ignore */ }); }).catch(function(){ /* ignore */ });
}; };
if(window.requestIdleCallback){ if(window.requestIdleCallback){
requestIdleCallback(warm, { timeout: 12000 }); requestIdleCallback(warm, { timeout: 3000 });
} else { } else {
setTimeout(warm, 12000); setTimeout(warm, 3000);
} }
} }
@@ -799,6 +1012,8 @@ function loadInstances(){
allInstances = data.instances || []; allInstances = data.instances || [];
renderStats(allInstances); renderStats(allInstances);
render(allInstances); render(allInstances);
var ts = document.getElementById("data-ts");
if(ts) ts.textContent = "Данные на: " + new Date().toLocaleTimeString("ru-RU", {hour:"2-digit",minute:"2-digit",second:"2-digit"});
}) })
.catch(function(e){ .catch(function(e){
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"7\" class=\"err-msg\">" + e.message + "</td></tr>"; document.getElementById("tbody").innerHTML = "<tr><td colspan=\"7\" class=\"err-msg\">" + e.message + "</td></tr>";
@@ -872,11 +1087,28 @@ function initApp(preloaded){
} }
document.getElementById("btn-login").addEventListener("click", doLogin); document.getElementById("btn-login").addEventListener("click", doLogin);
document.getElementById("btn-refresh").addEventListener("click", loadInstances); document.getElementById("btn-refresh").addEventListener("click", function(){
// Reset all caches so data is fetched fresh
allInstances = [];
cache = {};
graphAllData = null;
graphAllKey = "";
graphRunningData = null;
graphFullData = null;
graphWarmupScheduled = false;
loadInstances();
});
document.getElementById("btn-running-graph").addEventListener("click", openRunningGraph); document.getElementById("btn-running-graph").addEventListener("click", openRunningGraph);
document.getElementById("btn-theme").addEventListener("click", toggleTheme); document.getElementById("btn-theme").addEventListener("click", toggleTheme);
document.getElementById("btn-close-graph").addEventListener("click", closeRunningGraph); document.getElementById("btn-close-graph").addEventListener("click", closeRunningGraph);
document.getElementById("btn-exit").addEventListener("click", doLogout); document.getElementById("btn-exit").addEventListener("click", doLogout);
document.getElementById("chk-auxiliary").addEventListener("change", function(){
// Redraw current graph with updated auxiliary filter
if(graphRunningData){
graphDrawing = false;
drawGraph("running-graph", graphRunningData, "");
}
});
document.getElementById("graph-modal").addEventListener("click", function(e){ document.getElementById("graph-modal").addEventListener("click", function(e){
if(e.target && e.target.id === "graph-modal") closeRunningGraph(); if(e.target && e.target.id === "graph-modal") closeRunningGraph();
}); });
+8
View File
@@ -0,0 +1,8 @@
test
https://deck-api-test.ngcloud.ru/api/v1
api_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWNjMjY4LTZjNmEtNzgxZS04NjEzLTRiZWQ0ZWM3Y2QyMCIsImV4cCI6MTc4OTAxNTg2NywiaWF0IjoxNzczNDYzODY3LCJqdGkiOiJmYjRkMGFiNy1lYTI1LTQ5YmItYmQ3OC0zYjBiNmRiZTdhNzkiLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiIiLCJjb21wYW55X2lkIjoiIiwiZ3JvdXBzIjpudWxsLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiIiLCJnaXZlbl9uYW1lIjoiIiwiZmFtaWx5X25hbWUiOiIiLCJlbWFpbCI6InRhemV0ZGlub3ZuQGdtYWlsLmNvbSJ9.xPxWUWA8e_GEUQ8bgpuHOZvoY7Xu2udRtGPrpHZrzP3Z5uhX-NqDvVDDZQRSpuqoAeXjmekTxX0kHFvwEmv-Kd1hnQZJA_nJf3DvSVPYErtu4ePgy4U2N-4uwlMvjtRysxv17SMEPaDP4XAKj6SxDZ8eEtRCnrTJpgxwh-2cz1NRUsH99pIBY0doj7yNkPOQnnrOCQpnzWdX-d1I1ERCwGg10qOa5VpxP3yXoc2PuDMF6gFU_1VxlhCEdL94TTlFQDPdaBmaDk6hDwUq6T7RZk4cRQ8eao10tUWLHucfaMBGtKEutgrbqR8kOmMB6wY3x86TXdZfBS0zr7RO137d-g"
prod
https://deck-api.ngcloud.ru/api/v1
api_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOTllMzI1LTFjZGYtN2NkYS05MzE5LWU1MzAyYTg1ZTI5MSIsImV4cCI6MTc4NjkzMjI2MCwiaWF0IjoxNzcxMzgwMjYwLCJqdGkiOiIzOTQ3ZTgyMy0yNjljLTQ0MTAtYmU0My1iNGVkNTc1Njg0ZTQiLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiIiLCJncm91cHMiOm51bGwsInByZWZlcnJlZF91c2VybmFtZSI6IiIsImdpdmVuX25hbWUiOiIiLCJmYW1pbHlfbmFtZSI6IiIsImVtYWlsIjoidGF6ZXRAbmFyb2QucnUifQ.hzpIIqNWkKIoYUXDaLY7DLyGKH70rz0ZTqanv19qxF10i3N1t1g_KknA4Qsw1MduTyLzIz7y5SRSr4PSQ1gzR0vB_C0GudSFUhyBNNKkS4ClhRDWW9eN_IIEljbiJMLQi2L07XJ7Y5DQ0sIHRAPLkreCDFMKQ0yTCrKoScCJIDuUqzaTcOaX-hfjaxW8iV0SZMDxl0C5O3tke0btxkaLBaAcWH0V-1yu2r2m29fyU33FqikF0xAcDXiuZphfsrShKQYArZjKAphYCP_Vpmr-1sdjinkn8sPSk1qZny0rka8G6WVZUGaZSOnW8SYNLVUwdqtuQmK-Y18o7U0Suzrsjg"