feat: implement pngx-controller with Gitea CI/CD deployment
All checks were successful
Deploy / deploy (push) Successful in 30s

- Full FastAPI sync engine: master→replica document sync via paperless REST API
- Web UI: dashboard, replicas, logs, settings (Jinja2 + HTMX + Pico CSS)
- APScheduler background sync, SSE live log stream, Prometheus metrics
- Fernet encryption for API tokens at rest
- pngx.env credential file: written on save, pre-fills forms on load
- Dockerfile with layer-cached uv build, Python healthcheck
- docker-compose with host networking for Tailscale access
- Gitea Actions workflow: version bump, secret injection, docker compose deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 17:59:25 +01:00
parent 942482daab
commit b99dbf694d
40 changed files with 4184 additions and 0 deletions

0
app/ui/__init__.py Normal file
View File

204
app/ui/routes.py Normal file
View File

@@ -0,0 +1,204 @@
"""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,
},
)