""" Watcher Manager — admin API for series3-watcher and thor-watcher agents. Endpoints: GET /api/admin/watchers — list all watcher agents GET /api/admin/watchers/{agent_id} — get single agent detail POST /api/admin/watchers/{agent_id}/trigger-update — flag agent for update POST /api/admin/watchers/{agent_id}/clear-update — clear update flag GET /api/admin/watchers/{agent_id}/update-check — polled by watcher on heartbeat Page: GET /admin/watchers — HTML admin page """ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel from sqlalchemy.orm import Session from typing import Optional from backend.database import get_db from backend.models import WatcherAgent from backend.templates_config import templates router = APIRouter(tags=["admin"]) # ── helpers ────────────────────────────────────────────────────────────────── def _agent_to_dict(agent: WatcherAgent) -> dict: last_seen = agent.last_seen if last_seen: # Compute age in minutes (last_seen stored as UTC naive) now_utc = datetime.utcnow() age_minutes = int((now_utc - last_seen).total_seconds() // 60) else: age_minutes = None return { "id": agent.id, "source_type": agent.source_type, "version": agent.version, "last_seen": last_seen.isoformat() if last_seen else None, "age_minutes": age_minutes, "status": agent.status, "ip_address": agent.ip_address, "log_tail": agent.log_tail, "update_pending": bool(agent.update_pending), "update_version": agent.update_version, } # ── API routes ──────────────────────────────────────────────────────────────── @router.get("/api/admin/watchers") def list_watchers(db: Session = Depends(get_db)): agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all() return [_agent_to_dict(a) for a in agents] @router.get("/api/admin/watchers/{agent_id}") def get_watcher(agent_id: str, db: Session = Depends(get_db)): agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first() if not agent: raise HTTPException(status_code=404, detail="Watcher agent not found") return _agent_to_dict(agent) class TriggerUpdateRequest(BaseModel): version: Optional[str] = None # target version label (informational) @router.post("/api/admin/watchers/{agent_id}/trigger-update") def trigger_update(agent_id: str, body: TriggerUpdateRequest, db: Session = Depends(get_db)): agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first() if not agent: raise HTTPException(status_code=404, detail="Watcher agent not found") agent.update_pending = True agent.update_version = body.version db.commit() return {"ok": True, "agent_id": agent_id, "update_pending": True} @router.post("/api/admin/watchers/{agent_id}/clear-update") def clear_update(agent_id: str, db: Session = Depends(get_db)): agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first() if not agent: raise HTTPException(status_code=404, detail="Watcher agent not found") agent.update_pending = False agent.update_version = None db.commit() return {"ok": True, "agent_id": agent_id, "update_pending": False} @router.get("/api/admin/watchers/{agent_id}/update-check") def update_check(agent_id: str, db: Session = Depends(get_db)): """ Polled by watcher agents on each heartbeat cycle. Returns update_available=True when an update has been triggered via the UI. Automatically clears the flag after the watcher acknowledges it. """ agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first() if not agent: return {"update_available": False} pending = bool(agent.update_pending) if pending: # Clear the flag — the watcher will now self-update agent.update_pending = False db.commit() return { "update_available": pending, "version": agent.update_version, } # ── HTML page ───────────────────────────────────────────────────────────────── @router.get("/admin/watchers", response_class=HTMLResponse) def admin_watchers_page(request: Request, db: Session = Depends(get_db)): agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all() agents_data = [_agent_to_dict(a) for a in agents] return templates.TemplateResponse("admin_watchers.html", { "request": request, "agents": agents_data, })