Files
dashboard/static/index.html
T

1110 lines
48 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; }
.btn-graph { padding: 5px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; cursor: pointer; font-size: 12px; }
.btn-graph:hover { background: #30363d; }
.header-user { display: flex; align-items: center; gap: 10px; }
.user-email { font-size: 12px; color: #8b949e; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn-exit { padding: 5px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #f85149; cursor: pointer; font-size: 12px; }
.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; }
.nav-back { padding: 7px 20px; background: #21262d; color: #e1e4e8; font-size: 13px; font-weight: 700; border-bottom: 1px solid #30363d; }
.nav-back a { color: #e1e4e8; text-decoration: none; font-weight: 700; }
.nav-back a:hover { color: #fff; text-decoration: underline; }
.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-params { background: #111827; border-top: 2px solid #1e3a5f; }
.panel-history { background: #0f1a0f; border-top: 2px solid #1a4a2e; }
.panel-deps { background: #1a1209; border-top: 2px solid #3d2a00; }
body.light .panel-params { background: #eff6ff; border-top: 2px solid #bfdbfe; }
body.light .panel-history { background: #f0fdf4; border-top: 2px solid #bbf7d0; }
body.light .panel-deps { background: #fffbeb; border-top: 2px solid #fde68a; }
.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; cursor: pointer; }
.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, body.light .btn-graph { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; }
body.light .btn-refresh:hover, body.light .btn-theme:hover, body.light .btn-exit:hover, body.light .btn-graph:hover { background: #e2e8f0; }
body.light .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; }
body.light .p-item { border-bottom-color: #e2e8f0; color: #0f172a; }
body.light .p-key { color: #475569; }
body.light .p-val { color: #0f172a; }
body.light .params-section h4 { color: #64748b; }
body.light .dep-item { background: #ffffff; border-color: #cbd5e1; }
body.light .dep-name { color: #1d4ed8; }
body.light .dep-meta { color: #64748b; }
body.light .deps-section h4 { color: #64748b; }
body.light .hist-table th { color: #64748b; border-bottom-color: #cbd5e1; }
body.light .hist-table td { border-bottom-color: #e2e8f0; color: #0f172a; }
body.light .nav-back { background: #e2e8f0; border-bottom-color: #cbd5e1; color: #334155; font-weight: 700; }
body.light .nav-back a { color: #0f172a; font-weight: 700; }
body.light .nav-back a:hover { color: #000; }
@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 src="https://cdn.jsdelivr.net/npm/panzoom@9.4.3/dist/panzoom.min.js"></script>
<script>
window.onerror=function(m,s,l){var e=document.getElementById("js-err");if(e){e.style.display="block";e.textContent="JS ERROR line "+l+": "+m;}};
</script>
<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 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";
}
// ─── GRAPH: SVG + panzoom ─────────────────────────────────────────────────
// Status color for node border
function nodeStatusColor(status){
var m = {
"running": "#238636", "suspended": "#e3b341", "deleting": "#f85149",
"deleted": "#6e7681", "not created": "#8b949e", "pending": "#a371f7",
"creating": "#1f6feb"
};
return m[status] || "#8b949e";
}
// Service → background color
function nodeBgColor(svc){
var s = (svc || "").toLowerCase();
if(/postgres|sql/.test(s)) return "#0c1e36";
if(/kubernetes|k8s|шт[уy]рвал/.test(s)) return "#0f1e0f";
if(/s3|object storage|бакет/.test(s)) return "#1a1200";
if(/edge|шлюз/.test(s)) return "#200c1a";
if(/vm|виртуальн/.test(s)) return "#1a1014";
if(/redis/.test(s)) return "#200a0a";
if(/rabbit/.test(s)) return "#0a1a10";
if(/dns/.test(s)) return "#0a1220";
if(/cloud director|vdc|датацентр/.test(s)) return "#001430";
return "#161b22";
}
// Break text into lines fitting maxWidth (chars-based estimate)
function wrapText(text, maxChars){
var words = text.split(/(?=[_\-])/); // split on _ and - keeping delimiter
var lines = [];
var cur = "";
for(var i = 0; i < words.length; i++){
var w = words[i];
if((cur + w).length > maxChars && cur.length > 0){
lines.push(cur);
cur = w;
} else {
cur += w;
}
}
if(cur) lines.push(cur);
return lines.length ? lines : [text];
}
// Fetch graph data from backend
function fetchGraph(params){
if(graphFetchInFlight) return Promise.resolve(null);
graphFetchInFlight = true;
var p = new URLSearchParams();
Object.keys(params || {}).forEach(function(k){
var v = params[k];
if(Array.isArray(v)) v.forEach(function(x){ p.append(k, x); });
else if(v !== undefined && v !== null && v !== "") p.append(k, String(v));
});
return fetch(API + "/graph?" + p.toString(), { headers: apiHeaders() })
.then(function(r){
graphFetchInFlight = false;
if(r.status === 401){ doLogout(); return null; }
if(!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.catch(function(e){
graphFetchInFlight = false;
throw e;
});
}
function ensureAllGraphLoaded(){
var statuses = getChecked().slice().sort();
var key = statuses.join(",");
if(key === graphAllKey && graphAllData) return Promise.resolve(graphAllData);
if(statuses.length === 0){
graphAllKey = key;
graphAllData = {nodes:[], edges:[]};
return Promise.resolve(graphAllData);
}
return fetchGraph({ status: statuses }).then(function(data){
graphAllKey = key;
graphAllData = data || {nodes:[], edges:[]};
return graphAllData;
});
}
function ensureFullGraphLoaded(){
if(graphFullData) return Promise.resolve(graphFullData);
return fetchGraph({}).then(function(data){
graphFullData = data || {nodes:[], edges:[]};
return graphFullData;
});
}
// Build subgraph from rawDetail dependencies (BFS, no edges needed)
function buildDepSubgraphFromDetail(rootUid){
var seen = new Set([rootUid]);
var frontier = new Set([rootUid]);
for(var i = 0; i < 3; i++){
var nxt = new Set();
frontier.forEach(function(u){
var det = rawDetail[u];
if(!det) return;
(det.dependencies || []).forEach(function(dep){
var duid = depUid(dep);
if(duid && !seen.has(duid)) nxt.add(duid);
});
(det.dependentInstances || []).forEach(function(dep){
var duid = depUid(dep);
if(duid && !seen.has(duid)) nxt.add(duid);
});
});
if(nxt.size === 0) break;
nxt.forEach(function(x){ seen.add(x); });
frontier = nxt;
}
var allNodes = (graphFullData && graphFullData.nodes) || [];
var filtered = allNodes.filter(function(n){ return seen.has(n.id); });
if(!filtered.some(function(n){ return n.id === rootUid; })){
for(var j = 0; j < allInstances.length; j++){
if(allInstances[j].instanceUid === rootUid){
var inst = allInstances[j];
filtered.push({
id: rootUid, label: inst.displayName || rootUid,
status: inst.explainedStatus || "unknown", svc: inst.svc || "",
lane: "regular", created: inst.instanceConfigDtCreated || inst.dtCreated || ""
});
break;
}
}
}
return { nodes: filtered, edges: [] };
}
// ─── MAIN DRAW FUNCTION ────────────────────────────────────────────────────
function drawGraph(containerId, data, focusUid){
var el = document.getElementById(containerId);
if(!el) return;
el.innerHTML = "";
var nodes = data.nodes || [];
if(nodes.length === 0){
el.innerHTML = '<div class="empty-msg" style="padding:20px">Нет данных</div>';
return;
}
var NODE_W = 200;
var NODE_H = 80;
var GAP_X = 30;
var GAP_Y = 50;
var MAX_COLS = 5;
var PAD = 40;
// Sort: platforms first, singletons second, regular last
var sorted = nodes.slice().sort(function(a, b){
var rank = function(n){ return n.lane === "platform" ? 0 : n.lane === "singleton" ? 1 : 2; };
return rank(a) - rank(b);
});
// Assign grid positions
var cols = Math.min(sorted.length, MAX_COLS);
sorted.forEach(function(n, idx){
n._col = idx % cols;
n._row = Math.floor(idx / cols);
});
var rows = Math.ceil(sorted.length / cols);
var svgW = PAD * 2 + cols * NODE_W + (cols - 1) * GAP_X;
var svgH = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y;
// ── Create container with overflow scroll ──
var wrapper = document.createElement("div");
wrapper.style.cssText = "width:100%;height:100%;overflow:hidden;position:relative;background:#0d1117;";
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", svgW);
svg.setAttribute("height", svgH);
svg.setAttribute("viewBox", "0 0 " + svgW + " " + svgH);
svg.style.cssText = "display:block;cursor:grab;user-select:none;";
var g = document.createElementNS("http://www.w3.org/2000/svg", "g");
sorted.forEach(function(n){
var x = PAD + n._col * (NODE_W + GAP_X);
var y = PAD + n._row * (NODE_H + GAP_Y);
var cx = x + NODE_W / 2;
var cy = y + NODE_H / 2;
var bg = nodeBgColor(n.svc || "");
var bc = nodeStatusColor(n.status || "");
var isFocus = focusUid && n.id === focusUid;
// Rect
var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", x);
rect.setAttribute("y", y);
rect.setAttribute("width", NODE_W);
rect.setAttribute("height", NODE_H);
rect.setAttribute("rx", 8);
rect.setAttribute("fill", bg);
rect.setAttribute("stroke", isFocus ? "#facc15" : bc);
rect.setAttribute("stroke-width", isFocus ? 3 : 2);
rect.style.cursor = "pointer";
rect.addEventListener("click", function(e){
e.stopPropagation();
navigateToDep(n.id);
});
g.appendChild(rect);
// Name — wrapped
var nameLines = wrapText(n.label || n.id || "", 20);
var nameFs = nameLines.length > 2 ? 10 : nameLines.length > 1 ? 11 : 12;
var lineH = nameFs * 1.5;
var totalTextH = nameLines.length * lineH + (n.svc ? lineH : 0);
var startY = cy - totalTextH / 2 + nameFs;
nameLines.forEach(function(line, li){
var t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", cx);
t.setAttribute("y", startY + li * lineH);
t.setAttribute("text-anchor", "middle");
t.setAttribute("font-size", nameFs);
t.setAttribute("font-weight", "bold");
t.setAttribute("fill", "#dbeafe");
t.setAttribute("font-family", "system-ui,sans-serif");
t.style.pointerEvents = "none";
t.textContent = line;
g.appendChild(t);
});
// Service
if(n.svc){
var st = document.createElementNS("http://www.w3.org/2000/svg", "text");
st.setAttribute("x", cx);
st.setAttribute("y", startY + nameLines.length * lineH);
st.setAttribute("text-anchor", "middle");
st.setAttribute("font-size", 9);
st.setAttribute("font-style", "italic");
st.setAttribute("fill", "#93c5fd");
st.setAttribute("font-family", "system-ui,sans-serif");
st.style.pointerEvents = "none";
st.textContent = n.svc;
g.appendChild(st);
}
// Age
var age = humanAge(n.created || "");
if(age){
var at = document.createElementNS("http://www.w3.org/2000/svg", "text");
at.setAttribute("x", cx);
at.setAttribute("y", y + NODE_H - 6);
at.setAttribute("text-anchor", "middle");
at.setAttribute("font-size", 9);
at.setAttribute("fill", "#9ca3af");
at.setAttribute("font-family", "system-ui,sans-serif");
at.style.pointerEvents = "none";
at.textContent = "\u23f1 " + age;
g.appendChild(at);
}
});
svg.appendChild(g);
wrapper.appendChild(svg);
el.appendChild(wrapper);
// ── panzoom: pan + scroll-zoom ──
if(window.panzoom){
var instance = window.panzoom(g, {
smoothScroll: false,
bounds: false,
zoomDoubleClickSpeed: 1,
minZoom: 0.2,
maxZoom: 3
});
svg.addEventListener("dblclick", function(){
instance.moveTo(0, 0);
instance.zoomAbs(0, 0, 1);
});
}
}
// ── Open / Close running graph modal ──────────────────────────────────────
function openRunningGraph(){
var modal = document.getElementById("graph-modal");
modal.classList.add("open");
var titleEl = document.getElementById("graph-modal-title");
var statuses = getChecked();
if(titleEl) titleEl.textContent = "Общий граф зависимостей: " + (statuses.length ? statuses.join(", ") : "все");
var target = document.getElementById("running-graph");
if(target) target.innerHTML = '<div class="graph-loading">Собираю граф...</div>';
ensureAllGraphLoaded()
.then(function(data){ drawGraph("running-graph", data || {nodes:[], edges:[]}, ""); })
.catch(function(e){
var el2 = document.getElementById("running-graph");
if(el2) el2.innerHTML = '<div class="err-msg">' + escHtml(e.message) + '</div>';
});
}
function closeRunningGraph(){
document.getElementById("graph-modal").classList.remove("open");
}
function showGraphModal(title, data, focusUid){
var modal = document.getElementById("graph-modal");
var titleEl = document.getElementById("graph-modal-title");
if(titleEl) titleEl.textContent = title;
modal.classList.add("open");
drawGraph("running-graph", data || {nodes:[], edges:[]}, focusUid || "");
}
// ── Instance graph (вкладка Граф в детали) ─────────────────────────────────
function renderInstanceGraph(uid){
var holderId = "g-" + uid;
var holder = document.getElementById(holderId);
if(holder) holder.innerHTML = '<div class="graph-loading">Строю граф...</div>';
ensureFullGraphLoaded().then(function(){
var g = buildDepSubgraphFromDetail(uid);
if((g.nodes || []).length > 2){
showGraphModal("Граф зависимостей: " + escHtml(instanceName(uid)), g, uid);
} else {
drawGraph(holderId, g, uid);
}
}).catch(function(e){
var el = document.getElementById(holderId);
if(el) el.innerHTML = '<div class="err-msg">' + escHtml(e.message) + '</div>';
});
}
function getToken(){
return localStorage.getItem("deck_token") || "";
}
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 panel-params 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 panel-history\" 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 panel-deps\" 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){ drawGraph("running-graph", graphRunningData, ""); }
});
loadAllDetails();
}
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-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>