simbiot-harness/harness/dashboard.py

613 lines
No EOL
28 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"}
# ── чат Simbiot прямо в дашборде (t-261001100740-99d55e) ──────────────────
# Канал один для всех UI: БД портала agent_portal_chat (/root/coop/coop.db),
# вход — sender='user' (пишет портал/дашборд), ответ агента — sender='agent'
# (HTTP-push из сек-рефлекса portal_channel.push). Реплаи из стора харнеса
# (ReplySent) — дубль текста ответа, берём из проекции инбокса (канал «portal»,
# у master-хода там же ходим TG-реплаи — их chat_id не 'portal:*', не мешаем).
import sqlite3 as _sqlite3
_COOP_DB = "/root/coop/coop.db"
_COOP_AGENT_CACHE = {"id": None, "name": None, "ts": 0.0}
_CHAT_MAX = 8000 # ровно кап coop-портала в chat/send
def _coop_agent(ttl: float = 60.0):
"""Агент Simbiot в БД портала (id нужен для адресации agent_portal_chat)."""
now = time.time()
if _COOP_AGENT_CACHE["id"] and now - _COOP_AGENT_CACHE["ts"] < ttl:
return _COOP_AGENT_CACHE["id"], _COOP_AGENT_CACHE["name"]
try:
db = _sqlite3.connect(_COOP_DB)
db.row_factory = _sqlite3.Row
row = db.execute(
"SELECT id, agent_name FROM agents WHERE harness_type='simbiot' "
"ORDER BY (agent_name='Simbiot') DESC, id ASC LIMIT 1").fetchone()
db.close()
except Exception:
return None, None
if not row:
return None, None
_COOP_AGENT_CACHE.update({"id": int(row["id"]), "name": row["agent_name"],
"ts": now})
return _COOP_AGENT_CACHE["id"], _COOP_AGENT_CACHE["name"]
def _ts_to_ms(ts) -> int:
"""ISO-строка стора или int-ms → int-ms (единая сортировка чата)."""
if isinstance(ts, (int, float)):
return int(ts)
from datetime import datetime
try:
return int(datetime.fromisoformat(str(ts).replace("Z", "+00:00")).timestamp() * 1000)
except Exception:
return 0
def build_chat(limit: int = 80) -> dict:
"""Лента чата: БД портала (primary) + реплаи стора (полнота текста батчей).
Дедуп: вход пользователя в обоих источниках под одним ключом
('m-'+msg_id) — версию стора предпочитаем (там replied/reply_text).
"""
agent_id, agent_name = _coop_agent()
if not agent_id:
return {"ok": False, "error": "no_agent", "messages": []}
db = _sqlite3.connect(_COOP_DB)
db.row_factory = _sqlite3.Row
rows = db.execute(
"SELECT msg_id, sender, text, ts, consumed FROM agent_portal_chat "
"WHERE agent_id=? ORDER BY ts DESC LIMIT ?", (agent_id, limit)).fetchall()
db.close()
store = {}
try:
model = es.run_projections()
for m in (model.get("inbox") or {}).values():
if isinstance(m, dict) and str(m.get("chat_id") or "").startswith("portal"):
m = dict(m)
m["store_id"] = "m-" + str(m.get("update_id") or "")
m["ts_ms"] = _ts_to_ms(m.get("ts") or m.get("date"))
store[m["store_id"]] = m
except Exception:
pass # стор недоступен: БД портала всё равно покажет ленту
msgs = []
# анкеры: msg_id agent-реплаев вида "reply-<ms>-<update_id>" — покрывает
# push-строку в БД портала; реплай стора дублирует её — не показываем
anchors = set()
for r in rows:
if r["sender"] == "agent" and str(r["msg_id"]).startswith("reply-"):
anchors.add("-".join(str(r["msg_id"]).split("-", 2)[2:]))
for r in reversed(rows): # старые → новые
# служебные команды канала (/set-tg-bot с токеном, cmd-settg-*) — не
# чата: токен секрет, в ленту UI не показываем (t-261001100740)
if (r["sender"] == "user" and (str(r["msg_id"]).startswith("cmd-settg-")
or str(r["text"]).startswith("/set-tg-bot"))):
continue
if r["sender"] == "agent" and str(r["text"]).lstrip().startswith("/set-tg"):
continue
key = "m-" + str(r["msg_id"])
st = store.get(key) if r["sender"] == "user" else None
if st is not None and st.get("replied") and st.get("reply_text") \
and str(st.get("update_id") or "") not in anchors:
# реплай стора НЕ отражён в БД портала — показываем ПАРУ:
# вопрос пользователя + ответ агента из стора
msgs.append({
"id": str(r["msg_id"]), "from": "user",
"text": st.get("text") or r["text"],
"ts": int(r["ts"] or 0), "channel": "portal", "pending": False,
})
msgs.append({
"id": str(r["msg_id"]) + "#reply", "from": "agent",
"text": st.get("reply_text"),
"ts": _ts_to_ms(st.get("ts") or r["ts"]) or int(r["ts"] or 0),
"channel": "portal", "pending": False,
})
continue
if st is not None:
text = st.get("text") or r["text"]
pending = not (st.get("replied") or r["consumed"])
else:
text = r["text"]
pending = bool(r["sender"] == "user" and not r["consumed"] and
time.time() * 1000 - int(r["ts"] or 0) < 600_000)
msgs.append({
"id": str(r["msg_id"]),
"from": r["sender"],
"text": text,
"ts": int(r["ts"] or 0),
"channel": "portal",
"pending": bool(pending),
})
# последние реплаи стора, которых ещё нет в БД (push идёт напрямую);
# их нет как строк БД — показываем только если ретрай не доехал до портала
seen_db_ids = {str(r["msg_id"]) for r in rows}
for st in store.values():
if (str(st.get("update_id") or "") not in seen_db_ids
and st.get("replied") and st.get("reply_text")):
msgs.append({"id": st["store_id"], "from": "agent",
"text": st.get("reply_text"), "ts": st.get("ts_ms") or 0,
"channel": "portal", "pending": False})
msgs.sort(key=lambda m: m["ts"])
# схлопнуть подряд идущие ОДИНАКОВЫЕ agent-пуши (ретраи ретраят с новым
# msg_id — settg-диагностика светится как шум ×3): keep-last + счётчик
collapsed = []
for m in msgs:
if (m["from"] == "agent" and collapsed
and collapsed[-1]["from"] == "agent"
and collapsed[-1].get("text") == m.get("text")
and int(m["ts"]) - int(collapsed[-1]["ts"]) < 600_000):
collapsed[-1]["repeat"] = collapsed[-1].get("repeat", 1) + 1
collapsed[-1]["ts"] = m["ts"]
continue
collapsed.append(m)
return {"ok": True, "agent": agent_name, "messages": collapsed[-limit:]}
def chat_send(text: str) -> dict:
"""Сообщение владельца агенту: прямая запись в agent_portal_chat.
HTTP-роут портала требует юзер-сессию, дашборд авторизован своим токеном —
пишем напрямую в ту же таблицу (sender='user', consumed=0): сек-рефлекс
харнеса поднимет её ≤20 с и мин-LLM ответит в тот же канал.
"""
text = (text or "").strip()
if not text:
return {"ok": False, "error": "empty"}
agent_id, _ = _coop_agent()
if not agent_id:
return {"ok": False, "error": "no_agent"}
mid = f"dash-{agent_id}-{int(time.time() * 1000)}-{os.urandom(3).hex()}"
db = _sqlite3.connect(_COOP_DB, timeout=10)
try:
db.execute(
"INSERT INTO agent_portal_chat (msg_id, agent_id, sender, text, ts) "
"VALUES (?,?,?,?,?)", (mid, agent_id, "user", text[:_CHAT_MAX],
int(time.time() * 1000)))
db.commit()
finally:
db.close()
return {"ok": True, "msg_id": mid}
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()
# отчёт тика, закрывшего задачу: digest с summary_ref == result_ref
digest_by_ref = {}
for d in (model.get("digests") or {}).values():
if d.get("summary_ref"):
digest_by_ref[d["summary_ref"]] = d
tasks = []
for t in model.get("tasks", {}).values():
report = digest_by_ref.get(t.get("result_ref")) or {}
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"),
# детали для раскрытия по клику (т-260929220004)
"details": (t.get("details") or "")[:2000],
"promotions": t.get("promotions"), "promoted_to": t.get("promoted_to"),
"session_id": t.get("session_id"),
# t-261001082111: время взятия в работу (TaskClaimed) — для
# пометки «в работе» и таймера elapsed на карточке running
"claimed_at": t.get("claimed_at"),
"report_level": report.get("level"),
"report_ts": report.get("ts"),
"report": (report.get("text") or "")[:1500],
})
# два стабильных прохода: новые сверху внутри статуса,
# статусы по порядку 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)},
}
def build_agent(level: str) -> dict:
"""Детали одного агента-уровня (t-261002175316-eb24cc).
Read-only проекция «на лету»: состояние уровня — из run_projections()
(консистентно со scheduler/CLI), история тиков/ошибок/спенда — прямыми
read-only запросами к стору (проверено: 64–210мс на 643к событий).
"""
import re as _re
import sqlite3 as _sq
from datetime import datetime, timedelta, timezone
lvl = (level or "").strip().lower()
if not _re.fullmatch(r"[a-z_]{1,24}", lvl):
return {"error": "bad level: %r" % (level or "")}
model = es.run_projections()
cs = (model.get("cycle_state") or {}).get(lvl) or {}
def _q(sql, args=()):
db = _sq.connect(es.DB_PATH, timeout=10)
try:
return db.execute(sql, args).fetchall()
finally:
db.close()
now = datetime.now(timezone.utc)
c24 = (now - timedelta(hours=24)).isoformat()
c7 = (now - timedelta(days=7)).isoformat()
# ── счётчики 24ч/7д из стора (тихи, ошибки, спенд) ──
def _count(etype, cutoff):
return _q(
"SELECT COUNT(*) FROM events WHERE event_type=? "
"AND timestamp>=? AND json_extract(data,'$.level')=?",
(etype, cutoff, lvl))[0][0]
ticks_24, ticks_7 = _count("CycleTicked", c24), _count("CycleTicked", c7)
errors_24, errors_7 = _count("CycleError", c24), _count("CycleError", c7)
absorbed_24 = _count("TickAbsorbed", c24)
absorbed_7 = _count("TickAbsorbed", c7)
def _tokens(cutoff):
row = _q(
"SELECT COUNT(*), COALESCE(SUM(json_extract(data,'$.prompt_tokens')),0), "
"COALESCE(SUM(json_extract(data,'$.completion_tokens')),0) "
"FROM events WHERE event_type='BudgetSpent' AND timestamp>=? "
"AND json_extract(data,'$.level')=?", (cutoff, lvl))[0]
return {"calls": row[0], "prompt": row[1], "completion": row[2]}
tok24, tok7 = _tokens(c24), _tokens(c7)
# ── активность по часам суток (24 ведра, тики уровня за 24ч) ──
hourly = [{"h": h, "c": c} for h, c in _q(
"SELECT strftime('%H', timestamp) h, COUNT(*) c FROM events "
"WHERE event_type='CycleTicked' AND timestamp>=? "
"AND json_extract(data,'$.level')=? GROUP BY h ORDER BY h",
(c24, lvl))]
# ── последние тики и ошибки уровня (сырьё из стора, капы жёсткие) ──
recent_ticks = [{"ts": ts, "seq": aid} for aid, ts in _q(
"SELECT aggregate_id, timestamp FROM events "
"WHERE event_type='CycleTicked' AND json_extract(data,'$.level')=? "
"ORDER BY id DESC LIMIT 40", (lvl,))]
last_err_text = None
recent_errors = []
for ts, err in _q(
"SELECT timestamp, json_extract(data,'$.error') FROM events "
"WHERE event_type='CycleError' AND timestamp>=? "
"AND json_extract(data,'$.level')=? ORDER BY id DESC LIMIT 8", (c7, lvl)):
err = (err or "")[:300]
recent_errors.append({"ts": ts, "error": err})
if last_err_text is None:
last_err_text = err
# ── LLM-вызовы уровня: последние 30 по токенам (график спенда) ──
spend_rows = [{"trace_id": t, "prompt": p, "completion": cc}
for t, p, cc in _q(
"SELECT json_extract(data,'$.trace_id'), "
"json_extract(data,'$.prompt_tokens'), "
"json_extract(data,'$.completion_tokens') FROM events "
"WHERE event_type='BudgetSpent' AND json_extract(data,'$.level')=? "
"ORDER BY id DESC LIMIT 30", (lvl,))]
spend_rows.reverse()
# ── задачи уровня (из проекции) + время в работе ──
lvl_tasks = []
time_in_work_24 = 0.0
for t in model.get("tasks", {}).values():
if t.get("level") != lvl:
continue
st, cl = t.get("status"), t.get("claimed_at")
dur = None
if cl:
try:
beg = datetime.fromisoformat(cl)
end = datetime.fromisoformat(t["finished"]) if t.get("finished") else now
if end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
if beg.tzinfo is None:
beg = beg.replace(tzinfo=timezone.utc)
dur = max(0.0, (end - beg).total_seconds())
if cl >= c24:
time_in_work_24 += dur
except (ValueError, TypeError, KeyError):
dur = None
lvl_tasks.append({
"id": t.get("id"), "title": (t.get("title") or "")[:120],
"size": t.get("size"), "status": st,
"created": t.get("created"), "claimed_at": cl,
"finished": t.get("finished"), "duration_sec": dur,
"error": t.get("error"), "promoted_to": t.get("promoted_to"),
})
lvl_tasks.sort(key=lambda x: x.get("claimed_at") or x.get("created") or "",
reverse=True)
current = [{"id": t["id"], "title": t["title"], "claimed_at": t["claimed_at"],
"duration_sec": t["duration_sec"]}
for t in lvl_tasks if t["status"] == "running"]
# ── эскалации, задевающие уровень ──
esc = [{"id": e.get("id"), "severity": e.get("severity"),
"reason": (e.get("reason") or "")[:200],
"from_level": e.get("from_level"), "to_level": e.get("to_level"),
"ts": e.get("ts")}
for e in (model.get("escalations") or {}).values()
if e.get("status") == "open"
and (e.get("from_level") == lvl or e.get("to_level") == lvl)]
esc.sort(key=lambda e: e.get("ts") or "", reverse=True)
# ── дайджесты именно этого уровня ──
digests = [{"period": d.get("period"), "text": (d.get("text") or "")[:600],
"ts": d.get("ts"), "summary_ref": d.get("summary_ref")}
for d in (model.get("digests") or {}).values()
if d.get("level") == lvl]
digests.sort(key=lambda d: d.get("ts") or "", reverse=True)
return {
"generated_at": _utcnow(),
"agent": {
"level": lvl,
"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": last_err_text,
"ticks_24h": ticks_24, "absorbed_24h": absorbed_24,
"errors_24h": errors_24,
"ticks_7d": ticks_7, "absorbed_7d": absorbed_7, "errors_7d": errors_7,
"llm_24h": tok24, "llm_7d": tok7,
"time_in_work_24h_sec": round(time_in_work_24),
"current_tasks": current,
},
"tasks": lvl_tasks[:30],
"digests": digests[:10],
"escalations_open": esc,
"recent_ticks": recent_ticks,
"recent_errors": recent_errors,
"hourly": hourly,
"spend_rows": spend_rows,
"meta": {"token_required": bool(_TOKEN)},
}
class _Handler(BaseHTTPRequestHandler):
server_version = "SimbiotDash/1.2"
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/chat":
# чат Simbiot в дашборде (t-261001100740): read — авторизуем так же
if not self._authorized():
return self._forbidden()
try:
return self._json(build_chat())
except Exception as e:
return self._json({"ok": False, "error": str(e)[:500],
"messages": []}, code=503)
if route == "/api/agent":
# детали агента-уровня (t-261002175316-eb24cc): read-only,
# ?level=sec|min|hour|day (+ токен/cookie как у /api/state)
if not self._authorized():
return self._forbidden()
qs = self.path.partition("?")[2]
from urllib.parse import parse_qs as _pqs
lvl = (_pqs(qs).get("level") or [""])[0]
try:
return self._json(build_agent(lvl))
except Exception as e:
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 do_POST(self):
route = self.path.partition("?")[0]
if route == "/api/chat/send":
# send чата (t-261001100740): та же авторизация, что у read-API
if not self._authorized():
return self._forbidden()
try:
n = int(self.headers.get("Content-Length") or 0)
except ValueError:
n = 0
if n <= 0 or n > 64_000:
return self._json({"ok": False, "error": "bad length"}, code=400)
try:
data = json.loads(self.rfile.read(n).decode("utf-8"))
except Exception:
return self._json({"ok": False, "error": "bad json"}, code=400)
try:
return self._json(chat_send(str(data.get("text") or "")))
except Exception as e:
return self._json({"ok": False, "error": str(e)[:500]}, code=503)
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()