simbiot-harness/harness/dashboard.html

406 lines
No EOL
23 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)}
/* ── вкладки + чат (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}
</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>
</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, внутри — новые сверху · <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:&lt;agent&gt;) · ответ агент пишет сам по такту</div>
</div><!-- /view-chat -->
<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);
// ── вкладки + чат (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(); } // первый рендер сразу, без ожидания таймера
}
try {
// deep-link: ?view=chat|dash (проверяемый вход «открыть чат по ссылке», приоритет над localStorage)
const q = new URLSearchParams(location.search).get("view");
const ql = q === "chat" ? "chat" : (q === "dash" ? "dash" : null);
const v0 = ql || localStorage.simbiotView;
if (v0 === "chat") setView("chat");
} catch(e){}
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);
</script>
</body>
</html>