simbiot-harness/harness/dashboard.py
Simbiot Harness c68335e7fb 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
2026-09-29 18:04:38 +00:00

220 lines
No EOL
8.8 KiB
Python
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.

"""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()