- es: TaskClosed → статус closed (терминальный, обратимый через requeue; closed_by manual|archive) - commands: close_task (ручной, только failed, идемпотентен), archive_stale_failed(max_age_hours=24, now=None) — демон-архиватор - scheduler._delivery_and_orphans_loop: каждый проход (15с) архивирует failed>24ч - requeue принимает closed → ready (доработка из архива) - cli: simbiot close <id> [-r reason] - dashboard: порядок ready→running→failed→done→архив, счётчик «архив» в статусбаре, схема закрытых фейлов под одной строкой-счётчиком (тумблер показывает полностью), pill.closed - тест tests/test_close_stale_failed.py: 15 проверок (закрытие, порог через симуляцию +25ч, done не трогается, идемпотентность, полный цикл closed→ready→failed→closed)
621 lines
No EOL
36 KiB
HTML
621 lines
No EOL
36 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.closed{background:#efefe9;color:#8a867c;border-color:#d8d5cb}
|
||
.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)}
|
||
|
||
/* ── вкладки + чат (t-261001100740) ── */
|
||
.tabs{display:flex;gap:6px;margin:26px 0 22px}
|
||
.tab{font-family:ui-monospace,Menlo,monospace;font-size:12px;letter-spacing:.12em;text-transform:uppercase;font-weight:700;
|
||
border:1px solid var(--ink);background:transparent;color:var(--dim);padding:9px 18px;cursor:pointer;border-radius:8px 8px 0 0}
|
||
.tab.active{background:var(--ink);color:var(--paper)}
|
||
.view{display:none}.view.active{display:block}
|
||
.chat{display:flex;flex-direction:column;height:calc(100vh - 320px);min-height:420px;border:1px solid var(--ink);background:#fffdf8}
|
||
.chat-log{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px}
|
||
.chat-row{display:flex;max-width:86%}
|
||
.chat-row.agent{align-self:flex-start}
|
||
.chat-row.user{align-self:flex-end;flex-direction:row-reverse}
|
||
.bubble{border:1px solid var(--line);background:var(--card);padding:9px 12px;font-size:14px;line-height:1.5;white-space:pre-wrap;word-wrap:break-word;max-width:100%}
|
||
.chat-row.agent .bubble{background:#fff;border-left:3px solid var(--signal)}
|
||
.chat-row.user .bubble{background:var(--ink);color:var(--paper);border:0}
|
||
.bubble .who{display:block;font-family:ui-monospace,Menlo,monospace;font-size:10px;letter-spacing:.1em;text-transform:uppercase;opacity:.75;margin-bottom:3px}
|
||
.bubble .pend{color:var(--warn);font-size:11px;font-style:italic}
|
||
.chat-input{display:flex;gap:8px;border-top:1px solid var(--ink);padding:10px;background:var(--card)}
|
||
.chat-input textarea{flex:1;resize:none;font:14px/1.5 -apple-system,"Segoe UI",Roboto,Arial,sans-serif;padding:9px 11px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--ink);outline:none;max-height:140px}
|
||
.chat-input textarea:focus{border-color:var(--signal)}
|
||
.chat-send{border:1px solid var(--ink);background:var(--signal);color:#fff;font-weight:700;font-family:ui-monospace,Menlo,monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;padding:0 20px;cursor:pointer;border-radius:6px}
|
||
.chat-send:disabled{opacity:.45;cursor:default}
|
||
.chat-note{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--dim);margin:8px 2px 0}
|
||
/* ── детали агента (t-261002175316-eb24cc) ── */
|
||
.back{font-family:ui-monospace,Menlo,monospace;font-size:12px;letter-spacing:.1em;text-transform:uppercase;
|
||
border:1px solid var(--ink);background:transparent;color:var(--dim);padding:8px 14px;cursor:pointer;border-radius:6px}
|
||
.back:hover{background:var(--ink);color:var(--paper)}
|
||
.agenthead{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin:6px 0 4px}
|
||
.agenthead h2{margin:0;border:0;padding:0}
|
||
tr.agentrow{cursor:pointer}
|
||
tr.agentrow:hover td{background:#fff3ea}
|
||
.bars{display:flex;align-items:flex-end;gap:3px;height:70px;border:1px solid var(--line);background:var(--card);padding:10px 10px 4px}
|
||
.bars div{flex:1;background:var(--signal);opacity:.8;min-width:2px;border-radius:2px 2px 0 0}
|
||
.bars-x{display:flex;gap:3px;font-family:ui-monospace,Menlo,monospace;font-size:9.5px;color:var(--dim);padding:2px 10px 0}
|
||
.bars-x span{flex:1;text-align:left;overflow:visible;white-space:nowrap}
|
||
.tickgrid{display:flex;flex-wrap:wrap;gap:5px;font-family:ui-monospace,Menlo,monospace;font-size:10.5px}
|
||
.tickgrid span{border:1px solid var(--line);padding:2px 6px;border-radius:4px;color:var(--dim);background:var(--card)}
|
||
.erritem{border:1px solid var(--line);border-left:3px solid var(--err);background:#fdf6f4;padding:8px 12px;margin:6px 0;font-size:12.5px}
|
||
.erritem .h{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--dim);margin-bottom:3px}
|
||
.erritem pre{white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;margin:0}
|
||
</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>
|
||
|
||
<!-- вкладки (t-261001100740): Дашборд | Чат — одна страница, состояние сохраняем -->
|
||
<div class="tabs" role="tablist">
|
||
<button class="tab active" id="tab-dash" role="tab" aria-selected="true" onclick="setView('dash')">✓ Дашборд</button>
|
||
<button class="tab" id="tab-chat" role="tab" aria-selected="false" onclick="setView('chat')">💬 Чат</button>
|
||
<button class="tab" id="tab-agent" role="tab" aria-selected="false" onclick="setView('agent')">🔍 Агент</button>
|
||
</div>
|
||
|
||
<div class="errbox" id="errbox"></div>
|
||
|
||
<div class="view active" id="view-dash">
|
||
<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 → архив · failed старше 24ч автоматически уезжают в архив (closed) · <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>
|
||
</div><!-- /view-dash -->
|
||
|
||
<div class="view" id="view-chat">
|
||
<h2 style="margin-top:0">Чат с Simbiot</h2>
|
||
<div class="lead">сообщение уходит агенту по порталу-каналу, ответ придёт сам (обычно ≤ 1 мин) · тоже дублируется в TG, если бот подключён</div>
|
||
<div class="chat">
|
||
<div class="chat-log" id="chat-log"><div class="skel">загружаю…</div></div>
|
||
<div class="chat-input">
|
||
<textarea id="chat-text" rows="1" placeholder="Сообщение агенту… (Enter — отправить, Shift+Enter — перенос)"></textarea>
|
||
<button class="chat-send" id="chat-send" onclick="chatSend()">Отправить</button>
|
||
</div>
|
||
</div>
|
||
<div class="chat-note" id="chat-note">канал: портал (portal:<agent>) · ответ агент пишет сам по такту</div>
|
||
</div><!-- /view-chat -->
|
||
|
||
<div class="view" id="view-agent">
|
||
<button class="back" onclick="setView('dash')">← обратно к дашборду</button>
|
||
<div class="agenthead">
|
||
<h2 id="ag-title">агент</h2>
|
||
<span id="ag-status"></span>
|
||
<span class="m dim" id="ag-updated"></span>
|
||
</div>
|
||
<div class="stats" id="ag-stats"></div>
|
||
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin-top:14px">
|
||
<div>
|
||
<div class="lead" style="margin-left:0">тики уровня по часам суток (24ч, UTC)</div>
|
||
<div class="bars" id="ag-hours"></div>
|
||
<div class="bars-x"><span>00</span><span style="text-align:left">06</span><span>12</span><span style="text-align:left">18</span><span style="text-align:right">23</span></div>
|
||
</div>
|
||
<div>
|
||
<div class="lead" style="margin-left:0">последние LLM-вызовы (prompt+completion токены)</div>
|
||
<div class="bars" id="ag-spend"></div>
|
||
<div class="bars-x"><span>-30</span><span style="text-align:left">-20</span><span style="text-align:left">-10</span><span style="text-align:right">последний</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<h2>Задачи уровня <span class="m dim" id="ag-taskcount"></span></h2>
|
||
<div class="lead">все задачи, когда-либо взятые этим уровнем · последние 30</div>
|
||
<table id="ag-tasks"><tr><td class="skel">загружаю…</td></tr></table>
|
||
|
||
<h2>Свежие дайджесты уровня</h2>
|
||
<div class="lead">выжимки тиков только этого агента — последние 10</div>
|
||
<div id="ag-digests"><div class="skel">загружаю…</div></div>
|
||
|
||
<h2>Открытые эскалации уровня <span class="m dim" id="ag-esccount"></span></h2>
|
||
<div id="ag-esc"><div class="skel">загружаю…</div></div>
|
||
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;margin-top:26px">
|
||
<div>
|
||
<h2 style="margin-top:0">Последние ошибки <span class="m dim" id="ag-errcount"></span></h2>
|
||
<div id="ag-errors"></div>
|
||
</div>
|
||
<div>
|
||
<h2 style="margin-top:0">Лента тиков <span class="m dim" id="ag-tickcount"></span></h2>
|
||
<div class="tickgrid" id="ag-ticks"></div>
|
||
</div>
|
||
</div>
|
||
</div><!-- /view-agent -->
|
||
|
||
<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 раскрытых строк (переживает автообновление)
|
||
// t-261003091019-7ab074 + авто-схлопывание (просьба Alex, e-261003094432):
|
||
// failed старше 24ч демон-архиватор переводит в closed — «архив» внизу доски
|
||
// (после done). Пока не закрыты (≤ проход демона, 15с), живут на rank 3.45.
|
||
// Схлопывание одной строкой-счётчиком; тумблер oldFailedExpanded честно
|
||
// показывает всё скрытое (openTasks переживает автообновление).
|
||
let oldFailedExpanded = false;
|
||
function toggleCollapse(){ oldFailedExpanded = !oldFailedExpanded; if (typeof tick === "function" && document.getElementById("view-dash")?.classList.contains("active")) tick(); }
|
||
function failedAgeH(t){
|
||
const ts = (t.status === "failed" && t.finished) ? new Date(t.finished) : null;
|
||
return (ts && !isNaN(ts)) ? (Date.now() - ts.getTime()) / 36e5 : 0;
|
||
}
|
||
function taskRank(t){
|
||
if(t.status === "failed" && failedAgeH(t) > 24) return 3.45; // до прохода архиватора: после done(3), до closed(4)
|
||
const o = {ready:0, running:1, failed:2, done:3, closed:4};
|
||
return o[t.status] ?? 9;
|
||
}
|
||
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">${st.closed || 0}</div><div class="l">архив</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 class="agentrow" title="открыть детали агента ${esc(l.level)}" onclick="openAgent('${esc(l.level)}')">
|
||
<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})`;
|
||
const s0 = [...s.tasks];
|
||
// порядок: ready → running → failed → done → архив (closed).
|
||
// t-261003091019-7ab074: failed старше 24ч до прохода архиватора живут
|
||
// на rank 3.45 (сразу под done); архиватор переводит их в closed.
|
||
s0.sort((a, b) =>
|
||
(b.created || "").localeCompare(a.created || "") || taskRank(a) - taskRank(b));
|
||
s0.sort((a, b) => taskRank(a) - taskRank(b));
|
||
// авто-схлопывание (просьба Alex, e-261003094432): архив = closed
|
||
// (+ переходные failed>24ч до ближайшего прохода архиватора, ≤15с)
|
||
// прячется под одной строкой-счётчиком в самом низу доски.
|
||
const oldFailed = s0.filter(t => t.status === "closed"
|
||
|| (t.status === "failed" && failedAgeH(t) > 24));
|
||
const freshTasks = oldFailed.length ? s0.filter(t => !oldFailed.includes(t)) : s0;
|
||
const collapseRow = (oldFailed.length && !oldFailedExpanded) ? `<tr class="task" id="tr-old-failed" onclick="toggleCollapse()">
|
||
<td class="arrow">▶</td>
|
||
<td class="m dim">архив</td>
|
||
<td><b>${oldFailed.length} failed/closed старше 24ч</b> — показать</td>
|
||
<td></td><td></td><td></td><td></td>
|
||
</tr>` : "";
|
||
const shownTasks = oldFailedExpanded ? s0 : [...freshTasks];
|
||
const errPreview = t => (t.error && t.status !== "closed" && !(t.status === "failed" && failedAgeH(t) > 24))
|
||
? `<br><span class="m" style="color:var(--err)">${esc(String(t.error).slice(0,140))}</span>` : "";
|
||
$("tasks").innerHTML = s0.length ? `<table>
|
||
<tr><th></th><th>id</th><th>задача</th><th>размер</th><th>статус</th><th>уровень</th><th>создана</th></tr>
|
||
${shownTasks.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>${errPreview(t)}</td>
|
||
<td>${size(t.size)}</td>
|
||
<td>${pill(t.status)}${t.status === "failed" && failedAgeH(t) > 24 ? `<span class="m dim">· ${Math.floor(failedAgeH(t))}ч</span>` : ""}${workingTag(t)}</td>
|
||
<td class="m">${esc(t.level || "—")}</td>
|
||
<td class="m dim">${fmtTime(t.created)}</td>
|
||
</tr>
|
||
${taskDetail(t)}`).join("")}
|
||
${collapseRow}</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);
|
||
|
||
// ── вкладки + чат (t-261001100740) ──────────────────────────────────────
|
||
function setView(v){
|
||
document.querySelectorAll(".tab").forEach(t => {
|
||
const on = t.id === "tab-" + v;
|
||
t.classList.toggle("active", on);
|
||
t.setAttribute("aria-selected", on ? "true" : "false");
|
||
});
|
||
document.querySelectorAll(".view").forEach(s => s.classList.toggle("active", s.id === "view-" + v));
|
||
try { localStorage.simbiotView = v; } catch(e){}
|
||
if (v === "chat") { chatTick(); }
|
||
if (v === "agent") { if (!agentLevel) agentLevel = "min"; startAgentMonitor(agentLevel); }
|
||
}
|
||
try {
|
||
// deep-link: ?view=chat|dash|agent&level=min (проверяемый вход «открыть чат по ссылке», приоритет над localStorage)
|
||
const _qp = new URLSearchParams(location.search);
|
||
const q = _qp.get("view");
|
||
const ql = q === "chat" ? "chat" : (q === "dash" ? "dash" : (q === "agent" ? "agent" : null));
|
||
const v0 = ql || localStorage.simbiotView;
|
||
if (v0 === "agent") {
|
||
const hl = _qp.get("level") || ((location.hash.match(/^#agent=([a-z_]+)$/) || [])[1]);
|
||
if (hl) agentLevel = hl;
|
||
setView("agent");
|
||
} else if (v0 === "chat") setView("chat");
|
||
} catch(e){}
|
||
//.hash = #agent=<level> — клик по уровню в лестнице или прямая ссылка
|
||
window.addEventListener("hashchange", () => {
|
||
const m = (decodeURIComponent(location.hash).match(/^#agent=([a-z_]+)$/) || [])[1];
|
||
if (m && getViewName() === "agent") { startAgentMonitor(m); }
|
||
});
|
||
|
||
const CHAT_REFRESH = 10; // сек: канал тикает по сек-рефлексу, чаще нет смысла
|
||
let chatTimer = null;
|
||
let chatOpen = false;
|
||
|
||
function chatBubble(m){
|
||
const who = m.from === "agent" ? "Simbiot" : "вы";
|
||
const pend = m.pending ? ` <span class="pend">· ждёт ответа…</span>` : "";
|
||
const rep = m.repeat > 1 ? ` ×${m.repeat}` : "";
|
||
let t = "";
|
||
try { const d = new Date(Number(m.ts)); if (!isNaN(d)) t = d.toISOString().slice(11,16) + " UTC"; } catch(e){}
|
||
return `<div class="chat-row ${m.from === "agent" ? "agent" : "user"}">` +
|
||
`<div class="bubble"><span class="who">${esc(who)}${t ? " · " + esc(t) : ""}${pend}${rep}</span>${esc(m.text || "")}</div></div>`;
|
||
}
|
||
|
||
async function chatTick(){
|
||
if (getViewName() !== "chat") return;
|
||
chatOpen = true;
|
||
const log = $("chat-log");
|
||
try{
|
||
const r = await fetch("api/chat?t=" + Date.now(), {cache:"no-store"});
|
||
if(!r.ok) throw new Error("HTTP " + r.status);
|
||
const s = await r.json();
|
||
if(s.ok === false) throw new Error(s.error || "chat unavailable");
|
||
const html = (s.messages || []).map(chatBubble).join("");
|
||
const stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 40; // прилипание к низу
|
||
log.innerHTML = html || `<div class="skel">сообщений пока нет — напиши первым</div>`;
|
||
if (stick) log.scrollTop = log.scrollHeight;
|
||
$("chat-note").textContent = "канал: портал (portal:" + (s.agent || "?") + ") · ответ агент пишет сам по такту";
|
||
}catch(e){
|
||
if (e instanceof SyntaxError) return; // полу-JSON от 503 — следующий тик перепишет
|
||
log.innerHTML = `<div class="skel">чат недоступен: ${esc(e.message)} — попробую через ${CHAT_REFRESH}с</div>`;
|
||
}
|
||
}
|
||
|
||
function getViewName(){
|
||
return document.getElementById("view-dash").classList.contains("active") ? "dash" : "chat";
|
||
}
|
||
|
||
async function chatSend(){
|
||
const ta = $("chat-text");
|
||
const text = (ta.value || "").trim();
|
||
if(!text) return;
|
||
const btn = $("chat-send");
|
||
btn.disabled = true;
|
||
try{
|
||
const r = await fetch("api/chat/send", {
|
||
method: "POST",
|
||
headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify({text})
|
||
});
|
||
if(!r.ok) throw new Error("HTTP " + r.status);
|
||
const s = await r.json();
|
||
if(s.ok === false) throw new Error(s.error || "send failed");
|
||
ta.value = "";
|
||
await chatTick();
|
||
}catch(e){
|
||
$("chat-note").textContent = "⚠ отправка не прошла: " + e.message + " — сообщение не потеряно, попробую снова на следующем тике";
|
||
}finally{
|
||
btn.disabled = false;
|
||
ta.focus();
|
||
}
|
||
}
|
||
|
||
// textarea: Enter = отправить, Shift+Enter = перенос строки; авто-рост
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const ta = $("chat-text");
|
||
ta.addEventListener("keydown", e => {
|
||
if(e.key === "Enter" && !e.shiftKey){ e.preventDefault(); chatSend(); }
|
||
});
|
||
ta.addEventListener("input", () => {
|
||
ta.style.height = "auto";
|
||
ta.style.height = Math.min(ta.scrollHeight, 140) + "px";
|
||
});
|
||
});
|
||
chatTimer = setInterval(chatTick, CHAT_REFRESH * 1000);
|
||
|
||
// ── детали агента (t-261002175316-eb24cc) ───────────────────────────────
|
||
var agentLevel = null;
|
||
var agentTimer = null;
|
||
|
||
function barChart(el, data, fmt){
|
||
const max = Math.max(1, ...data.map(d => d.v));
|
||
el.innerHTML = data.map(d =>
|
||
`<div style="height:${Math.max(2, Math.round(d.v / max * 100))}%" title="${esc(d.t)}"></div>`).join("");
|
||
}
|
||
|
||
function fmtDur(sec){
|
||
if (sec == null) return "—";
|
||
sec = Math.round(sec);
|
||
if (sec < 60) return sec + "с";
|
||
if (sec < 3600) return Math.round(sec / 60) + "м";
|
||
return (sec / 3600).toFixed(1) + "ч";
|
||
}
|
||
|
||
function agentTick(){
|
||
if (!agentLevel) return;
|
||
fetch("api/agent?level=" + encodeURIComponent(agentLevel) + "&t=" + Date.now(), {cache: "no-store"})
|
||
.then(r => { if(!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||
.then(d => {
|
||
if (d.error) throw new Error(d.error);
|
||
$("errbox").style.display = "none";
|
||
const a = d.agent;
|
||
$("ag-title").textContent = "агент · " + a.level;
|
||
$("ag-status").innerHTML = a.paused ? '<span class="pill failed">paused</span>'
|
||
: a.busy ? '<span class="pill running">busy</span>'
|
||
: '<span class="pill ready">ok</span>' +
|
||
(a.current_tasks.length ? ` <span class="pill working"> 🔧 ${esc(a.current_tasks[0].title.slice(0,34))} · ${esc(fmtDur(a.current_tasks[0].duration_sec))}</span>` : "");
|
||
$("ag-updated").textContent = (d.generated_at || "").slice(11, 19) + " UTC";
|
||
$("ag-stats").innerHTML = [
|
||
[a.ticks_24h, "тиков 24ч"], [a.absorbed_24h, "поглощено 24ч"],
|
||
[a.errors_24h, "ошибок 24ч"], [fmtDur(a.time_in_work_24h_sec), "в работе 24ч"],
|
||
[a.ticks_7d, "тиков 7д"], [a.errors_7d, "ошибок 7д"],
|
||
[a.llm_24h.calls, "LLM-вызовы 24ч"], [(a.llm_24h.prompt/1e6).toFixed(1)+"M", "prompt 24ч"],
|
||
[(a.llm_24h.completion/1e3).toFixed(0)+"k", "completion 24ч"],
|
||
].map(([n, l]) => `<div class="stat"><div class="n">${esc(String(n))}</div><div class="l">${esc(l)}</div></div>`).join("");
|
||
// часы: 24 ведра, ноль — прозрачным
|
||
const hm = {}; (d.hourly || []).forEach(x => hm[x.h] = x.c);
|
||
const hours = []; for (let h = 0; h < 24; h++) {
|
||
const k = String(h).padStart(2, "0"), v = hm[k] || 0;
|
||
hours.push({v, t: k + ":00 UTC — " + v + " тиков"});
|
||
}
|
||
barChart($("ag-hours"), hours);
|
||
const spends = (d.spend_rows || []).map(r =>
|
||
({v: r.prompt + r.completion, t: (r.trace_id || "?") + " · " + r.prompt + "pt + " + r.completion + "ct"}));
|
||
barChart($("ag-spend"), spends);
|
||
const tasks = d.tasks || [];
|
||
$("ag-taskcount").textContent = `(${tasks.length})`;
|
||
$("ag-tasks").innerHTML = tasks.length ? 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,110))}</span>` : ""}</td>
|
||
<td>${pill(t.status)}</td>
|
||
<td class="m">${esc(t.claimed_at ? fmtTime(t.claimed_at) : "—")}</td>
|
||
<td class="m dim">${t.finished ? esc(fmtTime(t.finished)) : "—"}</td>
|
||
<td class="m">${fmtDur(t.duration_sec)}</td>
|
||
</tr>`).join("") : `<tr><td class="skel">этот уровень задач пока не брал</td></tr>`;
|
||
$("ag-digests").innerHTML = (d.digests || []).length
|
||
? d.digests.map(x => `<div class="digest"><div class="h">${esc(x.period || "")} · ${esc(fmtTime(x.ts))} · ref ${esc(x.summary_ref || "—")}</div><p>${esc((x.text || "").slice(0, 400))}</p></div>`).join("")
|
||
: `<div class="skel">дайджестов уровня нет</div>`;
|
||
const esc5 = d.escalations_open || [];
|
||
$("ag-esccount").textContent = `(${esc5.length})`;
|
||
$("ag-esc").innerHTML = esc5.length
|
||
? `<table><tr><th>id</th><th>sev</th><th>маршрут</th><th>причина</th><th>поднята</th></tr>` +
|
||
esc5.map(e => `<tr><td class="m dim">${esc(e.id)}</td><td><span class="pill sev-${esc(e.severity || "warn")}">${esc(e.severity || "?")}</span></td>
|
||
<td class="m">${esc(e.from_level || "?")} → ${esc(e.to_level || "?")}</td><td>${esc(e.reason || "")}</td>
|
||
<td class="m dim">${esc(fmtTime(e.ts))}</td></tr>`).join("") + `</table>`
|
||
: `<div class="skel">открытых эскалаций уровня нет</div>`;
|
||
const errs = d.recent_errors || [];
|
||
$("ag-errcount").textContent = `(${errs.length})`;
|
||
$("ag-errors").innerHTML = errs.length
|
||
? errs.map(e => `<div class="erritem"><div class="h">${esc(fmtTime(e.ts))}</div><pre>${esc(e.error)}</pre></div>`).join("")
|
||
: `<div class="skel">ошибок за 7д нет</div>`;
|
||
$("ag-tickcount").textContent = `(${(d.recent_ticks || []).length})`;
|
||
$("ag-ticks").innerHTML = (d.recent_ticks || []).length
|
||
? d.recent_ticks.map(t => `<span title="${esc(t.ts)}">${esc((t.seq || "").split(":").slice(1).join(":"))}</span>`).join("")
|
||
: `<div class="skel">тиков не найдено</div>`;
|
||
})
|
||
.catch(e => {
|
||
const box = $("errbox");
|
||
box.style.display = "block";
|
||
box.textContent = "агент " + agentLevel + " недоступен: " + e.message + " — попробую снова на следующем тике";
|
||
});
|
||
}
|
||
|
||
function startAgentMonitor(level){
|
||
agentLevel = level;
|
||
if (agentTimer) { clearInterval(agentTimer); agentTimer = null; }
|
||
agentTick();
|
||
agentTimer = setInterval(agentTick, REFRESH * 1000);
|
||
}
|
||
|
||
function openAgent(level){
|
||
try { location.hash = "agent=" + encodeURIComponent(level); } catch(e){}
|
||
startAgentMonitor(level);
|
||
setView("agent");
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |