simbiot-harness/harness/dashboard.html
Simbiot Harness c68335e7fb feat(dashboard): веб-дашборд задач харнеса — все задачи/статусы/размеры/уровни, лестница, эскалации, дайджесты; автообновление 5с (t-260929164722-60dae2)
- harness/dashboard.py: stdlib-only HTTP-сервер, /api/state (read-only из event store через run_projections — та же проекция, что у scheduler/CLI), /api/health на отдельном порту 8766 (без auth — для systemd healthcheck)
- harness/dashboard.html: страница в стиле architecture.html (paper/ink/signal), fetch каждые 5с, честный errbox при недоступности API, XSS-экранирование всех полей
- auth: SIMBIOT_DASH_TOKEN из /etc/simbiot/dashboard.env (env-файл юнита, 600); без/с неверным токеном — 401; токен в query → cookie
- systemd-юнит simbiot-dashboard.service (после scheduler, restart=always), порт 8765/tcp в UFW
2026-09-29 18:04:38 +00:00

211 lines
No EOL
11 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>Simbiot Harness — дашборд задач</title>
<style>
:root{
--paper:#faf8f3; --ink:#15150f; --signal:#FF4F00;
--dim:#6b675e; --line:#d8d3c8; --card:#f3f0e8;
--ok:#1d7a3e; --warn:#a3690f; --err:#b3261e;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--paper);color:var(--ink);font:16px/1.62 -apple-system,"Segoe UI",Roboto,Arial,sans-serif;-webkit-font-smoothing:antialiased}
.mono,code{font-family:ui-monospace,"JetBrains Mono","SF Mono",Menlo,Consolas,monospace}
.wrap{max-width:1080px;margin:0 auto;padding:36px 28px 90px}
header{border-bottom:3px solid var(--ink);padding-bottom:22px;margin-bottom:28px}
.kicker{font-family:ui-monospace,Menlo,monospace;font-size:12px;letter-spacing:.22em;color:var(--signal);font-weight:700;text-transform:uppercase}
h1{font-size:clamp(28px,5vw,44px);line-height:1.05;font-weight:800;letter-spacing:-.02em;margin:8px 0 10px;text-transform:uppercase}
.sub{font-size:15px;color:var(--dim)}
.meta{display:flex;flex-wrap:wrap;gap:10px 18px;margin-top:14px;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--dim)}
.meta b{color:var(--ink)}
.live{display:inline-flex;align-items:center;gap:7px;font-family:ui-monospace,Menlo,monospace;font-size:12px;letter-spacing:.12em;text-transform:uppercase}
.dot{width:9px;height:9px;border-radius:50%;background:var(--ok);display:inline-block}
.dot.err{background:var(--err)}
.dot.wait{background:var(--warn)}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin:22px 0}
.stat{border:1px solid var(--ink);background:var(--card);padding:12px 14px}
.stat .n{font-family:ui-monospace,Menlo,monospace;font-size:26px;font-weight:800}
.stat .l{font-family:ui-monospace,Menlo,monospace;font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--dim);margin-top:2px}
.stat.hot{background:var(--ink);color:var(--paper)}
.stat.hot .l{color:#cfcbc0}
h2{font-size:19px;font-weight:800;text-transform:uppercase;letter-spacing:.02em;margin:34px 0 4px;border-left:3px solid var(--signal);padding-left:10px}
.lead{color:var(--dim);font-size:13.5px;margin:0 0 14px 13px}
table{width:100%;border-collapse:collapse;font-size:13px;margin:6px 0 8px}
th{background:var(--ink);color:var(--paper);text-align:left;padding:8px 9px;font-family:ui-monospace,Menlo,monospace;font-size:10.5px;letter-spacing:.09em;text-transform:uppercase}
td{border-bottom:1px solid var(--line);padding:8px 9px;vertical-align:top}
tr:hover td{background:#fff3ea}
.m{font-family:ui-monospace,Menlo,monospace;font-size:11.5px}
.dim{color:var(--dim)}
.pill{display:inline-block;border:1px solid var(--ink);padding:1px 7px;font-family:ui-monospace,Menlo,monospace;font-size:10.5px;white-space:nowrap;letter-spacing:.06em}
.pill.ready{background:#eaf5ec}
.pill.running{background:var(--ink);color:var(--paper)}
.pill.failed{background:#fdeaec;border-color:var(--err);color:var(--err)}
.pill.done{background:var(--card);color:var(--dim)}
.sizeS{color:var(--ok)} .sizeM{color:var(--warn)} .sizeL{color:var(--signal)} .sizeXL{color:var(--err)}
.pill.sev-critical{background:var(--err);border-color:var(--err);color:#fff}
.pill.sev-warn{background:#fdeedd;border-color:var(--warn);color:var(--warn)}
.errbox{display:none;border:2px solid var(--err);background:#fdeaec;padding:14px 16px;margin:18px 0;font-family:ui-monospace,Menlo,monospace;font-size:13px}
.skel{color:var(--dim);font-family:ui-monospace,Menlo,monospace;font-size:13px;padding:16px 2px}
.digest{border:1px solid var(--line);border-left:3px solid var(--signal);background:var(--card);padding:10px 14px;margin:8px 0;font-size:13px}
.digest .h{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--dim);margin-bottom:4px}
.digest p{white-space:pre-wrap;max-width:100%;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
a{color:var(--signal)}
footer{margin-top:46px;border-top:1px solid var(--line);padding-top:14px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;color:var(--dim)}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="kicker">Simbiot Harness · такт &lt; час</div>
<h1>Дашборд задач</h1>
<div class="sub">Все задачи харнеса: статусы, размеры, уровни. Лестница циклов, эскалации, свежие дайджесты. Автообновление.</div>
<div class="meta">
<span class="live"><span class="dot wait" id="dot"></span><span id="conn">соединяюсь…</span></span>
<span>обновлено: <b id="updated">—</b> UTC</span>
<span>событий в сторе: <b id="events">—</b></span>
<span>обновление: <b id="refresh">5</b>с</span>
</div>
</header>
<div class="errbox" id="errbox"></div>
<div class="stats" id="stats"></div>
<h2>Лестница уровней</h2>
<div class="lead">сек → мин → час → день · занятый уровень поглощает такты</div>
<div id="ladder"><div class="skel">загружаю…</div></div>
<h2>Задачи <span class="m dim" id="taskcount"></span></h2>
<div class="lead">сортировка: ready → running → failed → done, внутри — новые сверху</div>
<div id="tasks"><div class="skel">загружаю…</div></div>
<h2>Открытые эскалации <span class="m dim" id="esccount"></span></h2>
<div id="escalations"><div class="skel">загружаю…</div></div>
<h2>Свежие дайджесты</h2>
<div class="lead">выжимки тиков всех уровней — последние 12</div>
<div id="digests"><div class="skel">загружаю…</div></div>
<footer>
JSON-API: <code>/api/state</code> · healthcheck: <code>/api/health</code> ·
источник — event store (CQRS/ES, проекция на лету) ·
сервис <code>simbiot-dashboard.service</code>, порт 8765
</footer>
</div>
<script>
"use strict";
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const $ = id => document.getElementById(id);
const fmtTime = s => (s || "").replace("T", " ").slice(5, 19);
const REFRESH = (() => {
const v = parseInt(new URLSearchParams(location.search).get("refresh") || "", 10);
return v > 0 && v <= 120 ? v : 5;
})();
let api = "api/state";
{ const tok = new URLSearchParams(location.search).get("token");
if (tok) { document.cookie = "simbiot_token=" + encodeURIComponent(tok) + ";max-age=86400;SameSite=Strict"; }
}
api += (api.includes("?") ? "&" : "?") + "t=";
$("refresh").textContent = REFRESH;
function pill(status){ return `<span class="pill ${esc(status)}">${esc(status)}</span>`; }
function size(s){ return `<span class="pill size${esc(s)}">${esc(s)}</span>`; }
function setConn(cls, text){ $("dot").className = "dot " + cls; $("conn").textContent = text; }
async function tick(){
try{
const r = await fetch(api + Date.now(), {cache: "no-store"});
if(!r.ok) throw new Error("HTTP " + r.status);
const s = await r.json();
if(s.error) throw new Error(s.error);
$("errbox").style.display = "none";
setConn("", "live");
render(s);
}catch(e){
setConn("err", "нет связи");
const box = $("errbox");
box.style.display = "block";
box.textContent = "API недоступен: " + e.message + " — попробую снова через " + REFRESH + "с";
}
}
function render(s){
$("updated").textContent = (s.generated_at || "").slice(11, 19);
$("events").textContent = s.counts.events_total;
const c = s.counts, st = c.tasks_by_status || {};
$("stats").innerHTML = `
<div class="stat"><div class="n">${c.tasks_total}</div><div class="l">задач всего</div></div>
<div class="stat"><div class="n">${st.ready || 0}</div><div class="l">ready</div></div>
<div class="stat hot"><div class="n">${st.running || 0}</div><div class="l">running</div></div>
<div class="stat"><div class="n">${st.failed || 0}</div><div class="l">failed</div></div>
<div class="stat"><div class="n">${st.done || 0}</div><div class="l">done</div></div>
<div class="stat"><div class="n">${c.escalations_open || 0}</div><div class="l">эскалации</div></div>
<div class="stat"><div class="n">${c.digests_total || 0}</div><div class="l">дайджестов</div></div>`;
// лестница
$("ladder").innerHTML = s.ladder.length ? `<table>
<tr><th>уровень</th><th>статус</th><th class="m">тики</th><th class="m">absorbed</th><th class="m">ошибки</th><th>последний тик</th></tr>
${s.ladder.map(l => `<tr>
<td class="m"><b>${esc(l.level)}</b></td>
<td>${l.paused ? '<span class="pill failed">paused</span>'
: l.busy ? '<span class="pill running">busy</span>'
: '<span class="pill ready">ok</span>'}
${l.errors ? `<span class="m dim" title="${esc(l.last_error)}">⚠ ${l.errors}</span>` : ""}</td>
<td class="m">${l.ticks}</td>
<td class="m">${l.absorbed || 0}</td>
<td class="m">${l.errors || 0}</td>
<td class="m dim">${fmtTime(l.last_tick)}</td>
</tr>`).join("")}</table>`
: `<div class="skel">нет данных</div>`;
// задачи
$("taskcount").textContent = `(${s.tasks.length})`;
$("tasks").innerHTML = s.tasks.length ? `<table>
<tr><th>id</th><th>задача</th><th>размер</th><th>статус</th><th>уровень</th><th>создана</th></tr>
${s.tasks.map(t => `<tr>
<td class="m dim">${esc(t.id)}</td>
<td><b>${esc(t.title)}</b>${t.error ? `<br><span class="m" style="color:var(--err)">${esc(String(t.error).slice(0,140))}</span>` : ""}</td>
<td>${size(t.size)}</td>
<td>${pill(t.status)}</td>
<td class="m">${esc(t.level || "—")}</td>
<td class="m dim">${fmtTime(t.created)}</td>
</tr>`).join("")}</table>`
: `<div class="skel">задач нет</div>`;
// эскалации
$("esccount").textContent = `(${s.escalations.length})`;
$("escalations").innerHTML = s.escalations.length ? `<table>
<tr><th>id</th><th>sev</th><th>маршрут</th><th>причина</th><th>поднята</th></tr>
${s.escalations.map(e => `<tr>
<td class="m dim">${esc(e.id)}</td>
<td><span class="pill sev-${esc(e.severity)}">${esc(e.severity)}</span></td>
<td class="m">${esc(e.from_level)} → ${esc(e.to_level)}</td>
<td><b>${esc(e.reason)}</b></td>
<td class="m dim">${fmtTime(e.ts)}</td>
</tr>`).join("")}</table>`
: `<div class="skel">открытых нет — чисто</div>`;
// дайджесты
$("digests").innerHTML = s.digests.length
? s.digests.map(d => `<div class="digest">
<div class="h">${esc(d.level)} · ${fmtTime(d.ts)} · ref ${esc(d.summary_ref)}</div>
<p>${esc((d.text || "").slice(0, 300))}</p>
</div>`).join("")
: `<div class="skel">дайджестов нет</div>`;
}
tick();
setInterval(tick, REFRESH * 1000);
</script>
</body>
</html>