UI now shows a small footer badge with: - release channel + CalVer version (e.g. 'stable-2026.04.47') - floating image tag (e.g. 'stable') - time since last container restart (proxy for 'last deployed') Backend: - app/api/health.py: /api/health returns image_tag, commit_sha, deployed_at - app/api/health.py: new /api/version endpoint (lightweight, no DB hit, for footer badge polling) Infra: - startup-script.sh.tpl: resolves image digest from ghcr pull, derives channel + version from the tag name, and writes AGNES_VERSION / RELEASE_CHANNEL / AGNES_COMMIT_SHA into .env so the app can surface them to the UI. UI: - app/web/templates/base.html: footer loads /api/version asynchronously and renders '<channel>-<version> · <tag> · deployed <relative> (<UTC>)'. Tooltip shows full detail (commit sha, schema version).
100 lines
3.6 KiB
Python
100 lines
3.6 KiB
Python
"""Health check endpoint — structured diagnostics for AI agents."""
|
|
|
|
import os
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends
|
|
import duckdb
|
|
|
|
from app.auth.dependencies import _get_db
|
|
from src.db import SCHEMA_VERSION
|
|
from src.repositories.sync_state import SyncStateRepository
|
|
|
|
router = APIRouter(tags=["health"])
|
|
|
|
# Captured at module import (i.e., app process start) — proxy for "deployed at".
|
|
# When the cron auto-upgrade pulls a new digest and recreates the container,
|
|
# this resets. Accurate enough for a UI "last updated" badge.
|
|
_DEPLOYED_AT = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
@router.get("/api/health")
|
|
async def health_check(conn: duckdb.DuckDBPyConnection = Depends(_get_db)):
|
|
"""Structured health check. No auth required."""
|
|
checks = {}
|
|
|
|
# DuckDB state
|
|
try:
|
|
conn.execute("SELECT 1").fetchone()
|
|
checks["duckdb_state"] = {"status": "ok"}
|
|
except Exception as e:
|
|
checks["duckdb_state"] = {"status": "error", "detail": str(e)}
|
|
|
|
# Sync state summary
|
|
try:
|
|
repo = SyncStateRepository(conn)
|
|
all_states = repo.get_all_states()
|
|
total_tables = len(all_states)
|
|
total_rows = sum(s.get("rows", 0) or 0 for s in all_states)
|
|
stale = []
|
|
now = datetime.now(timezone.utc)
|
|
for s in all_states:
|
|
last = s.get("last_sync")
|
|
if last:
|
|
try:
|
|
# Handle both tz-aware and tz-naive datetimes from DuckDB
|
|
if hasattr(last, 'tzinfo') and last.tzinfo is None:
|
|
from datetime import timezone as tz
|
|
last = last.replace(tzinfo=tz.utc)
|
|
if (now - last).total_seconds() > 86400:
|
|
stale.append(s["table_id"])
|
|
except (TypeError, AttributeError):
|
|
pass # skip if timestamp comparison fails
|
|
checks["data"] = {
|
|
"status": "ok" if not stale else "warning",
|
|
"tables": total_tables,
|
|
"total_rows": total_rows,
|
|
"stale_tables": stale,
|
|
}
|
|
except Exception as e:
|
|
checks["data"] = {"status": "error", "detail": str(e)}
|
|
|
|
# User count
|
|
try:
|
|
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
checks["users"] = {"status": "ok", "count": user_count}
|
|
except Exception as e:
|
|
checks["users"] = {"status": "error", "detail": str(e)}
|
|
|
|
overall = "healthy"
|
|
for check in checks.values():
|
|
if check.get("status") == "error":
|
|
overall = "unhealthy"
|
|
break
|
|
if check.get("status") == "warning":
|
|
overall = "degraded"
|
|
|
|
return {
|
|
"status": overall,
|
|
"version": os.environ.get("AGNES_VERSION", "dev"),
|
|
"channel": os.environ.get("RELEASE_CHANNEL", "dev"),
|
|
"image_tag": os.environ.get("AGNES_TAG", "unknown"),
|
|
"commit_sha": os.environ.get("AGNES_COMMIT_SHA", "unknown"),
|
|
"schema_version": SCHEMA_VERSION,
|
|
"deployed_at": _DEPLOYED_AT,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"services": checks,
|
|
}
|
|
|
|
|
|
@router.get("/api/version")
|
|
async def version_info():
|
|
"""Lightweight version info — cacheable, no DB touch. Used by UI footer badge."""
|
|
return {
|
|
"version": os.environ.get("AGNES_VERSION", "dev"),
|
|
"channel": os.environ.get("RELEASE_CHANNEL", "dev"),
|
|
"image_tag": os.environ.get("AGNES_TAG", "unknown"),
|
|
"commit_sha": os.environ.get("AGNES_COMMIT_SHA", "unknown"),
|
|
"schema_version": SCHEMA_VERSION,
|
|
"deployed_at": _DEPLOYED_AT,
|
|
}
|