1252 lines
53 KiB
HTML
1252 lines
53 KiB
HTML
<!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; }
|
||
.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.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 { 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; }
|
||
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">↻ Обновить</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">✕ Выйти</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">↕</span></th>
|
||
<th data-col="svc">Сервис <span class="arrow">↕</span></th>
|
||
<th data-col="explainedStatus">Статус <span class="arrow">↕</span></th>
|
||
<th data-col="resourceRealm">Платформа <span class="arrow">↕</span></th>
|
||
<th data-col="instanceConfigDtCreated">Создан <span class="arrow">↕</span></th>
|
||
<th data-col="dtState">Изменён <span class="arrow">↕</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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/\"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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-shape": "none",
|
||
"curve-style": "taxi",
|
||
"taxi-direction": "auto",
|
||
"taxi-turn": "50px",
|
||
"taxi-turn-min-distance": "10px",
|
||
"width": 2.4,
|
||
"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 nameFs = Math.round(fs * 1.25);
|
||
var html = '<div style="text-align:center;color:#dbeafe;font-family:system-ui,sans-serif;padding:4px 6px;">';
|
||
html += '<div style="font-size:'+nameFs+'px;font-weight:700;text-decoration:underline;word-break:break-word;white-space:normal;">' + (d.label||'') + '</div>';
|
||
if(d.svc) html += '<div style="font-size:'+(fs-1)+'px;font-style:italic;margin-top:3px;color:#93c5fd;">' + d.svc + '</div>';
|
||
if(d.age) html += '<div style="font-size:9px;font-style:italic;color:#9ca3af;margin-top:10px;">⏱ ' + 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\">►</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 ? "↑" : "↓") : "↕";
|
||
});
|
||
}
|
||
|
||
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 ? "⌛" : op.isSuccessful ? "✓" : "✗";
|
||
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>Длит. (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\">► " + escHtml(d.displayName||d.uid) +
|
||
"</div><div class=\"dep-meta\">" + escHtml(d.svc||"") +
|
||
(d.param ? " · через <code>" + escHtml(d.param) + "</code>" : "") +
|
||
(d.descr ? " · " + 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\">► " + escHtml(d.displayName||d.uid) +
|
||
"</div><div class=\"dep-meta\">" + escHtml(d.svc||"") +
|
||
(d.param ? " · через <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>⚠ Инстанс скрыт текущими фильтрами</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>
|