"""Dashboard API endpoints. Provides JSON data for the standalone HTML dashboard. All ClickUp data is cached for 5 minutes to avoid hammering the API. """ from __future__ import annotations import json import logging import shutil import threading import time from typing import TYPE_CHECKING from fastapi import APIRouter if TYPE_CHECKING: from .agent_registry import AgentRegistry from .config import Config from .db import Database log = logging.getLogger(__name__) router = APIRouter(prefix="/api") # Module-level refs wired at startup by create_api_router() _config: Config | None = None _db: Database | None = None _registry: AgentRegistry | None = None # Simple in-memory cache for ClickUp data _cache: dict[str, dict] = {} _cache_lock = threading.Lock() CACHE_TTL = 300 # 5 minutes def create_api_router( config: Config, db: Database, registry: AgentRegistry ) -> APIRouter: """Wire dependencies and return the router.""" global _config, _db, _registry _config = config _db = db _registry = registry return router def _get_cached(key: str) -> dict | None: with _cache_lock: entry = _cache.get(key) if entry and time.time() - entry["ts"] < CACHE_TTL: return entry["data"] return None def _set_cached(key: str, data): with _cache_lock: _cache[key] = {"data": data, "ts": time.time()} def _get_clickup_client(): from .clickup import ClickUpClient return ClickUpClient( api_token=_config.clickup.api_token, workspace_id=_config.clickup.workspace_id, task_type_field_name=_config.clickup.task_type_field_name, ) # ── Tasks ── @router.get("/tasks") async def get_tasks(): """All ClickUp tasks grouped by work category.""" cached = _get_cached("tasks") if cached is not None: return cached if not _config or not _config.clickup.enabled: return {"tasks": [], "error": "ClickUp not configured"} client = _get_clickup_client() try: raw_tasks = client.get_tasks_from_space(_config.clickup.space_id) tasks = [] for t in raw_tasks: tasks.append( { "id": t.id, "name": t.name, "status": t.status, "task_type": t.task_type, "url": t.url, "due_date": t.due_date, "list_name": t.list_name, "custom_fields": t.custom_fields, } ) result = {"tasks": tasks, "count": len(tasks)} _set_cached("tasks", result) return result except Exception as e: log.error("Failed to fetch tasks for dashboard: %s", e) return {"tasks": [], "error": str(e)} finally: client.close() @router.get("/tasks/by-company") async def get_tasks_by_company(): """Tasks grouped by Client custom field.""" data = await get_tasks() by_company: dict[str, list] = {} for task in data.get("tasks", []): company = task["custom_fields"].get("Client") or "Unassigned" by_company.setdefault(company, []).append(task) # Sort companies by task count descending sorted_companies = sorted(by_company.items(), key=lambda x: -len(x[1])) return { "companies": [ {"name": name, "tasks": tasks, "count": len(tasks)} for name, tasks in sorted_companies ] } @router.get("/tasks/link-building") async def get_link_building_tasks(): """Link building tasks with KV state merged in.""" data = await get_tasks() lb_tasks = [t for t in data.get("tasks", []) if t["task_type"] == "Link Building"] # Merge KV state if _db: for task in lb_tasks: kv_key = f"clickup:task:{task['id']}:state" raw = _db.kv_get(kv_key) if raw: try: task["kv_state"] = json.loads(raw) except json.JSONDecodeError: task["kv_state"] = None else: task["kv_state"] = None # Group by company by_company: dict[str, list] = {} for task in lb_tasks: company = task["custom_fields"].get("Client") or "Unassigned" by_company.setdefault(company, []).append(task) return { "total": len(lb_tasks), "companies": [ {"name": name, "tasks": tasks, "count": len(tasks)} for name, tasks in sorted(by_company.items(), key=lambda x: -len(x[1])) ], "status_counts": _count_statuses(lb_tasks), } @router.get("/tasks/press-releases") async def get_press_release_tasks(): """Press release tasks with KV state merged in.""" data = await get_tasks() pr_tasks = [t for t in data.get("tasks", []) if t["task_type"] == "Press Release"] if _db: for task in pr_tasks: kv_key = f"clickup:task:{task['id']}:state" raw = _db.kv_get(kv_key) if raw: try: task["kv_state"] = json.loads(raw) except json.JSONDecodeError: task["kv_state"] = None else: task["kv_state"] = None by_company: dict[str, list] = {} for task in pr_tasks: company = task["custom_fields"].get("Client") or "Unassigned" by_company.setdefault(company, []).append(task) return { "total": len(pr_tasks), "companies": [ {"name": name, "tasks": tasks, "count": len(tasks)} for name, tasks in sorted(by_company.items(), key=lambda x: -len(x[1])) ], "status_counts": _count_statuses(pr_tasks), } def _count_statuses(tasks: list[dict]) -> dict[str, int]: counts: dict[str, int] = {} for t in tasks: s = t.get("status", "unknown") counts[s] = counts.get(s, 0) + 1 return counts # ── Agents ── @router.get("/agents") async def get_agents(): """Agent configurations.""" if not _config: return {"agents": []} agents = [] for ac in _config.agents: agents.append( { "name": ac.name, "display_name": ac.display_name, "personality_file": ac.personality_file, "model": ac.model or _config.chat_model, "tools": ac.tools, "skills": ac.skills, "memory_scope": ac.memory_scope or "shared", } ) return {"agents": agents} # ── System ── @router.get("/system/health") async def get_system_health(): """System health: disk space, brains, integrations.""" health = { "execution_brain": shutil.which("claude") is not None, "clickup_enabled": _config.clickup.enabled if _config else False, "chat_model": _config.chat_model if _config else "unknown", "execution_model": _config.default_model if _config else "unknown", "disks": [], } # Disk space (Windows drives) for drive in ["C:", "D:", "E:", "Z:"]: try: usage = shutil.disk_usage(drive + "/") health["disks"].append( { "drive": drive, "total_gb": round(usage.total / (1024**3), 1), "free_gb": round(usage.free / (1024**3), 1), "percent_free": round(usage.free / usage.total * 100, 1), } ) except (FileNotFoundError, OSError): pass return health @router.get("/system/notifications") async def get_notifications(): """Recent notifications from the DB.""" if not _db: return {"notifications": []} notes = _db.get_notifications_after(0, limit=100) return {"notifications": notes} @router.get("/system/kv-states") async def get_kv_states(): """All ClickUp task states from KV store.""" if not _db: return {"states": []} pairs = _db.kv_scan("clickup:task:") states = [] for _key, val in pairs: try: state = json.loads(val) states.append(state) except json.JSONDecodeError: pass return {"states": states} @router.post("/cache/clear") async def clear_cache(): """Clear the ClickUp data cache.""" with _cache_lock: _cache.clear() return {"status": "cleared"}