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

88
app/api/status.py Normal file
View File

@@ -0,0 +1,88 @@
"""Dashboard status endpoint."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from ..database import get_session
from ..models import Replica, SyncRun
from ..sync.engine import get_progress
router = APIRouter(prefix="/api", tags=["status"])
@router.get("/status")
def get_status(session: Session = Depends(get_session)):
replicas = session.exec(select(Replica)).all()
progress = get_progress()
now = datetime.now(timezone.utc)
replica_data = []
for r in replicas:
lag = None
if r.last_sync_ts:
ts = r.last_sync_ts
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
lag = int((now - ts).total_seconds())
if r.suspended_at:
status = "suspended"
elif progress.running and progress.phase and r.name in progress.phase:
status = "syncing"
elif r.consecutive_failures > 0:
status = "error"
elif r.last_sync_ts:
status = "synced"
else:
status = "pending"
# Last run stats for this replica
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()
replica_data.append(
{
"id": r.id,
"name": r.name,
"url": r.url,
"enabled": r.enabled,
"status": status,
"lag_seconds": lag,
"last_sync_ts": r.last_sync_ts.isoformat() if r.last_sync_ts else None,
"consecutive_failures": r.consecutive_failures,
"suspended": r.suspended_at is not None,
"docs_synced_last_run": last_run.docs_synced if last_run else 0,
"docs_failed_last_run": last_run.docs_failed if last_run else 0,
}
)
last_run = session.exec(
select(SyncRun)
.order_by(SyncRun.started_at.desc()) # type: ignore[attr-defined]
.limit(1)
).first()
return {
"replicas": replica_data,
"sync_progress": {
"running": progress.running,
"phase": progress.phase,
"docs_done": progress.docs_done,
"docs_total": progress.docs_total,
},
"last_sync_run": {
"id": last_run.id,
"started_at": last_run.started_at.isoformat() if last_run and last_run.started_at else None,
"finished_at": last_run.finished_at.isoformat() if last_run and last_run.finished_at else None,
"docs_synced": last_run.docs_synced if last_run else 0,
"docs_failed": last_run.docs_failed if last_run else 0,
"timed_out": last_run.timed_out if last_run else False,
}
if last_run
else None,
}