From c68335e7fb9dd38f8f9bf75cf6902f9245dad39b Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Tue, 29 Sep 2026 18:04:38 +0000 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=D0=B2=D0=B5=D0=B1-=D0=B4?= =?UTF-8?q?=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=20=D1=85=D0=B0=D1=80=D0=BD=D0=B5=D1=81=D0=B0=20=E2=80=94?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8/?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B/=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D1=8B/=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B8,?= =?UTF-8?q?=20=D0=BB=D0=B5=D1=81=D1=82=D0=BD=D0=B8=D1=86=D0=B0,=20=D1=8D?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D0=BB=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=B9=D0=B4=D0=B6=D0=B5=D1=81=D1=82=D1=8B;=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=205=D1=81=20(t-260929164722-60dae2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 18 ++++ harness/dashboard.html | 211 +++++++++++++++++++++++++++++++++++++++ harness/dashboard.py | 220 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 harness/dashboard.html create mode 100644 harness/dashboard.py diff --git a/README.md b/README.md index 5e1b597..4d7873e 100644 --- a/README.md +++ b/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://: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`: diff --git a/harness/dashboard.html b/harness/dashboard.html new file mode 100644 index 0000000..5efa290 --- /dev/null +++ b/harness/dashboard.html @@ -0,0 +1,211 @@ + + + + + +Simbiot Harness — дашборд задач + + + +
+
+
Simbiot Harness · такт < час
+

Дашборд задач

+
Все задачи харнеса: статусы, размеры, уровни. Лестница циклов, эскалации, свежие дайджесты. Автообновление.
+
+ соединяюсь… + обновлено: — UTC + событий в сторе: — + обновление: 5с +
+
+ +
+ +
+ +

Лестница уровней

+
сек → мин → час → день · занятый уровень поглощает такты
+
загружаю…
+ +

Задачи

+
сортировка: ready → running → failed → done, внутри — новые сверху
+
загружаю…
+ +

Открытые эскалации

+
загружаю…
+ +

Свежие дайджесты

+
выжимки тиков всех уровней — последние 12
+
загружаю…
+ +
+ JSON-API: /api/state · healthcheck: /api/health · + источник — event store (CQRS/ES, проекция на лету) · + сервис simbiot-dashboard.service, порт 8765 +
+
+ + + + \ No newline at end of file diff --git a/harness/dashboard.py b/harness/dashboard.py new file mode 100644 index 0000000..e338ae4 --- /dev/null +++ b/harness/dashboard.py @@ -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() \ No newline at end of file