diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 49c291e..5701ea2 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -46,6 +46,7 @@ from dashboard.routes.tasks import router as tasks_router from dashboard.routes.telegram import router as telegram_router from dashboard.routes.thinking import router as thinking_router from dashboard.routes.tools import router as tools_router +from dashboard.routes.tower import router as tower_router from dashboard.routes.voice import router as voice_router from dashboard.routes.work_orders import router as work_orders_router from dashboard.routes.world import router as world_router @@ -583,6 +584,7 @@ app.include_router(system_router) app.include_router(experiments_router) app.include_router(db_explorer_router) app.include_router(world_router) +app.include_router(tower_router) @app.websocket("/ws") diff --git a/src/dashboard/routes/tower.py b/src/dashboard/routes/tower.py new file mode 100644 index 0000000..f27e629 --- /dev/null +++ b/src/dashboard/routes/tower.py @@ -0,0 +1,108 @@ +"""Tower dashboard — real-time Spark visualization via WebSocket. + +GET /tower — HTML Tower dashboard (Thinking / Predicting / Advising) +WS /tower/ws — WebSocket stream of Spark engine state updates +""" + +import asyncio +import json +import logging + +from fastapi import APIRouter, Request, WebSocket +from fastapi.responses import HTMLResponse + +from dashboard.templating import templates +from spark.engine import spark_engine + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tower", tags=["tower"]) + +_PUSH_INTERVAL = 5 # seconds between state broadcasts + + +def _spark_snapshot() -> dict: + """Build a JSON-serialisable snapshot of Spark state.""" + status = spark_engine.status() + + timeline = spark_engine.get_timeline(limit=10) + events = [] + for ev in timeline: + entry = { + "event_type": ev.event_type, + "description": ev.description, + "importance": ev.importance, + "created_at": ev.created_at, + } + if ev.agent_id: + entry["agent_id"] = ev.agent_id[:8] + if ev.task_id: + entry["task_id"] = ev.task_id[:8] + try: + entry["data"] = json.loads(ev.data) + except (json.JSONDecodeError, TypeError): + entry["data"] = {} + events.append(entry) + + predictions = spark_engine.get_predictions(limit=5) + preds = [] + for p in predictions: + pred = { + "task_id": p.task_id[:8] if p.task_id else "?", + "accuracy": p.accuracy, + "evaluated": p.evaluated_at is not None, + "created_at": p.created_at, + } + try: + pred["predicted"] = json.loads(p.predicted_value) + except (json.JSONDecodeError, TypeError): + pred["predicted"] = {} + preds.append(pred) + + advisories = spark_engine.get_advisories() + advs = [ + { + "category": a.category, + "priority": a.priority, + "title": a.title, + "detail": a.detail, + "suggested_action": a.suggested_action, + } + for a in advisories + ] + + return { + "type": "spark_state", + "status": status, + "events": events, + "predictions": preds, + "advisories": advs, + } + + +@router.get("", response_class=HTMLResponse) +async def tower_ui(request: Request): + """Render the Tower dashboard page.""" + snapshot = _spark_snapshot() + return templates.TemplateResponse( + request, + "tower.html", + {"snapshot": snapshot}, + ) + + +@router.websocket("/ws") +async def tower_ws(websocket: WebSocket) -> None: + """Stream Spark state snapshots to the Tower dashboard.""" + await websocket.accept() + logger.info("Tower WS connected") + + try: + # Send initial snapshot + await websocket.send_text(json.dumps(_spark_snapshot())) + + while True: + await asyncio.sleep(_PUSH_INTERVAL) + await websocket.send_text(json.dumps(_spark_snapshot())) + except Exception: + logger.debug("Tower WS disconnected") diff --git a/src/dashboard/templates/tower.html b/src/dashboard/templates/tower.html new file mode 100644 index 0000000..2a117cb --- /dev/null +++ b/src/dashboard/templates/tower.html @@ -0,0 +1,180 @@ +{% extends "base.html" %} + +{% block title %}Timmy Time — Tower{% endblock %} + +{% block extra_styles %}{% endblock %} + +{% block content %} +
+ +
+
TOWER
+
+ Real-time Spark visualization — + CONNECTING +
+
+ +
+ + +
+
+
// THINKING
+
+
Waiting for Spark data…
+
+
+
+ + +
+
+
// PREDICTING
+
+
Waiting for Spark data…
+
+
+
+
// EIDOS STATS
+
+
+
EVENTS0
+
MEMORIES0
+
PREDICTIONS0
+
ACCURACY
+
+
+
+
+ + +
+
+
// ADVISING
+
+
Waiting for Spark data…
+
+
+
+ +
+
+ + +{% endblock %} diff --git a/static/css/mission-control.css b/static/css/mission-control.css index 395b987..fc84430 100644 --- a/static/css/mission-control.css +++ b/static/css/mission-control.css @@ -2493,3 +2493,57 @@ .db-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .db-cell:hover { white-space: normal; word-break: break-all; } .db-truncated { font-size: 0.7rem; color: var(--amber); padding: 0.3rem 0; } + +/* ── Tower ────────────────────────────────────────────────────────────── */ +.tower-container { max-width: 1400px; margin: 0 auto; } +.tower-header { margin-bottom: 1rem; } +.tower-title { font-size: 1.6rem; font-weight: 700; color: var(--green); letter-spacing: 0.15em; } +.tower-subtitle { font-size: 0.85rem; color: var(--text-dim); } + +.tower-conn-badge { font-size: 0.7rem; font-weight: 600; padding: 2px 8px; border-radius: 3px; letter-spacing: 0.08em; } +.tower-conn-live { color: var(--green); border: 1px solid var(--green); } +.tower-conn-offline { color: var(--red); border: 1px solid var(--red); } +.tower-conn-connecting { color: var(--amber); border: 1px solid var(--amber); } + +.tower-phase-card { min-height: 300px; } +.tower-phase-thinking { border-left: 3px solid var(--purple); } +.tower-phase-predicting { border-left: 3px solid var(--orange); } +.tower-phase-advising { border-left: 3px solid var(--green); } +.tower-scroll { max-height: 50vh; overflow-y: auto; } +.tower-empty { text-align: center; color: var(--text-dim); padding: 16px; font-size: 0.85rem; } + +.tower-stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; text-align: center; } +.tower-stat-label { display: block; font-size: 0.65rem; color: var(--text-dim); letter-spacing: 0.1em; } +.tower-stat-value { display: block; font-size: 1.1rem; font-weight: 700; color: var(--text-bright); } + +.tower-event { padding: 8px; margin-bottom: 6px; border-left: 3px solid var(--border); border-radius: 3px; background: var(--bg-card); } +.tower-etype-task_posted { border-left-color: var(--purple); } +.tower-etype-bid_submitted { border-left-color: var(--orange); } +.tower-etype-task_completed { border-left-color: var(--green); } +.tower-etype-task_failed { border-left-color: var(--red); } +.tower-etype-agent_joined { border-left-color: var(--purple); } +.tower-etype-tool_executed { border-left-color: var(--amber); } +.tower-ev-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; } +.tower-ev-badge { font-size: 0.65rem; font-weight: 600; color: var(--text-bright); letter-spacing: 0.08em; } +.tower-ev-dots { font-size: 0.6rem; color: var(--amber); } +.tower-ev-desc { font-size: 0.8rem; color: var(--text); } +.tower-ev-time { font-size: 0.65rem; color: var(--text-dim); margin-top: 2px; } + +.tower-pred { padding: 8px; margin-bottom: 6px; border-radius: 3px; background: var(--bg-card); border-left: 3px solid var(--orange); } +.tower-pred-done { border-left-color: var(--green); } +.tower-pred-pending { border-left-color: var(--amber); } +.tower-pred-head { display: flex; justify-content: space-between; align-items: center; } +.tower-pred-task { font-size: 0.75rem; font-weight: 600; color: var(--text-bright); font-family: monospace; } +.tower-pred-acc { font-size: 0.75rem; font-weight: 700; } +.tower-pred-detail { font-size: 0.75rem; color: var(--text-dim); margin-top: 4px; } + +.tower-advisory { padding: 8px; margin-bottom: 6px; border-radius: 3px; background: var(--bg-card); border-left: 3px solid var(--border); } +.tower-adv-high { border-left-color: var(--red); } +.tower-adv-medium { border-left-color: var(--orange); } +.tower-adv-low { border-left-color: var(--green); } +.tower-adv-head { display: flex; justify-content: space-between; font-size: 0.65rem; margin-bottom: 4px; } +.tower-adv-cat { font-weight: 600; color: var(--text-dim); letter-spacing: 0.08em; } +.tower-adv-prio { font-weight: 700; color: var(--amber); } +.tower-adv-title { font-size: 0.85rem; font-weight: 600; color: var(--text-bright); } +.tower-adv-detail { font-size: 0.8rem; color: var(--text); margin-top: 2px; } +.tower-adv-action { font-size: 0.75rem; color: var(--green); margin-top: 4px; font-style: italic; }