simbiot-harness/harness/dashboard.html

666 lines
No EOL
39 KiB
HTML
Raw Permalink 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.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-261004103718-d8112b) */
.chan{display:inline-flex;align-items:center;gap:6px;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--ink);margin-right:16px;white-space:nowrap}
.chan .dot{width:8px;height:8px}
.chan small{color:var(--dim);font-size:11px}
/* ── детали агента (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 · такт &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>
<!-- вкладки (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 мин) · каналы: <span id="chan-status"><span class="chan"><span class="dot wait"></span>каналы: узнаю…</span></span></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:&lt;agent&gt;) · ответ агент пишет сам по такту · <span id="chan-note">статус каналов: узнаю…</span></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 => ({"&":"&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 раскрытых строк (переживает автообновление)
// 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>`;
}
// ── живой статус каналов (t-261004103718-d8112b) ──────────────────────────
// ok → зелёный + возраст последнего успеха; нет свежего штампа → красный.
// hermes-лейбл — только если бэкенд подтвердил маркер старого Гермес-харнеса.
function chanAge(s){
if (s.age_s == null) return "";
if (s.age_s < 60) return s.age_s + "с назад";
if (s.age_s < 3600) return Math.round(s.age_s / 60) + "м назад";
return Math.round(s.age_s / 3600) + "ч назад";
}
function chanChip(name, label, s){
s = s || {};
const cls = s.ok ? "" : "err";
const detail = s.ok ? (chanAge(s) || "успех") : (s.detail || "нет связи");
return `<span class="chan"><span class="dot ${cls}"></span>${label}` +
`<small>${esc(detail)}</small></span>`;
}
function renderChannels(ch){
const box = $("chan-status");
if (!box) return;
if (!ch){ return; } // бэкенд без статусов — оставляем «узнаю…» до след. тика
let html = "";
if (ch.hermes) html += `<span class="chan"><small>⚠ старый харнес (Hermes): данные могут быть устаревшими</small></span>`;
html += chanChip("portal", "портал", ch.portal);
const tg = ch.telegram || {};
if (tg.token_loaded === false){
html += `<span class="chan"><span class="dot err"></span>telegram<small>бот не подключён</small></span>`;
} else {
html += chanChip("tg", "telegram", tg);
}
box.innerHTML = html;
// краткая сводка там же, где раньше жил статичный «тоже дублируется в TG…»
const note = $("chan-note");
if (note){
const p = ch.portal || {}, t = ch.telegram || {};
const fmt = s => s.ok ? `<span style="color:var(--ok)">ok</span> (${esc(chanAge(s) || "сейчас")})`
: `<span style="color:var(--err)">down</span>`;
note.innerHTML = "статус: портал " + fmt(p) + " · telegram " + fmt(t);
}
}
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 || "?") + ") · ответ агент пишет сам по такту";
renderChannels(s.channels);
}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>