simbiot-harness/harness/dashboard.html
Simbiot Harness a0179e5df7 fix(guard)+feat(dash): requeue ready-гонка не валила тик (e-261001083543-0eab6d); дашборд «🔧 в работе» + elapsed (t-261001082111); пакет покупателя предпродажи (t-261001085722)
- scheduler: guard «бюджет исчерпан» вынесен в _guard_budget_stub_requeue()
  — ValueError requeue_task при ready (задача уже возвращена task_promoted
  путём того же тика) гасится, тик дофинализируется; тесты
  tests/test_guard_ready_race.py (FAILS 0), test_budget_stub_guard зелёный;
- es: TaskClaimed → task.claimed_at; TaskPromoted(requeue) → claimed_at=None;
- dashboard.py/.html: API отдаёт claimed_at; бейдж «🔧 в работе: hour · 12м»
  (минуты/секунды/часы), подсветка running-строки, «взята в работу» в деталях;
- docs/PACKAGE-SIMBIOT-PRESALE.md: состав пакета подписки (EN-блок для
  лендинга, состояние компонентов), цена единая $99.9 по директиве прайса
2026-10-01 09:11:23 +00:00

267 lines
No EOL
15 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}
tr.task{cursor:pointer}
tr.task.open td{background:#fff3ea}
tr.taskdetail td{background:var(--card);border-bottom:1px solid var(--ink)}
.taskdetail .box{display:grid;grid-template-columns:150px 1fr;gap:4px 14px;padding:8px 2px 12px;font-size:13px}
.taskdetail .k{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--dim);padding-top:3px}
.taskdetail pre{white-space:pre-wrap;word-break:break-word;margin:0;font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.5}
.hint{color:var(--dim);font-size:12px;font-family:ui-monospace,Menlo,monospace}
.arrow{display:inline-block;transition:transform .15s;color:var(--dim);font-size:10px}
tr.task.open .arrow{transform:rotate(90deg)}
.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)}
.pill.working{background:var(--signal);border-color:var(--signal);color:#fff;cursor:default}
tr.task.running td{background:#fff3ea}
.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, внутри — новые сверху · <span class="hint">клик по строке — детали задачи</span></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);
// t-261001082111: таймер elapsed по running-задаче — «сейчас в работе».
// Claim-время приходит из API (TaskClaimed в проекции), «сейчас» — часы
// браузера (обновление каждые 5с живое, серверная generated_at не нужна).
const elapsedSec = claimed => {
const t0 = claimed ? Date.parse(claimed) : NaN;
return Number.isFinite(t0) ? Math.max(0, Math.floor((Date.now() - t0) / 1000)) : null;
};
const elapsedStr = s => {
if (s == null) return "—";
if (s < 60) return s + "с";
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
return (h ? h + "ч " : "") + (h && !m && s % 60 < 30 ? "" : m + "м");
};
const workingTag = t => {
if (t.status !== "running") return "";
return ` <span class="pill working" title="взята в работу: ${esc(t.claimed_at || "—")}">🔧 в работе: ${esc(t.level || "?")} · ${esc(elapsedStr(elapsedSec(t.claimed_at)))}</span>`;
};
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>`; }
// детали задачи по клику (т-260929220004): раскрытие строки без перезагрузки
const openTasks = new Set(); // id раскрытых строк (переживает автообновление)
function toggleTask(id){
if(openTasks.has(id)) openTasks.delete(id); else openTasks.add(id);
const el = document.getElementById("tr-" + id);
if(el) el.classList.toggle("open", openTasks.has(id));
const det = document.getElementById("det-" + id);
if(det) det.style.display = openTasks.has(id) ? "" : "none";
}
function taskDetail(t){
const row = [
["id", t.id],
["размер / статус", `${t.size} · ${t.status}${t.level ? " · уровень " + t.level : ""}`],
["создана", fmtTime(t.created)],
t.status === "running" && t.claimed_at ? ["взята в работу", fmtTime(t.claimed_at) + " · " + elapsedStr(elapsedSec(t.claimed_at)) + " назад"] : null,
["завершена", t.finished ? fmtTime(t.finished) : "—"],
["session", t.session_id || "—"],
t.promoted_to ? ["промоушен", `${t.promotions || 1}× → ${t.promoted_to}`] : null,
t.error ? ["ошибка", t.error] : null,
t.report ? ["отчёт (" + (t.report_level || "?") + ")", t.report] : null,
].filter(Boolean);
const box = row.map(([k, v]) => `<div class="k">${esc(k)}</div><div><pre>${esc(v)}</pre></div>`).join("");
return `<tr class="taskdetail" id="det-${esc(t.id)}" style="display:${openTasks.has(t.id) ? "" : "none"}"><td colspan="7"><div class="box">${box}</div></td></tr>`;
}
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>`;
// задачи (клик по строке — детали, т-260929220004)
$("taskcount").textContent = `(${s.tasks.length})`;
$("tasks").innerHTML = s.tasks.length ? `<table>
<tr><th></th><th>id</th><th>задача</th><th>размер</th><th>статус</th><th>уровень</th><th>создана</th></tr>
${s.tasks.map(t => `<tr class="task${openTasks.has(t.id) ? " open" : ""}" id="tr-${esc(t.id)}" onclick="toggleTask('${esc(t.id)}')">
<td class="arrow">▶</td>
<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)}${workingTag(t)}</td>
<td class="m">${esc(t.level || "—")}</td>
<td class="m dim">${fmtTime(t.created)}</td>
</tr>
${taskDetail(t)}`).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>