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
+310 -78
View File
@@ -33,6 +33,12 @@
.filters { display: flex; gap: 10px; flex-wrap: wrap; }
.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; }
.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:hover { background: #30363d; }
.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="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" 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 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-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>
<div class="header-user">
<span class="user-email" id="user-email"></span>
@@ -191,6 +199,7 @@
</header>
<div class="main">
<div class="stats" id="stats"></div>
<div class="nav-back" id="nav-back" style="display:none"></div>
<div class="table-wrap" id="table-wrap">
<table>
<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;}};
</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>
(function(){
"use strict";
@@ -238,8 +248,11 @@ var expandedUid = null;
var cache = {};
var graphAllData = null;
var graphRunningData = null;
var graphFullData = null;
var graphAllKey = "";
var graphWarmupScheduled = false;
var graphDrawing = false;
var graphFetchInFlight = false;
function escHtml(v){
return String(v == null ? "" : v)
@@ -264,6 +277,8 @@ function statusColor(s){
}
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];
@@ -272,113 +287,303 @@ function fetchGraph(params){
});
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 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 = [];
(data.nodes || []).forEach(function(n){
var label = (n.label || n.id || "");
var labelLen = label.length;
var textMaxWidth = Math.max(170, Math.min(320, labelLen * 8));
var width = Math.max(190, Math.min(360, textMaxWidth + 26));
var lineChars = Math.max(10, Math.floor(textMaxWidth / 8));
var lines = Math.max(1, Math.min(4, Math.ceil(labelLen / lineChars)));
var height = Math.max(64, Math.min(150, 36 + (lines * 20)));
var fontSize = labelLen > 48 ? 14 : labelLen > 32 ? 16 : 18;
var name = (n.label || n.id || "");
var svc = n.svc || "";
var age = humanAge(n.created);
// Build label: name + svc + (2 blank lines) + age (small font)
var label = name;
if(svc) label += "\n" + svc;
if(age) label += "\n\n\u23f1 " + age;
// 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({
data: {
id: n.id,
label: label,
label: n.label || n.id,
fullLabel: n.label || n.id,
status: n.status || "unknown",
color: statusColor(n.status || ""),
width: width,
bgColor: bgColor,
width: nodeFixedW,
height: height,
fontSize: fontSize,
textMaxWidth: textMaxWidth
textMaxWidth: textMaxWidth,
svc: svc,
age: age
},
classes: (focusUid && n.id === focusUid) ? "focus" : ""
});
});
(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;
}
function drawGraph(containerId, data, focusUid){
function drawGraph(containerId, data, focusUid, forceShowAux){
if(graphDrawing) return;
graphDrawing = true;
var el = document.getElementById(containerId);
if(!el || !window.cytoscape) return;
if(!el || !window.cytoscape) { graphDrawing = false; return; }
el.innerHTML = "";
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;
}
var nCount = (data.nodes || []).length;
var layoutCfg;
if(nCount <= 2){
layoutCfg = {
name: "grid",
fit: true,
padding: 12,
avoidOverlap: true,
rows: 1,
cols: nCount
};
} else {
var cols = nCount <= 8 ? 3 : nCount <= 18 ? 4 : 5;
layoutCfg = {
name: "grid",
fit: true,
padding: 12,
avoidOverlap: true,
avoidOverlapPadding: 18,
nodeDimensionsIncludeLabels: true,
animate: false,
condense: true,
cols: cols
};
}
var layoutMeta = buildRowLayout(data, el.clientWidth || 1200, forceShowAux);
var posMap = layoutMeta.positions;
// Use filtered nodes if available
var visibleNodes = data._filteredNodes || data.nodes || [];
var visibleIds = new Set(visibleNodes.map(function(n){ return n.id; }));
var filteredData = {
nodes: visibleNodes,
edges: (data.edges || []).filter(function(e){ return visibleIds.has(e.source) && visibleIds.has(e.target); })
};
var layoutCfg = {
name: "preset",
fit: true,
padding: 24,
positions: function(node){ return posMap[node.id()] || {x: 0, y: 0}; },
animate: false
};
var cy = cytoscape({
container: el,
elements: graphElements(data, focusUid),
elements: graphElements(filteredData, focusUid, layoutMeta.laneById),
autoungrabify: true,
style: [
{ selector: "node", style: {
"shape": "round-rectangle",
"background-color": "data(color)",
"label": "data(label)",
"font-size": "data(fontSize)",
"font-weight": 600,
"color": "#e6edf3",
"text-wrap": "wrap",
"text-max-width": "data(textMaxWidth)",
"text-valign": "center",
"text-halign": "center",
"background-color": "data(bgColor)",
"label": "",
"width": "data(width)",
"height": "data(height)",
"padding": "10px",
"border-color": "#0d1117",
"border-width": 2
"border-color": "data(color)",
"border-width": 2.4
}},
{ selector: "node.focus", style: { "border-color": "#facc15", "border-width": 3 }},
{ selector: "edge", style: {
"line-color": "#9fc2ff",
"target-arrow-color": "#9fc2ff",
"line-color": "data(color)",
"target-arrow-color": "data(color)",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
"width": 3.2,
"arrow-scale": 1.8,
"opacity": 1
"curve-style": "taxi",
"taxi-direction": "auto",
"taxi-turn": "50px",
"taxi-turn-min-distance": "10px",
"width": 2.4,
"arrow-scale": 1.3,
"opacity": 0.9
}}
],
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.
var refit = function(){
try { cy.resize(); cy.fit(undefined, 24); } catch(e) { /* noop */ }
@@ -386,6 +591,8 @@ function drawGraph(containerId, data, focusUid){
setTimeout(refit, 0);
setTimeout(refit, 120);
cy.on("layoutstop", refit);
// Release draw guard after layout settles.
setTimeout(function(){ graphDrawing = false; }, 300);
cy.on("tap", "node", function(evt){
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){
var holderId = "g-" + uid;
var holder = document.getElementById(holderId);
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(){
ensureAllGraphLoaded().then(function(){
var g = buildClientSubgraph(graphAllData || {nodes:[], edges:[]}, uid, 3);
if((g.nodes || []).length <= 1 && graphAllData){
var near = buildClientSubgraph(graphAllData, uid, 1);
ensureFullGraphLoaded().then(function(){
var g = buildClientSubgraph(graphFullData || {nodes:[], edges:[]}, uid, 3);
if((g.nodes || []).length <= 1 && graphFullData){
var near = buildClientSubgraph(graphFullData, uid, 1);
if((near.nodes || []).length > (g.nodes || []).length) g = near;
}
if((g.nodes || []).length > 2){
if(holder) holder.innerHTML = '<div class="graph-inline-note">Граф открыт почти на весь экран для удобства чтения.</div>';
showGraphModal("Граф зависимостей: " + escHtml(instanceName(uid)), g, uid);
showGraphModal("Граф зависимостей: " + escHtml(instanceName(uid)), g, uid, true);
} else {
drawGraph(holderId, g, uid);
drawGraph(holderId, g, uid, true);
}
}).catch(function(e){
var el = document.getElementById(holderId);
@@ -487,13 +701,12 @@ function openRunningGraph(){
var modal = document.getElementById("graph-modal");
modal.classList.add("open");
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");
if(target) target.innerHTML = '<div class="graph-loading">Собираю общий граф running...</div>';
var run = function(data){ drawGraph("running-graph", data || {nodes:[], edges:[]}, ""); };
if(graphRunningData) { run(graphRunningData); return; }
fetchGraph({ status: ["running"] })
.then(function(data){ graphRunningData = data || {nodes:[], edges:[]}; run(graphRunningData); })
if(target) target.innerHTML = '<div class="graph-loading">Собираю граф...</div>';
ensureAllGraphLoaded()
.then(function(data){ drawGraph("running-graph", data || {nodes:[], edges:[]}, ""); })
.catch(function(e){
var el = document.getElementById("running-graph");
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");
}
function showGraphModal(title, data, focusUid){
function showGraphModal(title, data, focusUid, forceShowAux){
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 || "");
drawGraph("running-graph", data || {nodes:[], edges:[]}, focusUid || "", forceShowAux);
}
function scheduleGraphWarmup(){
@@ -522,9 +735,9 @@ function scheduleGraphWarmup(){
}).catch(function(){ /* ignore */ });
};
if(window.requestIdleCallback){
requestIdleCallback(warm, { timeout: 12000 });
requestIdleCallback(warm, { timeout: 3000 });
} else {
setTimeout(warm, 12000);
setTimeout(warm, 3000);
}
}
@@ -799,6 +1012,8 @@ function loadInstances(){
allInstances = data.instances || [];
renderStats(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){
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-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-theme").addEventListener("click", toggleTheme);
document.getElementById("btn-close-graph").addEventListener("click", closeRunningGraph);
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){
if(e.target && e.target.id === "graph-modal") closeRunningGraph();
});