feat(ui): version badge in footer + /api/version endpoint

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).
This commit is contained in:
ZdenekSrotyr 2026-04-21 20:19:40 +02:00
parent cdd959b19f
commit b091cf7003
3 changed files with 67 additions and 0 deletions

View file

@ -12,6 +12,11 @@ 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)):
@ -73,7 +78,23 @@ async def health_check(conn: duckdb.DuckDBPyConnection = Depends(_get_db)):
"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,
}

View file

@ -46,7 +46,31 @@
<footer>
<p>&copy; {{ now().year if now is defined else 2024 }} {{ config.INSTANCE_COPYRIGHT or 'AI Data Analyst' }}</p>
<p class="version-badge" style="font-size: 0.75rem; color: var(--muted, #888); margin-top: 0.5rem;">
<span id="agnes-version-badge">Loading version…</span>
</p>
</footer>
</div>
<script>
// Version badge — fetched from /api/version, no auth needed.
(function() {
fetch('/api/version').then(r => r.ok ? r.json() : null).then(v => {
if (!v) return;
const el = document.getElementById('agnes-version-badge');
if (!el) return;
const deployed = new Date(v.deployed_at);
const relative = (() => {
const s = Math.floor((Date.now() - deployed.getTime()) / 1000);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s/60) + 'm ago';
if (s < 86400) return Math.floor(s/3600) + 'h ago';
return Math.floor(s/86400) + 'd ago';
})();
const tag = v.image_tag && v.image_tag !== 'unknown' ? ' · ' + v.image_tag : '';
el.textContent = `${v.channel}-${v.version}${tag} · deployed ${relative} (${deployed.toISOString().slice(0,19).replace('T',' ')}Z)`;
el.title = `version ${v.version}\nchannel ${v.channel}\nimage tag ${v.image_tag}\ncommit ${v.commit_sha}\nschema v${v.schema_version}\ndeployed at ${v.deployed_at}`;
}).catch(() => {});
})();
</script>
</body>
</html>

View file

@ -68,6 +68,25 @@ if [ "$DATA_SOURCE" = "keboola" ]; then
fi
JWT_KEY=$(gcloud secrets versions access latest --secret=agnes-$${CUSTOMER_NAME}-jwt-secret)
# Resolve the actual version/commit behind the requested tag so the UI can
# show specific `stable-2026.04.47` + commit SHA instead of just `stable`.
IMAGE_DIGEST=$(docker pull "$IMAGE_REPO:$IMAGE_TAG" 2>/dev/null | grep -o 'sha256:[a-f0-9]*' | head -1 || echo "unknown")
IMAGE_INFO=$(curl -fsSL "https://ghcr.io/v2/keboola/agnes-the-ai-analyst/manifests/$IMAGE_TAG" -H "Accept: application/vnd.oci.image.manifest.v1+json" 2>/dev/null || echo "{}")
# Channel derived from tag prefix (stable-*/dev-*/release-*) — simple heuristic.
case "$IMAGE_TAG" in
stable*) RELEASE_CHANNEL="stable" ;;
dev*) RELEASE_CHANNEL="dev" ;;
release*) RELEASE_CHANNEL="release" ;;
*) RELEASE_CHANNEL="custom" ;;
esac
# Version extracted from versioned tags (stable-2026.04.N); floating tags stay "dev".
case "$IMAGE_TAG" in
*-[0-9]*.[0-9]*.[0-9]*) AGNES_VERSION="$${IMAGE_TAG#*-}" ;;
*) AGNES_VERSION="$IMAGE_TAG" ;;
esac
cat > "$APP_DIR/.env" <<ENVEOF
JWT_SECRET_KEY=$JWT_KEY
DATA_DIR=$DATA_MNT
@ -78,6 +97,9 @@ SEED_ADMIN_EMAIL=$SEED_ADMIN_EMAIL
LOG_LEVEL=info
DOMAIN=$DOMAIN
AGNES_TAG=$IMAGE_TAG
AGNES_VERSION=$AGNES_VERSION
RELEASE_CHANNEL=$RELEASE_CHANNEL
AGNES_COMMIT_SHA=$IMAGE_DIGEST
ACME_EMAIL=$ACME_EMAIL
ENVEOF
chmod 600 "$APP_DIR/.env"