- 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 по директиве прайса
267 lines
No EOL
15 KiB
HTML
267 lines
No EOL
15 KiB
HTML
<!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 · такт < час</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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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> |