1075 lines
49 KiB
HTML
1075 lines
49 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; }
|
|
.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-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.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; }
|
|
.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 { 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 .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 .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">↻ Обновить</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="instanceUid">UUID <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="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>
|
|
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 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, "&")
|
|
.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 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;
|
|
}
|
|
|
|
|
|
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\">►</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 ? "↑" : "↓") : "↕";
|
|
});
|
|
}
|
|
|
|
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 ? "⌛" : 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 panel-history\" 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 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 = {};
|
|
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>⚠ Инстанс скрыт текущими фильтрами</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>
|