feat(dashboard): веб-дашборд задач харнеса — все задачи/статусы/размеры/уровни, лестница, эскалации, дайджесты; автообновление 5с (t-260929164722-60dae2)
- harness/dashboard.py: stdlib-only HTTP-сервер, /api/state (read-only из event store через run_projections — та же проекция, что у scheduler/CLI), /api/health на отдельном порту 8766 (без auth — для systemd healthcheck) - harness/dashboard.html: страница в стиле architecture.html (paper/ink/signal), fetch каждые 5с, честный errbox при недоступности API, XSS-экранирование всех полей - auth: SIMBIOT_DASH_TOKEN из /etc/simbiot/dashboard.env (env-файл юнита, 600); без/с неверным токеном — 401; токен в query → cookie - systemd-юнит simbiot-dashboard.service (после scheduler, restart=always), порт 8765/tcp в UFW
This commit is contained in:
parent
43e3c3b63b
commit
c68335e7fb
3 changed files with 449 additions and 0 deletions
18
README.md
18
README.md
|
|
@ -74,6 +74,24 @@ journalctl -u simbiot-harness -f
|
|||
отвечает `cli.py tg-reply` (Telegram-отправка + `ReplySent` закрывает входящее).
|
||||
Белый список чатов в `.env` (`SIMBIOT_TG_ALLOWED`), чужие → `MessageIgnored`.
|
||||
|
||||
## Дашборд задач
|
||||
|
||||
Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов +
|
||||
эскалации + свежие дайджесты, автообновление каждые 5с (JSON-API `/api/state`).
|
||||
|
||||
```bash
|
||||
systemctl status simbiot-dashboard # порт 8765, healthcheck 8766
|
||||
TOKEN=$(grep -oP '(?<=SIMBIOT_DASH_TOKEN=).*' /etc/simbiot/dashboard.env)
|
||||
xdg-open "http://<host>:8765/?token=$TOKEN" # токен пишется в cookie
|
||||
curl "http://127.0.0.1:8765/api/state?token=$TOKEN" | jq . # машиночитаемо
|
||||
```
|
||||
|
||||
Реализация: `harness/dashboard.py` (stdlib-only http.server, read-only из
|
||||
event store через `run_projections()` — консистентно с scheduler/CLI) +
|
||||
`harness/dashboard.html`. Авторизация: `SIMBIOT_DASH_TOKEN` из
|
||||
`/etc/simbiot/dashboard.env` (env-файл юнита, режим 600); без токена — 401.
|
||||
UFW: порт 8765/tcp открыт (токен-защищён).
|
||||
|
||||
## Модели
|
||||
|
||||
9router (router.cil.by), отдельный ключ `sk-simbiot` в `.env`, комбо `auto`:
|
||||
|
|
|
|||
211
harness/dashboard.html
Normal file
211
harness/dashboard.html
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<!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}
|
||||
.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)}
|
||||
.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, внутри — новые сверху</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);
|
||||
|
||||
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>`; }
|
||||
|
||||
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>`;
|
||||
|
||||
// задачи
|
||||
$("taskcount").textContent = `(${s.tasks.length})`;
|
||||
$("tasks").innerHTML = s.tasks.length ? `<table>
|
||||
<tr><th>id</th><th>задача</th><th>размер</th><th>статус</th><th>уровень</th><th>создана</th></tr>
|
||||
${s.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,140))}</span>` : ""}</td>
|
||||
<td>${size(t.size)}</td>
|
||||
<td>${pill(t.status)}</td>
|
||||
<td class="m">${esc(t.level || "—")}</td>
|
||||
<td class="m dim">${fmtTime(t.created)}</td>
|
||||
</tr>`).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>
|
||||
220
harness/dashboard.py
Normal file
220
harness/dashboard.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""Simbiot Harness — дашборд задач: веб-страница + JSON-API.
|
||||
|
||||
Задача t-260929164722-60dae2 [M]: страница со всеми задачами харнеса
|
||||
(статусы/размеры/уровни), автообновление. Плюс лестница уровней,
|
||||
эскалации и свежие дайджесты — состояние харнеса одним взглядом.
|
||||
|
||||
Дизайн: stdlib-only (http.server + ThreadingHTTPServer), никаких
|
||||
зависимостей и лишних процессов. Читает событие-стор напрямую через
|
||||
run_projections() (та же проекция, что у scheduler/CLI — консистентно).
|
||||
|
||||
Порты: 8765 (dashboard) / 8766 (healthcheck, без авторизации),
|
||||
SIMBIOT_DASH_PORT/SIMBIOT_DASH_ADDR/SIMBIOT_DASH_TOKEN — из env.
|
||||
Открытый мир — токен SIMBIOT_DASH_TOKEN (query ?token= или cookie):
|
||||
страница и API без токена отдают 401 (кроме порта 8766 — там только
|
||||
"ok", без данных; healthcheck для systemd не должен падать на auth).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http import cookies
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from harness import commands, es # noqa: E402
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_INDEX = os.path.join(_HERE, "dashboard.html")
|
||||
|
||||
_ADDR = os.environ.get("SIMBIOT_DASH_ADDR", "0.0.0.0")
|
||||
_PORT = int(os.environ.get("SIMBIOT_DASH_PORT", "8765"))
|
||||
_HEALTH_PORT = int(os.environ.get("SIMBIOT_DASH_HEALTH_PORT", "8766"))
|
||||
_TOKEN = os.environ.get("SIMBIOT_DASH_TOKEN", "")
|
||||
_REFRESH = int(os.environ.get("SIMBIOT_DASH_REFRESH", "5"))
|
||||
|
||||
_STATUS_ORDER = {"ready": 0, "running": 1, "failed": 2, "done": 3}
|
||||
_SIZE_COLOR = {"S": "#1d7a3e", "M": "#a3690f", "L": "#FF4F00", "XL": "#b3261e"}
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())
|
||||
|
||||
|
||||
def build_state() -> dict:
|
||||
"""Срез харнеса для API: задачи, лестница, эскалации, дайджесты, спенд.
|
||||
|
||||
Полный run_projections() на каждый запрос — безопасно: инкрементальный
|
||||
чекпоинт в БД, работа пропорциональна дельте событий, не истории.
|
||||
"""
|
||||
model = es.run_projections()
|
||||
|
||||
tasks = []
|
||||
for t in model.get("tasks", {}).values():
|
||||
tasks.append({
|
||||
"id": t.get("id"), "title": t.get("title", ""),
|
||||
"size": t.get("size", "?"), "status": t.get("status", "?"),
|
||||
"level": t.get("level"), "created": t.get("created"),
|
||||
"finished": t.get("finished"), "error": t.get("error"),
|
||||
})
|
||||
# два стабильных прохода: новые сверху внутри статуса,
|
||||
# статусы по порядку ready→running→failed→done
|
||||
tasks.sort(key=lambda x: x.get("created") or "", reverse=True)
|
||||
tasks.sort(key=lambda x: _STATUS_ORDER.get(x["status"], 9))
|
||||
|
||||
ladder = []
|
||||
for name, cs in sorted(model.get("cycle_state", {}).items()):
|
||||
ladder.append({
|
||||
"level": name, "busy": bool(cs.get("busy")),
|
||||
"paused": bool(cs.get("paused")),
|
||||
"ticks": cs.get("ticks", 0), "absorbed": cs.get("absorbed", 0),
|
||||
"errors": cs.get("errors", 0), "last_tick": cs.get("last_tick"),
|
||||
"last_error": cs.get("last_error"),
|
||||
})
|
||||
|
||||
escalations = [e for e in (model.get("escalations") or {}).values()
|
||||
if e.get("status") == "open"]
|
||||
escalations.sort(key=lambda e: e.get("ts", ""), reverse=True)
|
||||
|
||||
digests = []
|
||||
for d in model.get("digests", {}).values():
|
||||
digests.append({
|
||||
"level": d.get("level"), "period": d.get("period"),
|
||||
"text": d.get("text", ""), "ts": d.get("ts"),
|
||||
"summary_ref": d.get("summary_ref"),
|
||||
})
|
||||
digests.sort(key=lambda d: d.get("ts") or "", reverse=True)
|
||||
|
||||
spend = model.get("spend", [])[-12:]
|
||||
|
||||
counts = {}
|
||||
for t in tasks:
|
||||
key = f"{t['status']}:{t['size']}"
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
|
||||
return {
|
||||
"generated_at": _utcnow(),
|
||||
"counts": {
|
||||
"tasks_total": len(tasks),
|
||||
"tasks_by_status": {
|
||||
s: sum(1 for t in tasks if t["status"] == s)
|
||||
for s in ("ready", "running", "failed", "done")
|
||||
},
|
||||
"tasks_by_status_size": counts,
|
||||
"escalations_open": len(escalations),
|
||||
"events_total": es.count_events(),
|
||||
"digests_total": len(model.get("digests", {})),
|
||||
},
|
||||
"ladder": ladder,
|
||||
"tasks": tasks,
|
||||
"escalations": escalations,
|
||||
"digests": digests[:12],
|
||||
"spend": spend,
|
||||
"meta": {"refresh_seconds": _REFRESH, "token_required": bool(_TOKEN)},
|
||||
}
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
server_version = "SimbiotDash/1.0"
|
||||
|
||||
def log_message(self, fmt, *args): # тише в journalctl: одна строка
|
||||
print(f"[dash] {self.address_string()} {fmt % args}", flush=True)
|
||||
|
||||
# ── auth ──
|
||||
def _authorized(self) -> bool:
|
||||
if not _TOKEN:
|
||||
return True
|
||||
supplied = None
|
||||
qs = self.path.partition("?")[2]
|
||||
from urllib.parse import parse_qs
|
||||
for v in parse_qs(qs).get("token", []):
|
||||
supplied = v
|
||||
if supplied is None:
|
||||
c = cookies.SimpleCookie(self.headers.get("Cookie", ""))
|
||||
if "simbiot_token" in c:
|
||||
supplied = c["simbiot_token"].value
|
||||
return supplied == _TOKEN
|
||||
|
||||
def _forbidden(self):
|
||||
body = b'{"error": "unauthorized: pass ?token=SIMBIOT_DASH_TOKEN"}\n'
|
||||
self.send_response(401)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("WWW-Authenticate", 'Bearer realm="simbiot-dash"')
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _json(self, obj, code=200):
|
||||
body = json.dumps(obj, ensure_ascii=False, default=str).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _html(self, text: str):
|
||||
body = text.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
# ── routes ──
|
||||
def do_GET(self):
|
||||
route = self.path.partition("?")[0]
|
||||
if route == "/api/state":
|
||||
if not self._authorized():
|
||||
return self._forbidden()
|
||||
try:
|
||||
return self._json(build_state())
|
||||
except Exception as e: # стор бит/занят — 503, фронт покажет retry
|
||||
return self._json({"error": str(e)[:500]}, code=503)
|
||||
if route == "/api/health":
|
||||
return self._json({"ok": True, "ts": _utcnow()})
|
||||
if route in ("/", "/index.html"):
|
||||
if not self._authorized():
|
||||
return self._forbidden()
|
||||
try:
|
||||
with open(_INDEX, encoding="utf-8") as f:
|
||||
return self._html(f.read())
|
||||
except OSError as e:
|
||||
return self._json({"error": f"dashboard.html not found: {e}"}, code=500)
|
||||
return self._json({"error": "not found"}, code=404)
|
||||
|
||||
|
||||
def _health_server():
|
||||
"""Отдельный мини-порт для healthcheck: без auth, только {ok:true}."""
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
body = b'{"ok":true}'
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", _HEALTH_PORT), H)
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
def main():
|
||||
es.init_db() # идемпотентно
|
||||
if not os.path.exists(_INDEX):
|
||||
print(f"[dash] FATAL: {_INDEX} не найден", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
threading.Thread(target=_health_server, daemon=True).start()
|
||||
print(f"[dash] http://{_ADDR}:{_PORT}/ (health 127.0.0.1:{_HEALTH_PORT}/api/health) "
|
||||
f"refresh={_REFRESH}s token={'on' if _TOKEN else 'off'}", flush=True)
|
||||
ThreadingHTTPServer((_ADDR, _PORT), _Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue