diff --git a/backend/main.py b/backend/main.py index ee55012..0cff847 100644 --- a/backend/main.py +++ b/backend/main.py @@ -102,6 +102,9 @@ app.include_router(modem_dashboard.router) from backend.routers import settings app.include_router(settings.router) +from backend.routers import watcher_manager +app.include_router(watcher_manager.router) + # Projects system routers app.include_router(projects.router) app.include_router(project_locations.router) diff --git a/backend/models.py b/backend/models.py index 9abee13..3567a54 100644 --- a/backend/models.py +++ b/backend/models.py @@ -66,6 +66,26 @@ class RosterUnit(Base): slm_last_check = Column(DateTime, nullable=True) # Last communication check +class WatcherAgent(Base): + """ + Watcher agents: tracks the watcher processes (series3-watcher, thor-watcher) + that run on field machines and report unit heartbeats. + + Updated on every heartbeat received from each source_id. + """ + __tablename__ = "watcher_agents" + + id = Column(String, primary_key=True, index=True) # source_id (hostname) + source_type = Column(String, nullable=False) # series3_watcher | series4_watcher + version = Column(String, nullable=True) # e.g. "1.4.0" + last_seen = Column(DateTime, default=datetime.utcnow) + status = Column(String, nullable=False, default="unknown") # ok | pending | missing | error | unknown + ip_address = Column(String, nullable=True) + log_tail = Column(Text, nullable=True) # last N log lines (JSON array of strings) + update_pending = Column(Boolean, default=False) # set True to trigger remote update + update_version = Column(String, nullable=True) # target version to update to + + class IgnoredUnit(Base): """ Ignored units: units that report but should be filtered out from unknown emitters. diff --git a/backend/routers/watcher_manager.py b/backend/routers/watcher_manager.py new file mode 100644 index 0000000..a42c34b --- /dev/null +++ b/backend/routers/watcher_manager.py @@ -0,0 +1,129 @@ +""" +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, + }) diff --git a/backend/routes.py b/backend/routes.py index 2c6cd8f..2af0897 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Optional, List from backend.database import get_db -from backend.models import Emitter +from backend.models import Emitter, WatcherAgent router = APIRouter() @@ -107,6 +107,35 @@ def get_fleet_status(db: Session = Depends(get_db)): emitters = db.query(Emitter).all() return emitters +# ── Watcher agent upsert helper ─────────────────────────────────────────────── + +def _upsert_watcher_agent(db: Session, source_id: str, source_type: str, + version: str, ip_address: str, log_tail: str, + status: str) -> None: + """Create or update the WatcherAgent row for a given source_id.""" + agent = db.query(WatcherAgent).filter(WatcherAgent.id == source_id).first() + if agent: + agent.source_type = source_type + agent.version = version + agent.last_seen = datetime.utcnow() + agent.status = status + if ip_address: + agent.ip_address = ip_address + if log_tail is not None: + agent.log_tail = log_tail + else: + agent = WatcherAgent( + id=source_id, + source_type=source_type, + version=version, + last_seen=datetime.utcnow(), + status=status, + ip_address=ip_address, + log_tail=log_tail, + ) + db.add(agent) + + # series3v1.1 Standardized Heartbeat Schema (multi-unit) from fastapi import Request @@ -120,6 +149,11 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)): source = payload.get("source_id") units = payload.get("units", []) + version = payload.get("version") + log_tail = payload.get("log_tail") # list of strings or None + import json as _json + log_tail_str = _json.dumps(log_tail) if log_tail is not None else None + client_ip = request.client.host if request.client else None print("\n=== Series 3 Heartbeat ===") print("Source:", source) @@ -182,13 +216,38 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)): results.append({"unit": uid, "status": status}) + # Determine overall worst status for the watcher agent row + statuses = [r["status"] for r in results] + if "Missing" in statuses: + agent_status = "missing" + elif "Pending" in statuses: + agent_status = "pending" + elif statuses: + agent_status = "ok" + else: + agent_status = "unknown" + + if source: + _upsert_watcher_agent(db, source, "series3_watcher", version, + client_ip, log_tail_str, agent_status) + db.commit() + # Check if an update has been triggered for this agent + update_available = False + if source: + agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first() + if agent and agent.update_pending: + update_available = True + agent.update_pending = False + db.commit() + return { "message": "Heartbeat processed", "source": source, "units_processed": len(results), - "results": results + "results": results, + "update_available": update_available, } @@ -221,6 +280,11 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)): source = payload.get("source", "series4_emitter") units = payload.get("units", []) + version = payload.get("version") + log_tail = payload.get("log_tail") + import json as _json + log_tail_str = _json.dumps(log_tail) if log_tail is not None else None + client_ip = request.client.host if request.client else None print("\n=== Series 4 Heartbeat ===") print("Source:", source) @@ -276,11 +340,36 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)): results.append({"unit": uid, "status": status}) + # Determine overall worst status for the watcher agent row + statuses = [r["status"] for r in results] + if any(s.lower() == "stale" for s in statuses): + agent_status = "missing" + elif any(s.lower() == "late" for s in statuses): + agent_status = "pending" + elif statuses: + agent_status = "ok" + else: + agent_status = "unknown" + + if source: + _upsert_watcher_agent(db, source, "series4_watcher", version, + client_ip, log_tail_str, agent_status) + db.commit() + # Check if an update has been triggered for this agent + update_available = False + if source: + agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first() + if agent and agent.update_pending: + update_available = True + agent.update_pending = False + db.commit() + return { "message": "Heartbeat processed", "source": source, "units_processed": len(results), - "results": results + "results": results, + "update_available": update_available, } diff --git a/backend/templates_config.py b/backend/templates_config.py index 453b284..d1c7360 100644 --- a/backend/templates_config.py +++ b/backend/templates_config.py @@ -60,6 +60,19 @@ def jinja_same_date(dt1, dt2) -> bool: return False +def jinja_log_tail_display(s): + """Jinja filter: decode a JSON-encoded log tail array into a plain-text string.""" + if not s: + return "" + try: + lines = _json.loads(s) + if isinstance(lines, list): + return "\n".join(str(l) for l in lines) + return str(s) + except Exception: + return str(s) + + # Register Jinja filters and globals templates.env.filters["local_datetime"] = jinja_local_datetime templates.env.filters["local_time"] = jinja_local_time @@ -68,3 +81,4 @@ templates.env.filters["fromjson"] = jinja_fromjson templates.env.globals["timezone_abbr"] = jinja_timezone_abbr templates.env.globals["get_user_timezone"] = get_user_timezone templates.env.globals["same_date"] = jinja_same_date +templates.env.filters["log_tail_display"] = jinja_log_tail_display diff --git a/migrate_watcher_agents.py b/migrate_watcher_agents.py new file mode 100644 index 0000000..ebd2325 --- /dev/null +++ b/migrate_watcher_agents.py @@ -0,0 +1,37 @@ +""" +Migration: add watcher_agents table. + +Safe to run multiple times (idempotent). +""" + +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "data", "seismo.db") + + +def migrate(): + con = sqlite3.connect(DB_PATH) + cur = con.cursor() + + cur.execute(""" + CREATE TABLE IF NOT EXISTS watcher_agents ( + id TEXT PRIMARY KEY, + source_type TEXT NOT NULL, + version TEXT, + last_seen DATETIME, + status TEXT NOT NULL DEFAULT 'unknown', + ip_address TEXT, + log_tail TEXT, + update_pending INTEGER NOT NULL DEFAULT 0, + update_version TEXT + ) + """) + + con.commit() + con.close() + print("Migration complete: watcher_agents table ready.") + + +if __name__ == "__main__": + migrate() diff --git a/templates/admin_watchers.html b/templates/admin_watchers.html new file mode 100644 index 0000000..2fa418f --- /dev/null +++ b/templates/admin_watchers.html @@ -0,0 +1,273 @@ +{% extends "base.html" %} + +{% block title %}Watcher Manager — Admin{% endblock %} + +{% block content %} +
+
+

