diff --git a/src/dashboard/app.py b/src/dashboard/app.py index ebb04216..56b92369 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -32,6 +32,7 @@ from dashboard.routes.briefing import router as briefing_router from dashboard.routes.calm import router as calm_router from dashboard.routes.chat_api import router as chat_api_router from dashboard.routes.chat_api_v1 import router as chat_api_v1_router +from dashboard.routes.daily_run import router as daily_run_router from dashboard.routes.db_explorer import router as db_explorer_router from dashboard.routes.discord import router as discord_router from dashboard.routes.experiments import router as experiments_router @@ -625,6 +626,7 @@ app.include_router(db_explorer_router) app.include_router(world_router) app.include_router(matrix_router) app.include_router(tower_router) +app.include_router(daily_run_router) @app.websocket("/ws") diff --git a/src/dashboard/routes/daily_run.py b/src/dashboard/routes/daily_run.py new file mode 100644 index 00000000..e60f1903 --- /dev/null +++ b/src/dashboard/routes/daily_run.py @@ -0,0 +1,425 @@ +"""Daily Run metrics routes — dashboard card for triage and session metrics.""" + +from __future__ import annotations + +import json +import logging +import os +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request as UrlRequest +from urllib.request import urlopen + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, JSONResponse + +from config import settings +from dashboard.templating import templates + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["daily-run"]) + +REPO_ROOT = Path(settings.repo_root) +CONFIG_PATH = REPO_ROOT / "timmy_automations" / "config" / "daily_run.json" + +DEFAULT_CONFIG = { + "gitea_api": "http://localhost:3000/api/v1", + "repo_slug": "rockachopa/Timmy-time-dashboard", + "token_file": "~/.hermes/gitea_token", + "layer_labels_prefix": "layer:", +} + +LAYER_LABELS = ["layer:triage", "layer:micro-fix", "layer:tests", "layer:economy"] + + +def _load_config() -> dict: + """Load configuration from config file with fallback to defaults.""" + config = DEFAULT_CONFIG.copy() + if CONFIG_PATH.exists(): + try: + file_config = json.loads(CONFIG_PATH.read_text()) + if "orchestrator" in file_config: + config.update(file_config["orchestrator"]) + except (json.JSONDecodeError, OSError) as exc: + logger.debug("Could not load daily_run config: %s", exc) + + # Environment variable overrides + if os.environ.get("TIMMY_GITEA_API"): + config["gitea_api"] = os.environ.get("TIMMY_GITEA_API") + if os.environ.get("TIMMY_REPO_SLUG"): + config["repo_slug"] = os.environ.get("TIMMY_REPO_SLUG") + if os.environ.get("TIMMY_GITEA_TOKEN"): + config["token"] = os.environ.get("TIMMY_GITEA_TOKEN") + + return config + + +def _get_token(config: dict) -> str | None: + """Get Gitea token from environment or file.""" + if "token" in config: + return config["token"] + + token_file = Path(config["token_file"]).expanduser() + if token_file.exists(): + return token_file.read_text().strip() + + return None + + +class GiteaClient: + """Simple Gitea API client with graceful degradation.""" + + def __init__(self, config: dict, token: str | None): + self.api_base = config["gitea_api"].rstrip("/") + self.repo_slug = config["repo_slug"] + self.token = token + self._available: bool | None = None + + def _headers(self) -> dict: + headers = {"Accept": "application/json"} + if self.token: + headers["Authorization"] = f"token {self.token}" + return headers + + def _api_url(self, path: str) -> str: + return f"{self.api_base}/repos/{self.repo_slug}/{path}" + + def is_available(self) -> bool: + """Check if Gitea API is reachable.""" + if self._available is not None: + return self._available + + try: + req = UrlRequest( + f"{self.api_base}/version", + headers=self._headers(), + method="GET", + ) + with urlopen(req, timeout=5) as resp: + self._available = resp.status == 200 + return self._available + except (HTTPError, URLError, TimeoutError): + self._available = False + return False + + def get_paginated(self, path: str, params: dict | None = None) -> list: + """Fetch all pages of a paginated endpoint.""" + all_items = [] + page = 1 + limit = 50 + + while True: + url = self._api_url(path) + query_parts = [f"limit={limit}", f"page={page}"] + if params: + for key, val in params.items(): + query_parts.append(f"{key}={val}") + url = f"{url}?{'&'.join(query_parts)}" + + req = UrlRequest(url, headers=self._headers(), method="GET") + with urlopen(req, timeout=15) as resp: + batch = json.loads(resp.read()) + + if not batch: + break + + all_items.extend(batch) + if len(batch) < limit: + break + page += 1 + + return all_items + + +@dataclass +class LayerMetrics: + """Metrics for a single layer.""" + + name: str + label: str + current_count: int + previous_count: int + + @property + def trend(self) -> str: + """Return trend indicator.""" + if self.previous_count == 0: + return "→" if self.current_count == 0 else "↑" + diff = self.current_count - self.previous_count + pct = (diff / self.previous_count) * 100 + if pct > 20: + return "↑↑" + elif pct > 5: + return "↑" + elif pct < -20: + return "↓↓" + elif pct < -5: + return "↓" + return "→" + + @property + def trend_color(self) -> str: + """Return color for trend (CSS variable name).""" + trend = self.trend + if trend in ("↑↑", "↑"): + return "var(--green)" # More work = positive + elif trend in ("↓↓", "↓"): + return "var(--amber)" # Less work = caution + return "var(--text-dim)" + + +@dataclass +class DailyRunMetrics: + """Complete Daily Run metrics.""" + + sessions_completed: int + sessions_previous: int + layers: list[LayerMetrics] + total_touched_current: int + total_touched_previous: int + lookback_days: int + generated_at: str + + @property + def sessions_trend(self) -> str: + """Return sessions trend indicator.""" + if self.sessions_previous == 0: + return "→" if self.sessions_completed == 0 else "↑" + diff = self.sessions_completed - self.sessions_previous + pct = (diff / self.sessions_previous) * 100 + if pct > 20: + return "↑↑" + elif pct > 5: + return "↑" + elif pct < -20: + return "↓↓" + elif pct < -5: + return "↓" + return "→" + + @property + def sessions_trend_color(self) -> str: + """Return color for sessions trend.""" + trend = self.sessions_trend + if trend in ("↑↑", "↑"): + return "var(--green)" + elif trend in ("↓↓", "↓"): + return "var(--amber)" + return "var(--text-dim)" + + +def _extract_layer(labels: list[dict]) -> str | None: + """Extract layer label from issue labels.""" + for label in labels: + name = label.get("name", "") + if name.startswith("layer:"): + return name.replace("layer:", "") + return None + + +def _load_cycle_data(days: int = 14) -> dict: + """Load cycle retrospective data for session counting.""" + retro_file = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl" + if not retro_file.exists(): + return {"current": 0, "previous": 0} + + try: + entries = [] + for line in retro_file.read_text().strip().splitlines(): + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + + now = datetime.now(UTC) + current_cutoff = now - timedelta(days=days) + previous_cutoff = now - timedelta(days=days * 2) + + current_count = 0 + previous_count = 0 + + for entry in entries: + ts_str = entry.get("timestamp", "") + if not ts_str: + continue + try: + ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + if ts >= current_cutoff: + if entry.get("success", False): + current_count += 1 + elif ts >= previous_cutoff: + if entry.get("success", False): + previous_count += 1 + except (ValueError, TypeError): + continue + + return {"current": current_count, "previous": previous_count} + except (OSError, ValueError) as exc: + logger.debug("Failed to load cycle data: %s", exc) + return {"current": 0, "previous": 0} + + +def _fetch_layer_metrics( + client: GiteaClient, lookback_days: int = 7 +) -> tuple[list[LayerMetrics], int, int]: + """Fetch metrics for each layer from Gitea issues.""" + now = datetime.now(UTC) + current_cutoff = now - timedelta(days=lookback_days) + previous_cutoff = now - timedelta(days=lookback_days * 2) + + layers = [] + total_current = 0 + total_previous = 0 + + for layer_label in LAYER_LABELS: + layer_name = layer_label.replace("layer:", "") + try: + # Fetch all issues with this layer label (both open and closed) + issues = client.get_paginated( + "issues", + {"state": "all", "labels": layer_label, "limit": 100}, + ) + + current_count = 0 + previous_count = 0 + + for issue in issues: + updated_at = issue.get("updated_at", "") + if not updated_at: + continue + try: + updated = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) + if updated >= current_cutoff: + current_count += 1 + elif updated >= previous_cutoff: + previous_count += 1 + except (ValueError, TypeError): + continue + + layers.append( + LayerMetrics( + name=layer_name, + label=layer_label, + current_count=current_count, + previous_count=previous_count, + ) + ) + total_current += current_count + total_previous += previous_count + + except (HTTPError, URLError) as exc: + logger.debug("Failed to fetch issues for %s: %s", layer_label, exc) + layers.append( + LayerMetrics( + name=layer_name, + label=layer_label, + current_count=0, + previous_count=0, + ) + ) + + return layers, total_current, total_previous + + +def _get_metrics(lookback_days: int = 7) -> DailyRunMetrics | None: + """Get Daily Run metrics from Gitea API.""" + config = _load_config() + token = _get_token(config) + client = GiteaClient(config, token) + + if not client.is_available(): + logger.debug("Gitea API not available for Daily Run metrics") + return None + + try: + # Get layer metrics from issues + layers, total_current, total_previous = _fetch_layer_metrics(client, lookback_days) + + # Get session data from cycle retrospectives + cycle_data = _load_cycle_data(days=lookback_days) + + return DailyRunMetrics( + sessions_completed=cycle_data["current"], + sessions_previous=cycle_data["previous"], + layers=layers, + total_touched_current=total_current, + total_touched_previous=total_previous, + lookback_days=lookback_days, + generated_at=datetime.now(UTC).isoformat(), + ) + except Exception as exc: + logger.debug("Error fetching Daily Run metrics: %s", exc) + return None + + +@router.get("/daily-run/metrics", response_class=JSONResponse) +async def daily_run_metrics_api(lookback_days: int = 7): + """Return Daily Run metrics as JSON API.""" + metrics = _get_metrics(lookback_days) + if not metrics: + return JSONResponse( + {"error": "Gitea API unavailable", "status": "unavailable"}, + status_code=503, + ) + + return JSONResponse( + { + "status": "ok", + "lookback_days": metrics.lookback_days, + "sessions": { + "completed": metrics.sessions_completed, + "previous": metrics.sessions_previous, + "trend": metrics.sessions_trend, + }, + "layers": [ + { + "name": layer.name, + "label": layer.label, + "current": layer.current_count, + "previous": layer.previous_count, + "trend": layer.trend, + } + for layer in metrics.layers + ], + "totals": { + "current": metrics.total_touched_current, + "previous": metrics.total_touched_previous, + }, + "generated_at": metrics.generated_at, + } + ) + + +@router.get("/daily-run/panel", response_class=HTMLResponse) +async def daily_run_panel(request: Request, lookback_days: int = 7): + """Return Daily Run metrics panel HTML for HTMX polling.""" + metrics = _get_metrics(lookback_days) + + # Build Gitea URLs for filtered issue lists + config = _load_config() + repo_slug = config.get("repo_slug", "rockachopa/Timmy-time-dashboard") + gitea_base = config.get("gitea_api", "http://localhost:3000/api/v1").replace("/api/v1", "") + + # Logbook URL (link to issues with any layer label) + layer_labels = ",".join(LAYER_LABELS) + logbook_url = f"{gitea_base}/{repo_slug}/issues?labels={layer_labels}&state=all" + + # Layer-specific URLs + layer_urls = { + layer: f"{gitea_base}/{repo_slug}/issues?labels=layer:{layer}&state=all" + for layer in ["triage", "micro-fix", "tests", "economy"] + } + + return templates.TemplateResponse( + request, + "partials/daily_run_panel.html", + { + "metrics": metrics, + "logbook_url": logbook_url, + "layer_urls": layer_urls, + "gitea_available": metrics is not None, + }, + ) diff --git a/src/dashboard/templates/index.html b/src/dashboard/templates/index.html index 721a188e..5a4c7942 100644 --- a/src/dashboard/templates/index.html +++ b/src/dashboard/templates/index.html @@ -21,6 +21,11 @@ {% endcall %} + + {% call panel("DAILY RUN", hx_get="/daily-run/panel", hx_trigger="every 60s") %} +
LOADING...
+ {% endcall %} + diff --git a/src/dashboard/templates/partials/daily_run_panel.html b/src/dashboard/templates/partials/daily_run_panel.html new file mode 100644 index 00000000..af95cc51 --- /dev/null +++ b/src/dashboard/templates/partials/daily_run_panel.html @@ -0,0 +1,54 @@ +
// DAILY RUN METRICS
+
+ {% if not gitea_available %} +
+ Gitea API unavailable +
+ {% else %} + {% set m = metrics %} + + +
+
+ Sessions ({{ m.lookback_days }}d) + + Logbook → + +
+
+ {{ m.sessions_completed }} + {{ m.sessions_trend }} + vs {{ m.sessions_previous }} prev +
+
+ + +
+
Issues by Layer
+
+ {% for layer in m.layers %} +
+ + {{ layer.name.replace('-', ' ') }} + +
+ {{ layer.current_count }} + {{ layer.trend }} +
+
+ {% endfor %} +
+
+ + +
+
+ Total Issues Touched +
+ {{ m.total_touched_current }} + / {{ m.total_touched_previous }} prev +
+
+
+ {% endif %} +