Files
dashboard/static/index.html
T

1433 lines
63 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; }
.data-ts { font-size: 12px; color: #8b949e; margin-right: 10px; white-space: nowrap; }
.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.row-base { background: #171c25; }
tbody tr.data-row.row-alt { background: #222a36; }
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; background: #0b1220; }
.detail-box {
padding: 16px 22px;
background: #0b1220;
border: 1px solid #334155;
border-radius: 10px;
margin: 10px 12px 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .03);
}
.tabs { display: flex; border-bottom: 1px solid #334155; margin-bottom: 14px; background: rgba(15, 23, 42, .45); border-radius: 8px 8px 0 0; padding: 0 6px; }
.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: #0f223d; border-top: 2px solid #3b82f6; border-radius: 0 0 8px 8px; padding: 12px; }
.panel-history { background: #102617; border-top: 2px solid #22c55e; border-radius: 0 0 8px 8px; padding: 12px; }
.panel-deps { background: #2a1808; border-top: 2px solid #f59e0b; border-radius: 0 0 8px 8px; padding: 12px; }
body.light .panel-params { background: #dbeafe; border-top: 2px solid #60a5fa; }
body.light .panel-history { background: #dcfce7; border-top: 2px solid #4ade80; }
body.light .panel-deps { background: #fef3c7; border-top: 2px solid #f59e0b; }
.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: max-content max-content; gap: 14px 68px; max-width: none; margin: 0; justify-content: start; align-content: start; }
.params-section { min-width: 0; }
.params-section h4 { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; margin-bottom: 8px; }
.p-item { display: grid; grid-template-columns: calc(var(--pkey-maxch, 24) * 1ch + 10ch) minmax(0, max-content) 20px; justify-content: start; align-items: start; column-gap: 24px; padding: 5px 0; border-bottom: 1px solid #21262d; font-size: 12px; }
.p-item:nth-child(odd) { background: rgba(148, 163, 184, .04); }
.p-item:nth-child(even) { background: rgba(148, 163, 184, .08); }
.p-key { color: #8b949e; min-width: 0; white-space: nowrap; }
.p-val { color: #e1e4e8; text-align: left; max-width: min(44vw, 860px); word-break: normal; overflow-wrap: anywhere; }
.p-copy { width: 20px; height: 20px; border: 0; background: transparent; color: #7f8da3; cursor: pointer; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; justify-self: end; opacity: .45; transition: opacity .15s ease, color .15s ease, background .15s ease; font-size: 13px; line-height: 1; }
.p-item:hover .p-copy { opacity: .95; }
.p-copy:hover { color: #dbe7ff; background: rgba(88, 166, 255, .14); }
.p-copy.ok { color: #56d364; opacity: 1; }
.inst-name { font-weight: 700; letter-spacing: .01em; }
.col-uuid-cell { font-family: monospace; font-size: 12px; color: #9fb0c6; white-space: nowrap; }
.uuid-wrap { display: inline-flex; align-items: center; gap: 6px; }
.uuid-copy { width: 18px; height: 18px; border: 0; background: transparent; color: #7f8da3; cursor: pointer; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; opacity: .7; transition: opacity .15s ease, color .15s ease, background .15s ease; font-size: 12px; line-height: 1; }
.col-uuid-cell:hover .uuid-copy { opacity: 1; }
.uuid-copy:hover { color: #dbe7ff; background: rgba(88, 166, 255, .14); }
.uuid-copy.ok { color: #56d364; opacity: 1; }
.del-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #6e7681; border: 1px solid #8b949e; }
.del-dot.ok { background: #238636; border-color: #56d364; box-shadow: 0 0 0 2px rgba(35, 134, 54, .2); }
.changed-at-cell { white-space: nowrap; }
.changed-at-cell .del-dot.ok { margin-left: 8px; vertical-align: middle; }
.delete-note { margin-bottom: 10px; padding: 8px 10px; border: 1px solid #30363d; border-radius: 6px; font-size: 12px; color: #c9d1d9; }
.delete-note.ok { border-color: #238636; background: rgba(35, 134, 54, .12); color: #8ddb9e; }
.delete-note.wait { border-color: #6e7681; background: rgba(110, 118, 129, .12); color: #c9d1d9; }
.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; }
.hist-table tbody tr:nth-child(odd) { background: rgba(148, 163, 184, .04); }
.hist-table tbody tr:nth-child(even) { background: rgba(148, 163, 184, .08); }
.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 .data-ts { color: #64748b; }
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 .stat-card { background: #ffffff; border-color: #cbd5e1; }
body.light .stat-card .lbl { color: #64748b; }
body.light .status-badge { border: 1px solid transparent; }
body.light .s-running { background: #dcfce7; color: #166534; border-color: #86efac; }
body.light .s-suspended { background: #fef3c7; color: #92400e; border-color: #fcd34d; }
body.light .s-deleted { background: #fee2e2; color: #991b1b; border-color: #fca5a5; }
body.light .s-deleting { background: #fee2e2; color: #991b1b; border-color: #fca5a5; }
body.light .s-pending { background: #dbeafe; color: #1e40af; border-color: #93c5fd; }
body.light .s-creating { background: #dbeafe; color: #1e40af; border-color: #93c5fd; }
body.light .s-default { background: #e2e8f0; color: #334155; border-color: #cbd5e1; }
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.row-base { background: #ffffff; }
body.light tbody tr.data-row.row-alt { background: #eaf1ff; }
body.light tbody tr.data-row:hover, body.light tbody tr.data-row.expanded { background: #f8fafc; }
body.light .detail-row td { background: #eaf0fb; }
body.light .detail-box {
background: #eaf0fb;
border-color: #b6c6e8;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .8);
}
body.light .tabs { border-bottom-color: #b6c6e8; background: #dfe9fa; }
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-item:nth-child(odd) { background: rgba(148, 163, 184, .12); }
body.light .p-item:nth-child(even) { background: rgba(148, 163, 184, .18); }
body.light .p-key { color: #475569; }
body.light .p-val { color: #0f172a; }
body.light .p-copy { color: #64748b; }
body.light .p-copy:hover { color: #0f172a; background: rgba(29, 78, 216, .10); }
body.light .p-copy.ok { color: #15803d; }
body.light .uuid-copy { color: #64748b; }
body.light .uuid-copy:hover { color: #0f172a; background: rgba(29, 78, 216, .10); }
body.light .uuid-copy.ok { color: #15803d; }
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 .delete-note { border-color: #cbd5e1; color: #334155; background: #f8fafc; }
body.light .delete-note.ok { border-color: #86efac; background: #f0fdf4; color: #166534; }
body.light .delete-note.wait { border-color: #cbd5e1; background: #f8fafc; color: #475569; }
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 .hist-table tbody tr:nth-child(odd) { background: rgba(148, 163, 184, .10); }
body.light .hist-table tbody tr:nth-child(even) { background: rgba(148, 163, 184, .16); }
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(6), th:nth-child(6), td:nth-child(7), th:nth-child(7) { 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>
<span class="data-ts" id="data-ts"></span>
<button class="btn-refresh" id="btn-refresh">&#8635; Обновить</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="instanceUid">UUID <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="8" class="loading">Загрузка...</td></tr>
</tbody>
</table>
</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;
// Table constants are centralized for quick UI reconfiguration.
var TABLE_COLSPAN = 8;
var TABLE_STATE = {
isLoading: false,
loaded: 0,
total: 0,
lastDurationMs: 0
};
var SNAPSHOT_CFG = {
version: 2,
keyPrefix: "dashboard_snapshot_v2",
schema: "instance-stream-v2",
maxChars: 4_500_000
};
// Dashboard behavior and text templates.
var DASHBOARD_CFG = {
deletePolicy: {
status: "suspended",
minDays: 14,
opNamePatterns: ["suspend", "приост", "замороз"],
opTimeFields: ["dtFinish", "dtStart", "dtCreated", "dtState"],
fallbackTimeFields: ["dtState", "instanceConfigDtUpdated"]
},
text: {
deleteHintOnlySuspended: "Удаляемость доступна только для suspended > {days} дней",
deleteReadyTitle: "Можно удалять",
deleteWaitTitle: "Пока рано удалять",
detailNoData: "Детали не загружены. Нажмите «Обновить», чтобы перечитать все данные из API.",
detailDeleteReady: "Инстанс находится в suspended уже {days} дн. Достаточно старый для удаления.",
detailDeleteWait: "Инстанс находится в suspended {days} дн. Для удаления нужно {minDays}+ дней.",
tableNoData: "Нет данных",
tableLoading: "Загрузка данных...",
loadingProgress: "Загрузка: {loaded}/{total}",
freshAt: "Актуально на {time}",
freshAtWithDuration: "Актуально на {time} (загрузка {seconds} c)",
freshFromSnapshot: "Актуально на {time} (из локального snapshot)"
}
};
function fmtText(tpl, vars){
var out = String(tpl || "");
Object.keys(vars || {}).forEach(function(k){ out = out.replaceAll("{" + k + "}", String(vars[k])); });
return out;
}
function escHtml(v){
return String(v == null ? "" : v)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function statusColor(s){
var m = {
running: "#56d364",
suspended: "#e3b341",
deleted: "#f85149",
deleting: "#f85149",
pending: "#58a6ff",
creating: "#58a6ff",
"not created": "#8b949e"
};
return m[s] || "#8b949e";
}
function 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);
if(days >= 1){
var d10 = days % 10, d100 = days % 100;
var w = (d100 >= 11 && d100 <= 19) ? "дней" : d10 === 1 ? "день" : (d10 >= 2 && d10 <= 4) ? "дня" : "дней";
return days + " " + w;
}
if(hrs >= 1) return hrs + " ч";
return mins + " мин";
}
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;
}
// ─── 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 snapshotStorageKey(){
var env = getDeckEnv();
var user = decodeEmail(getToken()) || "anonymous";
return SNAPSHOT_CFG.keyPrefix + ":" + env + ":" + user;
}
// Keep one lightweight local snapshot per user+env for offline-like table work.
function saveSnapshot(){
try {
var payload = {
version: SNAPSHOT_CFG.version,
schema: SNAPSHOT_CFG.schema,
savedAt: new Date().toISOString(),
durationMs: TABLE_STATE.lastDurationMs || 0,
instances: allInstances,
details: rawDetail
};
var raw = JSON.stringify(payload);
if(raw.length > SNAPSHOT_CFG.maxChars) return;
localStorage.setItem(snapshotStorageKey(), raw);
} catch(e){}
}
function clearSnapshot(){
try { localStorage.removeItem(snapshotStorageKey()); } catch(e){}
}
function restoreSnapshot(){
try {
var raw = localStorage.getItem(snapshotStorageKey());
if(!raw) return false;
var snap = JSON.parse(raw);
if(!snap || snap.version !== SNAPSHOT_CFG.version || snap.schema !== SNAPSHOT_CFG.schema) return false;
if(!Array.isArray(snap.instances) || typeof snap.details !== "object" || snap.details === null) return false;
allInstances = snap.instances;
rawDetail = snap.details;
cache = {};
expandedUid = null;
navBackUid = null;
TABLE_STATE.lastDurationMs = Number(snap.durationMs || 0);
var nb = document.getElementById("nav-back");
if(nb) nb.style.display = "none";
renderStats(allInstances);
render(filteredInstances());
var ts = document.getElementById("data-ts");
if(ts){
var d = new Date(snap.savedAt || Date.now());
var t = isNaN(d.getTime())
? new Date().toLocaleTimeString("ru-RU", {hour:"2-digit", minute:"2-digit", second:"2-digit"})
: d.toLocaleTimeString("ru-RU", {hour:"2-digit", minute:"2-digit", second:"2-digit"});
ts.textContent = fmtText(DASHBOARD_CFG.text.freshFromSnapshot, {time: t});
}
return true;
} catch(e){
return false;
}
}
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(){
clearSnapshot();
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; }
}
// Parse timestamp safely: invalid or empty values are treated as missing.
function parseTs(v){
if(!v) return 0;
var t = Date.parse(v);
return isNaN(t) ? 0 : t;
}
// Prefer backend-computed metadata to keep policy logic centralized server-side.
function applyBackendMeta(row, meta){
row._suspendDays = 0;
row._canDelete = false;
row._suspendEnteredTs = 0;
if(!meta || !meta.deleteCandidate) return false;
var m = meta.deleteCandidate;
row._suspendDays = Number(m.daysInSuspend || 0);
row._canDelete = Boolean(m.eligible);
row._suspendEnteredTs = parseTs(m.suspendEnteredAt || "");
return true;
}
function changedIndicatorHtml(inst){
if(!inst || !inst._canDelete) return "";
return " <span class=\"del-dot ok\" title=\"" + escHtml(DASHBOARD_CFG.text.deleteReadyTitle) + "\"></span>";
}
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;
});
}
// Split table rendering into small units to avoid a monolithic render function.
function renderDataRow(inst, expanded, rowIdx){
var uid = inst.instanceUid;
var zebraCls = (rowIdx % 2 === 0) ? " row-base" : " row-alt";
var uuid = inst.instanceUid || "\u2014";
var uuidB64 = uuid && uuid !== "\u2014" ? toBase64Utf8(uuid) : "";
var uuidCopy = uuidB64 ? "<button class=\"uuid-copy\" type=\"button\" title=\"Копировать UUID\" data-copy-b64=\"" + escHtml(uuidB64) + "\" onclick=\"event.stopPropagation();copyParamValue(this);return false;\">⧉</button>" : "";
return "<tr class=\"data-row" + zebraCls + (expanded ? " expanded" : "") + "\" data-uid=\"" + uid + "\">" +
"<td><span class=\"expand-icon\">&#9658;</span></td>" +
"<td class=\"inst-name\">" + escHtml(inst.displayName || "\u2014") + "</td>" +
"<td class=\"col-uuid-cell\"><span class=\"uuid-wrap\"><span>" + escHtml(uuid) + "</span>" + uuidCopy + "</span></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 class=\"changed-at-cell\">" + fmtDt(inst.dtState || inst.instanceConfigDtUpdated) + changedIndicatorHtml(inst) + "</td>" +
"</tr>";
}
function renderDetailRow(uid, expanded){
return "<tr class=\"detail-row" + (expanded ? " open" : "") + "\" id=\"dr-" + uid + "\">" +
"<td colspan=\"" + TABLE_COLSPAN + "\"><div class=\"detail-box\" id=\"dc-" + uid + "\">" +
(expanded ? (cache[uid] || "<div class=\"loading\">Загрузка...</div>") : "") +
"</div></td></tr>";
}
function render(list){
var tbody = document.getElementById("tbody");
var s = sorted(list);
if(!s.length){
tbody.innerHTML = "<tr><td colspan=\"" + TABLE_COLSPAN + "\" class=\"loading\">" + DASHBOARD_CFG.text.tableNoData + "</td></tr>";
return;
}
var html = "";
s.forEach(function(inst, idx){
var uid = inst.instanceUid;
var exp = expandedUid === uid;
html += renderDataRow(inst, exp, idx);
html += renderDetailRow(uid, exp);
});
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 {
cache[uid] = "<div class=\"empty-msg\">" + DASHBOARD_CFG.text.detailNoData + "</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");
});
});
}
function buildDetail(inst){
if(!inst) return "<div class=\"err-msg\">Нет данных</div>";
var uid = inst.instanceUid || "x";
var rowMeta = allInstances.find(function(i){ return i.instanceUid === uid; }) || null;
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 h = "";
if(rowMeta && rowMeta.explainedStatus === DASHBOARD_CFG.deletePolicy.status){
if(rowMeta._canDelete){
h += "<div class=\"delete-note ok\">" + escHtml(fmtText(DASHBOARD_CFG.text.detailDeleteReady, {days: rowMeta._suspendDays})) + "</div>";
} else {
h += "<div class=\"delete-note wait\">" + escHtml(fmtText(DASHBOARD_CFG.text.detailDeleteWait, {days: rowMeta._suspendDays, minDays: DASHBOARD_CFG.deletePolicy.minDays})) + "</div>";
}
}
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>" +
"</div>";
var inKeyCh = maxParamKeyLen(params, "");
var outKeyCh = maxParamKeyLen(out, "");
h += "<div class=\"panel panel-params active\" id=\"p-" + uid + "\"><div class=\"params-grid\">" +
"<div class=\"params-section\" style=\"--pkey-maxch:" + inKeyCh + "\"><h4>Input (params)</h4>" + renderP(params) + "</div>" +
"<div class=\"params-section\" style=\"--pkey-maxch:" + outKeyCh + "\"><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>";
return h;
}
function renderP(obj, pfx){
if(!obj || typeof obj !== "object"){
var rootVal = obj != null ? String(obj) : "\u2014";
return "<div class=\"p-item\"><span class=\"p-key\"></span><span class=\"p-val\">" + escHtml(rootVal) + "</span>" + renderCopyBtnHtml(rootVal) + "</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\">" + escHtml(key) + "</span><span class=\"p-val\">" + escHtml(val) + "</span>" + renderCopyBtnHtml(val) + "</div>";
}
});
return r || "<div class=\"empty-msg\">Пусто</div>";
}
function toBase64Utf8(s){
try { return btoa(unescape(encodeURIComponent(String(s == null ? "" : s)))); }
catch(e){ return ""; }
}
function fromBase64Utf8(s){
try { return decodeURIComponent(escape(atob(String(s || "")))); }
catch(e){ return ""; }
}
function renderCopyBtnHtml(val){
var raw = String(val == null ? "" : val);
var normalized = raw.replace(/\s+/g, " ").trim();
if(!normalized || normalized === "\u2014") return "<span></span>";
var b64 = toBase64Utf8(val);
return "<button class=\"p-copy\" type=\"button\" title=\"Копировать значение\" data-copy-b64=\"" + escHtml(b64) + "\" onclick=\"copyParamValue(this)\">⧉</button>";
}
function copyParamValue(btn){
if(!btn) return;
var encoded = btn.getAttribute("data-copy-b64") || "";
var text = fromBase64Utf8(encoded);
var done = function(){
var prev = btn.textContent;
btn.textContent = "✓";
btn.classList.add("ok");
setTimeout(function(){ btn.textContent = prev; btn.classList.remove("ok"); }, 900);
};
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(done).catch(function(){});
return;
}
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "readonly");
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); done(); } catch(e) {}
document.body.removeChild(ta);
}
function maxParamKeyLen(obj, pfx){
if(!obj || typeof obj !== "object") return 1;
var maxLen = 1;
Object.keys(obj).forEach(function(k){
var v = obj[k];
var key = pfx ? pfx + "." + k : k;
if(key.length > maxLen) maxLen = key.length;
if(v && typeof v === "object" && !Array.isArray(v)){
var nested = maxParamKeyLen(v, key);
if(nested > maxLen) maxLen = nested;
}
});
return maxLen;
}
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) return;
var nowStr = new Date().toLocaleTimeString("ru-RU", {hour:"2-digit", minute:"2-digit", second:"2-digit"});
ts.textContent = fmtText(DASHBOARD_CFG.text.freshAt, {time: nowStr});
}
function showLoadingProgress(loaded, total){
var ts = document.getElementById("data-ts");
if(!ts) return;
var t = total || 0;
TABLE_STATE.loaded = loaded;
TABLE_STATE.total = t;
ts.textContent = fmtText(DASHBOARD_CFG.text.loadingProgress, {loaded: loaded, total: t});
}
function updateTimestampWithDuration(durationMs){
var ts = document.getElementById("data-ts");
if(!ts) return;
TABLE_STATE.lastDurationMs = durationMs || 0;
var sec = Math.max(0, Math.round(TABLE_STATE.lastDurationMs / 1000));
var nowStr = new Date().toLocaleTimeString("ru-RU", {hour:"2-digit", minute:"2-digit", second:"2-digit"});
ts.textContent = fmtText(DASHBOARD_CFG.text.freshAtWithDuration, {time: nowStr, seconds: sec});
}
function scheduleRender(){
// Throttle UI updates during stream without postponing them until the end.
if(renderTimer) return;
renderTimer = setTimeout(function(){
renderTimer = null;
renderStats(allInstances);
render(filteredInstances());
}, 150);
}
// Full refresh mode: read all data from API, then work from memory until next refresh.
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 = "Загрузка: 0/0";
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"" + TABLE_COLSPAN + "\" class=\"loading\">" + DASHBOARD_CFG.text.tableLoading + "</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){
updateTimestampWithDuration(obj.durationMs || 0);
saveSnapshot();
renderStats(allInstances);
render(filteredInstances());
return;
}
if(obj._progress){
showLoadingProgress(obj._progress.loaded || 0, obj._progress.total || 0);
delete obj._progress;
}
var uid = obj.instanceUid;
if(!uid) return;
var backendMeta = obj._meta;
if(backendMeta !== undefined) delete obj._meta;
var detail = obj._detail;
if(detail !== undefined) delete obj._detail;
applyBackendMeta(obj, backendMeta);
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=\"" + TABLE_COLSPAN + "\" 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(!restoreSnapshot()) loadAllDetails();
}
document.getElementById("btn-login").addEventListener("click", doLogin);
document.getElementById("btn-refresh").addEventListener("click", loadAllDetails);
document.getElementById("btn-theme").addEventListener("click", toggleTheme);
document.getElementById("btn-exit").addEventListener("click", doLogout);
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;
window.copyParamValue = copyParamValue;
})();
</script>
</body>
</html>