Files
dashboard/static/index.html
T

1237 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nubes Cloud Dashboard</title>
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png" type="image/png">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e1e4e8; margin: 0; height: 100vh; overflow: hidden; }
#app { display: flex; flex-direction: column; height: 100vh; }
#login-screen { position: fixed; inset: 0; background: #0f1117; display: flex; align-items: center; justify-content: center; z-index: 1000; }
#login-screen.hidden { display: none; }
.login-box { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 40px 48px; width: 400px; max-width: 90vw; }
.login-logo { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; justify-content: center; }
.login-logo span { font-size: 22px; font-weight: 700; color: #fff; letter-spacing: .04em; }
.login-box h2 { font-size: 16px; font-weight: 600; color: #e1e4e8; margin-bottom: 6px; text-align: center; }
.login-box p { font-size: 12px; color: #8b949e; margin-bottom: 20px; text-align: center; }
.login-box label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 6px; }
.login-box select { width: 100%; height: 36px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 13px; padding: 0 10px; margin-bottom: 10px; outline: none; }
.login-box select:focus { border-color: #58a6ff; }
.login-box textarea { width: 100%; height: 80px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 12px; padding: 10px; resize: none; outline: none; font-family: monospace; }
.login-box textarea:focus { border-color: #58a6ff; }
.btn-primary { width: 100%; padding: 10px; background: #238636; border: none; border-radius: 6px; color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; margin-top: 12px; }
.btn-primary:hover { background: #2ea043; }
.login-err { color: #f85149; font-size: 12px; text-align: center; margin-top: 8px; display: none; }
header { padding: 12px 24px; background: #161b22; border-bottom: 1px solid #30363d; display: flex; align-items: center; gap: 16px; flex-shrink: 0; flex-wrap: wrap; }
.header-logo { display: flex; align-items: center; gap: 9px; }
.brand-logo { height: 26px; width: auto; background: #fff; border-radius: 4px; padding: 2px 6px; }
.header-logo .logo-text { font-size: 15px; font-weight: 700; color: #fff; letter-spacing: .04em; }
.header-logo .logo-sub { font-size: 11px; color: #58a6ff; font-weight: 400; margin-left: 4px; }
.spacer { flex: 1; }
.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; }
.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; }
.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; }
.btn-exit:hover { background: #3d1a1a; border-color: #f85149; }
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.s-running { background: #1a4a2e; color: #56d364; }
.s-suspended { background: #3d2a00; color: #e3b341; }
.s-deleted { background: #3d1a1a; color: #f85149; }
.s-deleting { background: #3d1a1a; color: #f85149; }
.s-pending { background: #1a2a3d; color: #58a6ff; }
.s-creating { background: #1a2a3d; color: #58a6ff; }
.s-default { background: #2d2d2d; color: #8b949e; }
.main { padding: 20px 24px; flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.stats { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 8px 16px; text-align: center; min-width: 90px; }
.stat-card .num { font-size: 22px; font-weight: 700; }
.stat-card .lbl { font-size: 11px; color: #8b949e; margin-top: 2px; }
.table-wrap { flex: 1; overflow-y: auto; min-height: 0; }
table { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; }
thead { background: #1c2128; position: sticky; top: 0; z-index: 90; }
th { padding: 9px 13px; text-align: left; font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: .05em; cursor: pointer; user-select: none; white-space: nowrap; }
th:hover { color: #e1e4e8; }
.arrow { margin-left: 3px; opacity: .4; }
th.sorted .arrow { opacity: 1; }
tbody tr.data-row { border-top: 1px solid #21262d; cursor: pointer; }
tbody tr.data-row:hover { background: #1c2128; }
tbody tr.data-row.expanded { background: #1c2128; }
td { padding: 9px 13px; font-size: 13px; vertical-align: middle; }
.col-arrow { width: 28px; }
.expand-icon { font-size: 11px; color: #8b949e; display: inline-block; transition: transform .15s; }
.expanded .expand-icon { transform: rotate(90deg); }
.detail-row { display: none; }
.detail-row.open { display: table-row; }
.detail-row td { padding: 0; border-top: 1px solid #30363d; }
.detail-box { padding: 16px 22px; background: #0d1117; }
.tabs { display: flex; border-bottom: 1px solid #30363d; margin-bottom: 14px; }
.tab { padding: 7px 14px; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; color: #8b949e; }
.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
.panel { display: none; }
.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: 1fr 1fr; gap: 20px; }
.params-section h4 { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; margin-bottom: 8px; }
.p-item { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid #21262d; font-size: 12px; gap: 12px; }
.p-key { color: #8b949e; min-width: 140px; flex-shrink: 0; }
.p-val { color: #e1e4e8; word-break: break-all; text-align: right; }
.hist-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.hist-table th { padding: 5px 9px; text-align: left; color: #8b949e; border-bottom: 1px solid #30363d; font-weight: 500; }
.hist-table td { padding: 5px 9px; border-bottom: 1px solid #21262d; }
.ok { color: #56d364; }
.fail { color: #f85149; }
.run { color: #e3b341; }
.deps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.deps-section h4 { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; margin-bottom: 8px; }
.dep-item { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 9px 13px; margin-bottom: 6px; }
.dep-name { font-weight: 600; font-size: 13px; color: #58a6ff; }
.dep-meta { font-size: 11px; color: #8b949e; margin-top: 3px; }
.empty-msg { color: #8b949e; font-size: 12px; padding: 8px 0; }
.loading { text-align: center; padding: 40px; color: #8b949e; }
.err-msg { text-align: center; padding: 20px; color: #f85149; }
body.light { background: #f3f6fb; color: #0f172a; }
body.light #login-screen { background: #f3f6fb; }
body.light .login-box { background: #ffffff; border-color: #cbd5e1; }
body.light .login-logo span, body.light .login-box h2 { color: #0f172a; }
body.light .login-box p { color: #475569; }
body.light .login-box label { color: #475569; }
body.light .login-box select { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; }
body.light .login-box textarea { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; }
body.light header { background: #ffffff; border-bottom-color: #cbd5e1; }
body.light .header-logo .logo-text { color: #0f172a; }
body.light .header-logo .logo-sub, body.light .user-email { color: #475569; }
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 table { background: #ffffff; border-color: #cbd5e1; }
body.light thead { background: #e2e8f0; }
body.light th { color: #334155; }
body.light td { color: #0f172a; }
body.light tbody tr.data-row { border-top-color: #e2e8f0; }
body.light tbody tr.data-row:hover, body.light tbody tr.data-row.expanded { background: #f8fafc; }
body.light .detail-box { background: #f8fafc; }
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; }
@media (max-width: 700px) {
td:nth-child(5), th:nth-child(5), td:nth-child(4), th:nth-child(4) { display: none; }
.params-grid, .deps-grid { grid-template-columns: 1fr; }
.user-email { display: none; }
}
</style>
</head>
<body>
<div id="login-screen">
<div class="login-box">
<div class="login-logo">
<span>NUBES</span>
</div>
<h2>Cloud Dashboard</h2>
<p>Введите API токен для доступа</p>
<label for="deck-env">Стенд</label>
<select id="deck-env">
<option value="prod">PROD</option>
<option value="dev">DEV</option>
<option value="test" selected>TEST</option>
</select>
<textarea id="token-input" placeholder="eyJhbGciOiJ..." spellcheck="false"></textarea>
<button class="btn-primary" id="btn-login">Войти</button>
<div class="login-err" id="login-err"></div>
</div>
</div>
<div id="app" style="display:none">
<header>
<div class="header-logo">
<img class="brand-logo" src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes logo">
<span class="logo-text">NUBES<span class="logo-sub">Dashboard</span></span>
</div>
<div class="filters" id="filters">
<label class="filter-label"><input type="checkbox" value="running" checked> <span class="status-badge s-running">running</span></label>
<label class="filter-label"><input type="checkbox" value="suspended" checked> <span class="status-badge s-suspended">suspended</span></label>
<label class="filter-label"><input type="checkbox" value="deleting"> <span class="status-badge s-deleting">deleting</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="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>
<button class="btn-refresh" id="btn-refresh">&#8635; Обновить</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>
<button class="btn-exit" id="btn-exit">&#10005; Выйти</button>
</div>
</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>
<tr>
<th class="col-arrow"></th>
<th data-col="displayName">Имя <span class="arrow">&#8597;</span></th>
<th data-col="svc">Сервис <span class="arrow">&#8597;</span></th>
<th data-col="explainedStatus">Статус <span class="arrow">&#8597;</span></th>
<th data-col="resourceRealm">Платформа <span class="arrow">&#8597;</span></th>
<th data-col="instanceConfigDtCreated">Создан <span class="arrow">&#8597;</span></th>
<th data-col="dtState">Изменён <span class="arrow">&#8597;</span></th>
</tr>
</thead>
<tbody id="tbody">
<tr><td colspan="7" class="loading">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="graph-modal" id="graph-modal">
<div class="graph-modal-box">
<div class="graph-modal-head">
<div class="graph-modal-title" id="graph-modal-title">Общий граф зависимостей: running</div>
<button class="graph-modal-close" id="btn-close-graph">Закрыть</button>
</div>
<div class="graph-modal-body">
<div class="running-graph" id="running-graph"></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>
<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>
<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";
var API = "/dashboard/api";
var allInstances = [];
var sortCol = "displayName";
var sortDir = 1;
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;
var rawDetail = {};
var navBackUid = null;
var renderTimer = null;
function escHtml(v){
return String(v == null ? "" : v)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function statusColor(s){
var m = {
running: "#56d364",
suspended: "#e3b341",
deleted: "#f85149",
deleting: "#f85149",
pending: "#58a6ff",
creating: "#58a6ff",
"not created": "#8b949e"
};
return m[s] || "#8b949e";
}
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 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 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: n.label || n.id,
fullLabel: n.label || n.id,
status: n.status || "unknown",
color: statusColor(n.status || ""),
bgColor: bgColor,
width: nodeFixedW,
height: height,
fontSize: fontSize,
textMaxWidth: textMaxWidth,
svc: svc,
age: age
},
classes: (focusUid && n.id === focusUid) ? "focus" : ""
});
});
(data.edges || []).forEach(function(e, i){
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, forceShowAux){
if(graphDrawing) return;
graphDrawing = true;
var el = document.getElementById(containerId);
if(!el || !window.cytoscape) { graphDrawing = false; return; }
el.innerHTML = "";
if((data.nodes || []).length === 0){
el.innerHTML = '<div class="empty-msg" style="padding:14px">Загрузка графа... Если долго — обновите страницу.</div>';
graphDrawing = false;
return;
}
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(filteredData, focusUid, layoutMeta.laneById),
autoungrabify: true,
style: [
{ selector: "node", style: {
"shape": "round-rectangle",
"background-color": "data(bgColor)",
"label": "",
"width": "data(width)",
"height": "data(height)",
"padding": "10px",
"border-color": "data(color)",
"border-width": 2.4
}},
{ selector: "node.focus", style: { "border-color": "#facc15", "border-width": 3 }},
{ selector: "edge", style: {
"line-color": "data(color)",
"target-arrow-color": "data(color)",
"target-arrow-shape": "triangle",
"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 */ }
};
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();
var tip = n.fullLabel + "\n" + (n.status || "unknown");
if(n.fullLabel) el.title = tip;
});
}
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;
});
}
function renderInstanceGraph(uid){
var holderId = "g-" + uid;
var holder = document.getElementById(holderId);
if(holder) holder.innerHTML = '<div class="graph-loading">Строю граф зависимостей...</div>';
var run = function(){
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, true);
} else {
drawGraph(holderId, g, uid, true);
}
}).catch(function(e){
var el = document.getElementById(holderId);
if(el) el.innerHTML = '<div class="err-msg">' + escHtml(e.message) + '</div>';
});
};
requestAnimationFrame(function(){ requestAnimationFrame(run); });
}
function instanceName(uid){
for(var i=0;i<allInstances.length;i++){
if(allInstances[i].instanceUid === uid) return allInstances[i].displayName || uid;
}
return uid;
}
function depUid(dep){
return (dep && (dep.uid || dep.instanceUid || dep.dependencyUid || dep.relatedUid)) || "";
}
function visibleUidSet(){
var s = new Set();
var checked = Array.from(document.querySelectorAll("#filters input[value]:checked")).map(function(i){ return i.value; });
var auxEl = document.getElementById("chk-auxiliary");
var showAux = auxEl ? auxEl.checked : true;
allInstances.forEach(function(i){
if(!i || !i.instanceUid) return;
if(checked.length && checked.indexOf(i.explainedStatus) < 0) return;
if(!showAux){
var det = rawDetail[i.instanceUid];
if(det && det.isAuxiliary) return;
}
s.add(i.instanceUid);
});
return s;
}
function buildClientSubgraph(data, rootUid, depth){
var nodes = data.nodes || [];
var edges = data.edges || [];
var ids = new Set(nodes.map(function(n){ return n.id; }));
if(!ids.has(rootUid)) return { nodes: [], edges: [] };
var adj = {};
nodes.forEach(function(n){ adj[n.id] = new Set(); });
edges.forEach(function(e){
if(adj[e.source] && adj[e.target]){
adj[e.source].add(e.target);
adj[e.target].add(e.source);
}
});
var seen = new Set([rootUid]);
var frontier = new Set([rootUid]);
for(var i=0;i<depth;i++){
var nxt = new Set();
frontier.forEach(function(n){
(adj[n] || new Set()).forEach(function(x){ if(!seen.has(x)) nxt.add(x); });
});
if(nxt.size === 0) break;
nxt.forEach(function(x){ seen.add(x); });
frontier = nxt;
}
return {
nodes: nodes.filter(function(n){ return seen.has(n.id); }),
edges: edges.filter(function(e){ return seen.has(e.source) && seen.has(e.target); })
};
}
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 el = document.getElementById("running-graph");
if(el) el.innerHTML = '<div class="err-msg">' + escHtml(e.message) + '</div>';
});
}
function closeRunningGraph(){
document.getElementById("graph-modal").classList.remove("open");
}
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 || "", forceShowAux);
}
function scheduleGraphWarmup(){
if(graphWarmupScheduled) return;
graphWarmupScheduled = true;
var warm = function(){
// optional background warm-up: should never block UI
fetchGraph({ status: ["running"] }).then(function(data){
graphRunningData = data || {nodes:[], edges:[]};
}).catch(function(){ /* ignore */ });
};
if(window.requestIdleCallback){
requestIdleCallback(warm, { timeout: 3000 });
} else {
setTimeout(warm, 3000);
}
}
function getToken(){ return localStorage.getItem("deck_token") || ""; }
function getDeckEnv(){
return localStorage.getItem("deck_env") || "test";
}
function decodeEmail(token){
try {
var p = token.split(".")[1];
var padded = p + "=".repeat((4 - p.length % 4) % 4);
var obj = JSON.parse(atob(padded.replace(/-/g,"+").replace(/_/g,"/")));
return obj.email || obj.sub || obj.preferred_username || obj.name || "";
} catch(e){ return ""; }
}
function apiHeaders(){
return {
"X-Deck-Token": getToken(),
"X-Deck-Env": getDeckEnv(),
"Content-Type": "application/json"
};
}
function showLoginErr(msg){
var el = document.getElementById("login-err");
el.textContent = msg;
el.style.display = "block";
}
function doLogout(){
localStorage.removeItem("deck_token");
location.reload();
}
function getChecked(){
return Array.from(document.querySelectorAll("#filters input[value]:checked")).map(function(i){ return i.value; });
}
function sClass(s){
var m = {running:"s-running",suspended:"s-suspended",deleted:"s-deleted",deleting:"s-deleting",pending:"s-pending",creating:"s-creating"};
return m[s] || "s-default";
}
function fmtDt(s){
if(!s) return "\u2014";
try { return new Date(s).toLocaleString("ru-RU",{day:"2-digit",month:"2-digit",year:"2-digit",hour:"2-digit",minute:"2-digit"}); }
catch(e){ return s; }
}
function renderStats(list){
var c = {};
list.forEach(function(i){ var s = i.explainedStatus || "unknown"; c[s] = (c[s]||0)+1; });
var col = {running:"#56d364",suspended:"#e3b341",deleted:"#f85149",pending:"#58a6ff"};
var html = "";
for (var s in c){
html += "<div class=\"stat-card\"><div class=\"num\" style=\"color:" + (col[s]||"#8b949e") + "\">" + c[s] + "</div><div class=\"lbl\">" + s + "</div></div>";
}
html += "<div class=\"stat-card\"><div class=\"num\">" + list.length + "</div><div class=\"lbl\">всего</div></div>";
document.getElementById("stats").innerHTML = html;
}
function sorted(arr){
return arr.slice().sort(function(a,b){
var av = a[sortCol] || "";
var bv = b[sortCol] || "";
return av < bv ? -sortDir : av > bv ? sortDir : 0;
});
}
function render(list){
var tbody = document.getElementById("tbody");
var s = sorted(list);
if(!s.length){ tbody.innerHTML = "<tr><td colspan=\"7\" class=\"loading\">Нет данных</td></tr>"; return; }
var html = "";
s.forEach(function(inst){
var uid = inst.instanceUid;
var exp = expandedUid === uid;
html += "<tr class=\"data-row" + (exp ? " expanded" : "") + "\" data-uid=\"" + uid + "\">" +
"<td><span class=\"expand-icon\">&#9658;</span></td>" +
"<td>" + (inst.displayName || "\u2014") + "</td>" +
"<td>" + (inst.svc || "\u2014") + "</td>" +
"<td><span class=\"status-badge " + sClass(inst.explainedStatus) + "\">" + (inst.explainedStatus || "\u2014") + "</span></td>" +
"<td>" + (inst.resourceRealm || "\u2014") + "</td>" +
"<td>" + fmtDt(inst.instanceConfigDtCreated) + "</td>" +
"<td>" + fmtDt(inst.dtState || inst.instanceConfigDtUpdated) + "</td>" +
"</tr>";
html += "<tr class=\"detail-row" + (exp ? " open" : "") + "\" id=\"dr-" + uid + "\">" +
"<td colspan=\"7\"><div class=\"detail-box\" id=\"dc-" + uid + "\">" +
(exp ? (cache[uid] || "<div class=\"loading\">Загрузка...</div>") : "") +
"</div></td></tr>";
});
tbody.innerHTML = html;
tbody.querySelectorAll("tr.data-row").forEach(function(row){
row.addEventListener("click", function(){ toggle(row.dataset.uid, row); });
});
document.querySelectorAll("th[data-col]").forEach(function(th){
var active = th.dataset.col === sortCol;
th.classList.toggle("sorted", active);
th.querySelector(".arrow").innerHTML = active ? (sortDir > 0 ? "&#8593;" : "&#8595;") : "&#8597;";
});
}
function toggle(uid, row){
var dr = document.getElementById("dr-" + uid);
var dc = document.getElementById("dc-" + uid);
if(expandedUid === uid){
expandedUid = null;
dr.classList.remove("open");
row.classList.remove("expanded");
return;
}
if(expandedUid){
var pd = document.getElementById("dr-" + expandedUid);
if(pd) pd.classList.remove("open");
var pr = pd && pd.previousElementSibling;
if(pr) pr.classList.remove("expanded");
}
expandedUid = uid;
row.classList.add("expanded");
dr.classList.add("open");
if(!cache[uid]){
if(rawDetail[uid]){
cache[uid] = buildDetail(rawDetail[uid]);
dc.innerHTML = cache[uid];
bindTabs(dc);
} else {
dc.innerHTML = "<div class=\"loading\">Загрузка...</div>";
fetch(API + "/instances/" + uid, { headers: apiHeaders() })
.then(function(r){
if(r.status === 401){ doLogout(); return null; }
return r.json();
})
.then(function(d){
if(!d) return;
rawDetail[uid] = d.instance;
cache[uid] = buildDetail(d.instance);
dc.innerHTML = cache[uid];
bindTabs(dc);
})
.catch(function(e){ cache[uid] = "<div class=\"err-msg\">" + e.message + "</div>"; dc.innerHTML = cache[uid]; });
}
} else {
dc.innerHTML = cache[uid];
bindTabs(dc);
}
}
function bindTabs(container){
container.querySelectorAll(".tab").forEach(function(tab){
tab.addEventListener("click", function(){
var box = tab.closest(".detail-box");
box.querySelectorAll(".tab").forEach(function(t){ t.classList.remove("active"); });
box.querySelectorAll(".panel").forEach(function(p){ p.classList.remove("active"); });
tab.classList.add("active");
var pid = tab.dataset.panel;
var panel = document.getElementById(pid);
if(panel) panel.classList.add("active");
if(pid && pid.indexOf("g-graph-") === 0){
var uid = pid.slice("g-graph-".length);
if(uid) setTimeout(function(){ renderInstanceGraph(uid); }, 30);
}
});
});
}
function buildDetail(inst){
if(!inst) return "<div class=\"err-msg\">Нет данных</div>";
var uid = inst.instanceUid || "x";
var params = (inst.state && inst.state.params) || {};
var out = (inst.state && inst.state.out) || {};
var ops = inst.operations || [];
var rawDeps = inst.dependencies || [];
var rawDepOn = inst.dependentInstances || [];
var visible = visibleUidSet();
var deps = rawDeps.filter(function(d){ var id = depUid(d); return id && visible.has(id); });
var depOn = rawDepOn.filter(function(d){ var id = depUid(d); return id && visible.has(id); });
var hasDeps = (deps.length + depOn.length) > 0;
var h = "<div class=\"tabs\">" +
"<div class=\"tab active\" data-panel=\"p-" + uid + "\">Параметры</div>" +
"<div class=\"tab\" data-panel=\"h-" + uid + "\">История (" + ops.length + ")</div>" +
"<div class=\"tab\" data-panel=\"d-" + uid + "\">Зависимости (" + (deps.length + depOn.length) + ")</div>" +
(hasDeps ? "<div class=\"tab\" data-panel=\"g-graph-" + uid + "\">Граф</div>" : "") +
"</div>";
h += "<div class=\"panel active\" id=\"p-" + uid + "\"><div class=\"params-grid\">" +
"<div class=\"params-section\"><h4>Input (params)</h4>" + renderP(params) + "</div>" +
"<div class=\"params-section\"><h4>Output (out)</h4>" + renderP(out) + "</div>" +
"</div></div>";
var opsHtml = "";
if(ops.length){
ops.forEach(function(op){
var cls = op.isInProgress ? "run" : op.isSuccessful ? "ok" : "fail";
var icon = op.isInProgress ? "&#8987;" : op.isSuccessful ? "&#10003;" : "&#10007;";
var dur = op.duration != null ? op.duration.toFixed(1) : "\u2014";
opsHtml += "<tr><td>" + (op.operation||"\u2014") + "</td><td>" + (op.updaterShortname||op.updaterLogin||"\u2014") +
"</td><td>" + fmtDt(op.dtStart) + "</td><td>" + fmtDt(op.dtFinish) + "</td><td>" + dur +
"</td><td class=\"" + cls + "\">" + icon + "</td></tr>";
});
} else {
opsHtml = "<tr><td colspan=\"6\" class=\"empty-msg\" style=\"padding:10px\">Нет записей</td></tr>";
}
h += "<div class=\"panel\" id=\"h-" + uid + "\"><table class=\"hist-table\"><thead><tr>" +
"<th>Операция</th><th>Кто</th><th>Начало</th><th>Конец</th><th>Длит.&nbsp;(s)</th><th>Статус</th>" +
"</tr></thead><tbody>" + opsHtml + "</tbody></table></div>";
var depsHtml = "";
if(deps.length){
deps.forEach(function(d){
var did = depUid(d);
depsHtml += "<div class=\"dep-item\" onclick=\"navigateToDep('" + did + "')\"><div class=\"dep-name\">&#9658; " + escHtml(d.displayName||d.uid) +
"</div><div class=\"dep-meta\">" + escHtml(d.svc||"") +
(d.param ? " &middot; через <code>" + escHtml(d.param) + "</code>" : "") +
(d.descr ? " &middot; " + escHtml(d.descr) : "") + "</div></div>";
});
} else {
depsHtml = "<div class=\"empty-msg\">Нет зависимостей</div>";
}
var depOnHtml = "";
if(depOn.length){
depOn.forEach(function(d){
var did = depUid(d);
depOnHtml += "<div class=\"dep-item\" onclick=\"navigateToDep('" + did + "')\"><div class=\"dep-name\">&#9658; " + escHtml(d.displayName||d.uid) +
"</div><div class=\"dep-meta\">" + escHtml(d.svc||"") +
(d.param ? " &middot; через <code>" + escHtml(d.param) + "</code>" : "") + "</div></div>";
});
} else {
depOnHtml = "<div class=\"empty-msg\">Никто не зависит</div>";
}
h += "<div class=\"panel\" id=\"d-" + uid + "\"><div class=\"deps-grid\">" +
"<div class=\"deps-section\"><h4>Зависит от (" + deps.length + ")</h4>" + depsHtml + "</div>" +
"<div class=\"deps-section\"><h4>Зависят от него (" + depOn.length + ")</h4>" + depOnHtml + "</div>" +
"</div></div>";
if(hasDeps){
h += "<div class=\"panel\" id=\"g-graph-" + uid + "\">" +
"<div class=\"graph-hint\">Стрелка A → B означает: B зависит от A. Можно масштабировать колесом и перетаскивать узлы.</div>" +
"<div class=\"inst-graph\" id=\"g-" + uid + "\"><div class=\"graph-loading\">Нажмите вкладку «Граф» для расчёта...</div></div>" +
"</div>";
}
return h;
}
function renderP(obj, pfx){
if(!obj || typeof obj !== "object") return "<div class=\"p-item\"><span class=\"p-val\">" + (obj || "\u2014") + "</span></div>";
var r = "";
var keys = Object.keys(obj);
keys.forEach(function(k){
var v = obj[k];
var key = pfx ? pfx + "." + k : k;
if(v && typeof v === "object" && !Array.isArray(v)){
r += renderP(v, key);
} else {
var val = Array.isArray(v) ? JSON.stringify(v) : (v != null ? String(v) : "\u2014");
r += "<div class=\"p-item\"><span class=\"p-key\">" + key + "</span><span class=\"p-val\">" + val + "</span></div>";
}
});
return r || "<div class=\"empty-msg\">Пусто</div>";
}
function filteredInstances(){
var checked = Array.from(document.querySelectorAll("#filters input[value]:checked")).map(function(i){ return i.value; });
var auxEl = document.getElementById("chk-auxiliary");
var showAux = auxEl ? auxEl.checked : true;
return allInstances.filter(function(inst){
if(checked.length && checked.indexOf(inst.explainedStatus) < 0) return false;
if(!showAux){
var det = rawDetail[inst.instanceUid];
if(det && det.isAuxiliary) return false;
}
return true;
});
}
function updateTimestamp(){
var ts = document.getElementById("data-ts");
if(ts) ts.textContent = "Актуально на " + new Date().toLocaleTimeString("ru-RU",{hour:"2-digit",minute:"2-digit",second:"2-digit"});
}
function scheduleRender(){
if(renderTimer) clearTimeout(renderTimer);
renderTimer = setTimeout(function(){
renderTimer = null;
renderStats(allInstances);
render(filteredInstances());
}, 150);
}
function loadAllDetails(){
allInstances = [];
cache = {};
rawDetail = {};
graphAllData = null;
graphRunningData = null;
graphFullData = null;
graphAllKey = "";
expandedUid = null;
navBackUid = null;
var nb = document.getElementById("nav-back");
if(nb) nb.style.display = "none";
var ts = document.getElementById("data-ts");
if(ts) ts.textContent = "";
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"7\" class=\"loading\">Загрузка данных...</td></tr>";
fetch(API + "/instances/stream", { headers: apiHeaders() })
.then(function(r){
if(r.status === 401){ doLogout(); return null; }
if(!r.ok) throw new Error("HTTP " + r.status);
return r;
})
.then(function(r){
if(!r) return;
var reader = r.body.getReader();
var decoder = new TextDecoder();
var buffer = "";
function processLine(line){
var obj;
try { obj = JSON.parse(line); } catch(e){ return; }
if(obj._done){
updateTimestamp();
renderStats(allInstances);
render(filteredInstances());
return;
}
var uid = obj.instanceUid;
if(!uid) return;
var detail = obj._detail;
if(detail !== undefined) delete obj._detail;
if(detail) rawDetail[uid] = detail;
allInstances.push(obj);
scheduleRender();
}
function pump(){
return reader.read().then(function(result){
if(result.done){
if(buffer.trim()) processLine(buffer.trim());
updateTimestamp();
renderStats(allInstances);
render(filteredInstances());
return;
}
buffer += decoder.decode(result.value, {stream: true});
var lines = buffer.split("\n");
buffer = lines.pop();
lines.forEach(function(line){ if(line.trim()) processLine(line.trim()); });
return pump();
});
}
return pump();
})
.catch(function(e){
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"7\" class=\"err-msg\">" + escHtml(e.message) + "</td></tr>";
});
}
function navigateToDep(uid){
var rows = document.querySelectorAll("tr.data-row[data-uid=\"" + uid + "\"]");
if(!rows.length){
var nb = document.getElementById("nav-back");
if(nb){ nb.innerHTML = "<span>&#9888; Инстанс скрыт текущими фильтрами</span>"; nb.style.display = "block"; }
return;
}
navBackUid = expandedUid;
var nb = document.getElementById("nav-back");
if(nb && navBackUid){
var backName = instanceName(navBackUid);
nb.innerHTML = "<a href=\"#\" onclick=\"navBack();return false;\">← Назад: " + escHtml(backName) + "</a>";
nb.style.display = "block";
}
var row = rows[0];
toggle(uid, row);
row.scrollIntoView({behavior:"smooth", block:"center"});
}
function navBack(){
var uid = navBackUid;
navBackUid = null;
var nb = document.getElementById("nav-back");
if(nb) nb.style.display = "none";
if(!uid) return;
var rows = document.querySelectorAll("tr.data-row[data-uid=\"" + uid + "\"]");
if(!rows.length) return;
var row = rows[0];
toggle(uid, row);
row.scrollIntoView({behavior:"smooth", block:"center"});
}
function loadInstances(){ loadAllDetails(); }
function applyTheme(theme){
var isLight = theme === "light";
document.body.classList.toggle("light", isLight);
var btn = document.getElementById("btn-theme");
if(btn){
btn.textContent = isLight ? "☀" : "🌙";
btn.title = isLight ? "Тема: день" : "Тема: ночь";
}
localStorage.setItem("dashboard_theme", isLight ? "light" : "dark");
}
function toggleTheme(){
var cur = localStorage.getItem("dashboard_theme") || "dark";
applyTheme(cur === "dark" ? "light" : "dark");
}
function doLogin(){
var token = document.getElementById("token-input").value.trim();
var env = (document.getElementById("deck-env").value || "test").toLowerCase();
if(!token){ showLoginErr("Вставьте токен"); return; }
var btn = document.getElementById("btn-login");
btn.disabled = true;
btn.textContent = "Проверка...";
fetch(API + "/instances?status=running", { headers: {"X-Deck-Token": token, "X-Deck-Env": env} })
.then(function(r){
if(r.status === 401){ showLoginErr("Токен не принят"); btn.disabled = false; btn.textContent = "Войти"; return null; }
return r.json();
})
.then(function(data){
if(!data) return;
localStorage.setItem("deck_token", token);
localStorage.setItem("deck_env", env);
initApp();
})
.catch(function(e){
showLoginErr("Ошибка соединения: " + e.message);
btn.disabled = false;
btn.textContent = "Войти";
});
}
function initApp(){
document.getElementById("login-screen").classList.add("hidden");
document.getElementById("app").style.display = "flex";
var email = decodeEmail(getToken());
document.getElementById("user-email").textContent = email;
document.querySelectorAll("th[data-col]").forEach(function(th){
th.addEventListener("click", function(){
if(sortCol === th.dataset.col) sortDir *= -1;
else { sortCol = th.dataset.col; sortDir = 1; }
render(filteredInstances());
});
});
document.querySelectorAll("#filters input[value]").forEach(function(cb){
cb.addEventListener("change", function(){ cache = {}; render(filteredInstances()); });
});
document.getElementById("chk-auxiliary").addEventListener("change", function(){
cache = {};
render(filteredInstances());
if(graphRunningData){ graphDrawing = false; drawGraph("running-graph", graphRunningData, ""); }
});
loadAllDetails();
scheduleGraphWarmup();
}
document.getElementById("btn-login").addEventListener("click", doLogin);
document.getElementById("btn-refresh").addEventListener("click", loadAllDetails);
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("graph-modal").addEventListener("click", function(e){
if(e.target && e.target.id === "graph-modal") closeRunningGraph();
});
applyTheme(localStorage.getItem("dashboard_theme") || "dark");
var envSelect = document.getElementById("deck-env");
if(envSelect) envSelect.value = getDeckEnv();
if(getToken()) initApp();
// Expose functions called from inline onclick attributes
window.navigateToDep = navigateToDep;
window.navBack = navBack;
})();
</script>
</body>
</html>