"""Jinja2 HTML routes for the web UI."""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
from ..database import get_session
from ..models import Log, Replica, SyncRun
from ..sync.engine import get_progress
router = APIRouter(tags=["ui"])
templates: Optional[Jinja2Templates] = None
def setup_templates(t: Jinja2Templates) -> None:
global templates
templates = t
def _tmpl(name: str, request: Request, ctx: dict) -> HTMLResponse:
assert templates is not None
return templates.TemplateResponse(name, {"request": request, **ctx})
def _lag_str(ts: datetime | None) -> str:
if not ts:
return "never"
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
seconds = int((datetime.now(timezone.utc) - ts).total_seconds())
if seconds < 120:
return f"{seconds}s ago"
if seconds < 7200:
return f"{seconds // 60}m ago"
if seconds < 172800:
return f"{seconds // 3600}h ago"
return f"{seconds // 86400}d ago"
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, session: Session = Depends(get_session)):
replicas = session.exec(select(Replica)).all()
now = datetime.now(timezone.utc)
progress = get_progress()
replica_rows = []
for r in replicas:
last_run = session.exec(
select(SyncRun)
.where(SyncRun.replica_id == r.id)
.order_by(SyncRun.started_at.desc()) # type: ignore[attr-defined]
.limit(1)
).first()
if r.suspended_at:
status = "suspended"
elif progress.running and r.name in (progress.phase or ""):
status = "syncing"
elif r.consecutive_failures > 0:
status = "error"
elif r.last_sync_ts:
status = "synced"
else:
status = "pending"
replica_rows.append(
{
"replica": r,
"status": status,
"lag": _lag_str(r.last_sync_ts),
"last_run": last_run,
}
)
last_run = session.exec(
select(SyncRun)
.order_by(SyncRun.started_at.desc()) # type: ignore[attr-defined]
.limit(1)
).first()
return _tmpl(
"dashboard.html",
request,
{
"replica_rows": replica_rows,
"last_run": last_run,
"progress": progress,
},
)
@router.get("/replicas", response_class=HTMLResponse)
def replicas_page(request: Request, session: Session = Depends(get_session)):
from .. import envfile
replicas = session.exec(select(Replica).order_by(Replica.created_at)).all() # type: ignore[attr-defined]
env = envfile.read()
# Find env-defined replicas not yet in DB (keyed by name)
existing_names = {r.name.upper().replace(" ", "_").replace("-", "_") for r in replicas}
env_replicas: list[dict] = []
seen: set[str] = set()
for key, value in env.items():
if key.startswith("REPLICA_") and key.endswith("_URL"):
safe = key[len("REPLICA_"):-len("_URL")]
if safe not in existing_names and safe not in seen:
token_key = f"REPLICA_{safe}_TOKEN"
env_replicas.append({"safe": safe, "url": value, "token": env.get(token_key, "")})
seen.add(safe)
return _tmpl("replicas.html", request, {"replicas": replicas, "env_replicas": env_replicas})
@router.get("/replicas/{replica_id}", response_class=HTMLResponse)
def replica_detail(
replica_id: int, request: Request, session: Session = Depends(get_session)
):
replica = session.get(Replica, replica_id)
if not replica:
return HTMLResponse("Not found", status_code=404)
recent_runs = session.exec(
select(SyncRun)
.where(SyncRun.replica_id == replica_id)
.order_by(SyncRun.started_at.desc()) # type: ignore[attr-defined]
.limit(20)
).all()
from ..models import SyncMap
sync_map_page = session.exec(
select(SyncMap)
.where(SyncMap.replica_id == replica_id)
.order_by(SyncMap.last_synced.desc()) # type: ignore[attr-defined]
.limit(50)
).all()
return _tmpl(
"replica_detail.html",
request,
{
"replica": replica,
"recent_runs": recent_runs,
"sync_map_page": sync_map_page,
"lag": _lag_str(replica.last_sync_ts),
},
)
@router.get("/logs", response_class=HTMLResponse)
def logs_page(request: Request, session: Session = Depends(get_session)):
replicas = session.exec(select(Replica)).all()
recent_logs = session.exec(
select(Log)
.order_by(Log.created_at.desc()) # type: ignore[attr-defined]
.limit(100)
).all()
return _tmpl(
"logs.html",
request,
{"replicas": replicas, "logs": recent_logs},
)
@router.get("/settings", response_class=HTMLResponse)
def settings_page(request: Request, session: Session = Depends(get_session)):
from .. import envfile
from ..models import Setting
from ..scheduler import SETTINGS_DEFAULTS
rows = session.exec(select(Setting)).all()
settings = dict(SETTINGS_DEFAULTS)
for row in rows:
if row.value is not None:
settings[row.key] = row.value
env = envfile.read()
# Fallback master_url from env if not saved in DB
if not settings.get("master_url") and env.get("MASTER_URL"):
settings["master_url"] = env["MASTER_URL"]
# Mask DB secrets; pass env token for pre-fill only if DB has none
db_has_token = bool(settings.get("master_token"))
db_has_alert_token = bool(settings.get("alert_target_token"))
for k in ("master_token", "alert_target_token"):
if settings.get(k):
settings[k] = "••••••••"
env_master_token = env.get("MASTER_TOKEN", "") if not db_has_token else ""
env_alert_token = env.get("ALERT_TARGET_TOKEN", "") if not db_has_alert_token else ""
return _tmpl(
"settings.html",
request,
{
"settings": settings,
"env_master_token": env_master_token,
"env_alert_token": env_alert_token,
},
)