v9.43: pre-graph-cleanup baseline (zebra, uuid copy, theme fix, delete policy)

This commit is contained in:
Naeel
2026-04-17 08:51:07 +03:00
parent 38efc70bed
commit b778f5554c
7 changed files with 940 additions and 124 deletions
+377 -94
View File
@@ -30,6 +30,7 @@
.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; }
@@ -69,6 +70,8 @@
.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; }
@@ -77,18 +80,25 @@
.expanded .expand-icon { transform: rotate(90deg); }
.detail-row { display: none; }
.detail-row.open { display: table-row; }
.detail-row td { padding: 0; border-top: 1px solid #30363d; }
.detail-box { padding: 16px 22px; background: #0d1117; }
.tabs { display: flex; border-bottom: 1px solid #30363d; margin-bottom: 14px; }
.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: #111827; border-top: 2px solid #1e3a5f; }
.panel-history { background: #0f1a0f; border-top: 2px solid #1a4a2e; }
.panel-deps { background: #1a1209; border-top: 2px solid #3d2a00; }
body.light .panel-params { background: #eff6ff; border-top: 2px solid #bfdbfe; }
body.light .panel-history { background: #f0fdf4; border-top: 2px solid #bbf7d0; }
body.light .panel-deps { background: #fffbeb; border-top: 2px solid #fde68a; }
.panel-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; }
@@ -104,14 +114,37 @@
.graph-modal-body { flex: 1; min-height: 0; }
.running-graph { width: 100%; height: 100%; }
.graph-inline-note { color: #8b949e; font-size: 12px; margin-top: 8px; }
.params-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.params-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: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid #21262d; font-size: 12px; gap: 12px; }
.p-key { color: #8b949e; min-width: 140px; flex-shrink: 0; }
.p-val { color: #e1e4e8; word-break: break-all; text-align: right; }
.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; }
@@ -135,16 +168,35 @@
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-box { 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); }
@@ -152,20 +204,33 @@
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(5), th:nth-child(5), td:nth-child(4), th:nth-child(4) { display: none; }
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; }
}
@@ -206,8 +271,8 @@
<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-graph" id="btn-running-graph">📊 Граф зависимостей</button>
<button class="btn-theme" id="btn-theme" title="Сменить тему">🌙</button>
<div class="header-user">
<span class="user-email" id="user-email"></span>
@@ -223,6 +288,7 @@
<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>
@@ -231,23 +297,12 @@
</tr>
</thead>
<tbody id="tbody">
<tr><td colspan="7" class="loading">Загрузка...</td></tr>
<tr><td colspan="8" class="loading">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="graph-modal" id="graph-modal">
<div class="graph-modal-box">
<div class="graph-modal-head">
<div class="graph-modal-title" id="graph-modal-title">Общий граф зависимостей: running</div>
<button class="graph-modal-close" id="btn-close-graph">Закрыть</button>
</div>
<div class="graph-modal-body">
<div class="running-graph" id="running-graph"></div>
</div>
</div>
</div>
<div id="js-err" style="display:none;position:fixed;bottom:0;left:0;right:0;background:#f85149;color:#fff;padding:10px;font-family:monospace;font-size:12px;z-index:9999"></div>
<script src="https://cdn.jsdelivr.net/npm/panzoom@9.4.3/dist/panzoom.min.js"></script>
<script>
@@ -271,6 +326,51 @@ 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)
@@ -678,6 +778,68 @@ function decodeEmail(token){
} 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(),
@@ -693,6 +855,7 @@ function showLoginErr(msg){
}
function doLogout(){
clearSnapshot();
localStorage.removeItem("deck_token");
location.reload();
}
@@ -712,6 +875,31 @@ function fmtDt(s){
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; });
@@ -732,27 +920,45 @@ function sorted(arr){
});
}
function render(list){
var tbody = document.getElementById("tbody");
var s = sorted(list);
if(!s.length){ tbody.innerHTML = "<tr><td colspan=\"7\" class=\"loading\">Нет данных</td></tr>"; return; }
var html = "";
s.forEach(function(inst){
// Split table rendering into small units to avoid a monolithic render function.
function renderDataRow(inst, expanded, rowIdx){
var uid = inst.instanceUid;
var exp = expandedUid === uid;
html += "<tr class=\"data-row" + (exp ? " expanded" : "") + "\" data-uid=\"" + uid + "\">" +
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>" + (inst.displayName || "\u2014") + "</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>" + fmtDt(inst.dtState || inst.instanceConfigDtUpdated) + "</td>" +
"<td class=\"changed-at-cell\">" + fmtDt(inst.dtState || inst.instanceConfigDtUpdated) + changedIndicatorHtml(inst) + "</td>" +
"</tr>";
html += "<tr class=\"detail-row" + (exp ? " open" : "") + "\" id=\"dr-" + uid + "\">" +
"<td colspan=\"7\"><div class=\"detail-box\" id=\"dc-" + uid + "\">" +
(exp ? (cache[uid] || "<div class=\"loading\">Загрузка...</div>") : "") +
}
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;
@@ -776,6 +982,7 @@ function toggle(uid, row){
row.classList.remove("expanded");
return;
}
if(expandedUid){
var pd = document.getElementById("dr-" + expandedUid);
if(pd) pd.classList.remove("open");
@@ -790,22 +997,10 @@ function toggle(uid, row){
cache[uid] = buildDetail(rawDetail[uid]);
dc.innerHTML = cache[uid];
bindTabs(dc);
} else {
dc.innerHTML = "<div class=\"loading\">Загрузка...</div>";
fetch(API + "/instances/" + uid, { headers: apiHeaders() })
.then(function(r){
if(r.status === 401){ doLogout(); return null; }
return r.json();
})
.then(function(d){
if(!d) return;
rawDetail[uid] = d.instance;
cache[uid] = buildDetail(d.instance);
dc.innerHTML = cache[uid];
bindTabs(dc);
})
.catch(function(e){ cache[uid] = "<div class=\"err-msg\">" + e.message + "</div>"; dc.innerHTML = cache[uid]; });
}
} else {
cache[uid] = "<div class=\"empty-msg\">" + DASHBOARD_CFG.text.detailNoData + "</div>";
dc.innerHTML = cache[uid];
}
} else {
dc.innerHTML = cache[uid];
bindTabs(dc);
@@ -822,10 +1017,6 @@ function bindTabs(container){
var pid = tab.dataset.panel;
var panel = document.getElementById(pid);
if(panel) panel.classList.add("active");
if(pid && pid.indexOf("g-graph-") === 0){
var uid = pid.slice("g-graph-".length);
if(uid) setTimeout(function(){ renderInstanceGraph(uid); }, 30);
}
});
});
}
@@ -833,6 +1024,7 @@ function bindTabs(container){
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 || [];
@@ -841,18 +1033,27 @@ function buildDetail(inst){
var visible = visibleUidSet();
var deps = rawDeps.filter(function(d){ var id = depUid(d); return id && visible.has(id); });
var depOn = rawDepOn.filter(function(d){ var id = depUid(d); return id && visible.has(id); });
var hasDeps = (deps.length + depOn.length) > 0;
var h = "<div class=\"tabs\">" +
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>" +
(hasDeps ? "<div class=\"tab\" data-panel=\"g-graph-" + uid + "\">Граф</div>" : "") +
"</div>";
h += "<div class=\"panel panel-params active\" id=\"p-" + uid + "\"><div class=\"params-grid\">" +
"<div class=\"params-section\"><h4>Input (params)</h4>" + renderP(params) + "</div>" +
"<div class=\"params-section\"><h4>Output (out)</h4>" + renderP(out) + "</div>" +
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 = "";
@@ -900,18 +1101,14 @@ function buildDetail(inst){
"<div class=\"deps-section\"><h4>Зависят от него (" + depOn.length + ")</h4>" + depOnHtml + "</div>" +
"</div></div>";
if(hasDeps){
h += "<div class=\"panel\" id=\"g-graph-" + uid + "\">" +
"<div class=\"graph-hint\">Стрелка A → B означает: B зависит от A. Можно масштабировать колесом и перетаскивать узлы.</div>" +
"<div class=\"inst-graph\" id=\"g-" + uid + "\"><div class=\"graph-loading\">Нажмите вкладку «Граф» для расчёта...</div></div>" +
"</div>";
}
return h;
}
function renderP(obj, pfx){
if(!obj || typeof obj !== "object") return "<div class=\"p-item\"><span class=\"p-val\">" + (obj || "\u2014") + "</span></div>";
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){
@@ -921,12 +1118,70 @@ function renderP(obj, pfx){
r += renderP(v, key);
} else {
var val = Array.isArray(v) ? JSON.stringify(v) : (v != null ? String(v) : "\u2014");
r += "<div class=\"p-item\"><span class=\"p-key\">" + key + "</span><span class=\"p-val\">" + val + "</span></div>";
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");
@@ -943,11 +1198,32 @@ function filteredInstances(){
function updateTimestamp(){
var ts = document.getElementById("data-ts");
if(ts) ts.textContent = "Актуально на " + new Date().toLocaleTimeString("ru-RU",{hour:"2-digit",minute:"2-digit",second:"2-digit"});
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(){
if(renderTimer) clearTimeout(renderTimer);
// Throttle UI updates during stream without postponing them until the end.
if(renderTimer) return;
renderTimer = setTimeout(function(){
renderTimer = null;
renderStats(allInstances);
@@ -955,7 +1231,8 @@ function scheduleRender(){
}, 150);
}
function loadAllDetails(){
// Full refresh mode: read all data from API, then work from memory until next refresh.
function loadAllDetails(){
allInstances = [];
cache = {};
rawDetail = {};
@@ -968,8 +1245,8 @@ function loadAllDetails(){
var nb = document.getElementById("nav-back");
if(nb) nb.style.display = "none";
var ts = document.getElementById("data-ts");
if(ts) ts.textContent = "";
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"7\" class=\"loading\">Загрузка данных...</td></tr>";
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){
@@ -987,16 +1264,27 @@ function loadAllDetails(){
var obj;
try { obj = JSON.parse(line); } catch(e){ return; }
if(obj._done){
updateTimestamp();
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;
if(detail) rawDetail[uid] = detail;
applyBackendMeta(obj, backendMeta);
if(detail){
rawDetail[uid] = detail;
}
allInstances.push(obj);
scheduleRender();
}
@@ -1005,7 +1293,7 @@ function loadAllDetails(){
return reader.read().then(function(result){
if(result.done){
if(buffer.trim()) processLine(buffer.trim());
updateTimestamp();
updateTimestamp();
renderStats(allInstances);
render(filteredInstances());
return;
@@ -1020,7 +1308,7 @@ function loadAllDetails(){
return pump();
})
.catch(function(e){
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"7\" class=\"err-msg\">" + escHtml(e.message) + "</td></tr>";
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"" + TABLE_COLSPAN + "\" class=\"err-msg\">" + escHtml(e.message) + "</td></tr>";
});
}
@@ -1063,8 +1351,8 @@ function applyTheme(theme){
document.body.classList.toggle("light", isLight);
var btn = document.getElementById("btn-theme");
if(btn){
btn.textContent = isLight ? "☀" : "🌙";
btn.title = isLight ? "Тема: день" : "Тема: ночь";
btn.textContent = isLight ? "🌙" : "☀";
btn.title = isLight ? "Переключить на ночную тему" : "Переключить на дневную тему";
}
localStorage.setItem("dashboard_theme", isLight ? "light" : "dark");
}
@@ -1117,21 +1405,15 @@ function initApp(){
document.getElementById("chk-auxiliary").addEventListener("change", function(){
cache = {};
render(filteredInstances());
if(graphRunningData){ drawGraph("running-graph", graphRunningData, ""); }
});
loadAllDetails();
if(!restoreSnapshot()) loadAllDetails();
}
document.getElementById("btn-login").addEventListener("click", doLogin);
document.getElementById("btn-refresh").addEventListener("click", loadAllDetails);
document.getElementById("btn-running-graph").addEventListener("click", openRunningGraph);
document.getElementById("btn-theme").addEventListener("click", toggleTheme);
document.getElementById("btn-exit").addEventListener("click", doLogout);
document.getElementById("graph-modal").addEventListener("click", function(e){
if(e.target && e.target.id === "graph-modal") closeRunningGraph();
});
applyTheme(localStorage.getItem("dashboard_theme") || "dark");
var envSelect = document.getElementById("deck-env");
@@ -1142,6 +1424,7 @@ if(getToken()) initApp();
// Expose functions called from inline onclick attributes
window.navigateToDep = navigateToDep;
window.navBack = navBack;
window.copyParamValue = copyParamValue;
})();
</script>