Watcher Manager

+ Admin +
+

+ Monitor and manage field watcher agents. Data updates on each heartbeat received. +

+
+ + +
+ +{% if not agents %} +
+ + + +

No watcher agents have reported in yet.

+

Once a watcher sends its first heartbeat it will appear here.

+
+{% endif %} + +{% for agent in agents %} +
+ + +
+
+ + {% if agent.status == 'ok' %} + + {% elif agent.status == 'pending' %} + + {% elif agent.status in ('missing', 'error') %} + + {% else %} + + {% endif %} + +
+

{{ agent.id }}

+
+ {{ agent.source_type }} + {% if agent.version %} + v{{ agent.version }} + {% endif %} + {% if agent.ip_address %} + {{ agent.ip_address }} + {% endif %} +
+
+
+ +
+ + {% if agent.status == 'ok' %} + OK + {% elif agent.status == 'pending' %} + Pending + {% elif agent.status == 'missing' %} + Missing + {% elif agent.status == 'error' %} + Error + {% else %} + Unknown + {% endif %} + + + +
+
+ + +
+
+ Last seen + + {% if agent.last_seen %} + {{ agent.last_seen }} + {% if agent.age_minutes is not none %} + ({{ agent.age_minutes }}m ago) + {% endif %} + {% else %} + Never + {% endif %} + +
+
+ + + + Update pending — will apply on next heartbeat +
+
+ + + {% if agent.log_tail %} +
+
+ Log Tail +
+ + +
+
+ +
+ {% else %} +
No log data received yet.
+ {% endif %} + +
+{% endfor %} + +
+ + +
+ Auto-refreshes every 30 seconds — or refresh now +
+ + +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html index 84cb830..c5d1c82 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -41,6 +41,12 @@ Danger Zone + @@ -514,6 +520,32 @@ + + +