From 7b171cc5e3a785b05e35b7b85f44dbdf82638c95 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 7 Jun 2026 17:46:31 +0200
Subject: [PATCH] feat(admin): surface the running version on the overview
Add a dedicated version tile to the admin overview showing the running
version, short build SHA, and build timestamp, alongside a best-effort
'update available' hint computed against the latest GitHub release tag. The
hint reuses the existing /api/version/check-updates proxy (server-side
safeFetch past the CSP) so no new route or OpenAPI schema is introduced; the
check is day-stale and never blocks the render. Cookie-admin only by virtue of
the admin layout.
---
messages/de.json | 6 +
messages/en.json | 6 +
messages/es.json | 6 +
messages/fr.json | 6 +
messages/it.json | 6 +
messages/pl.json | 6 +
src/app/admin/page.tsx | 3 +
src/components/admin/version-tile-section.tsx | 131 ++++++++++++++++++
src/lib/query-keys.ts | 3 +
9 files changed, 173 insertions(+)
create mode 100644 src/components/admin/version-tile-section.tsx
diff --git a/messages/de.json b/messages/de.json
index 61b7ab6ba..938d2b68e 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4242,6 +4242,12 @@
"snapshotOfflineGeo": "Geodaten",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
+ "versionTileTitle": "Version",
+ "versionTileVersion": "Laufende Version",
+ "versionTileBuildSha": "Build-SHA",
+ "versionTileBuiltAt": "Gebaut am",
+ "versionTileUpdateAvailable": "Update verfügbar: {tag}",
+ "versionTileUpToDate": "Aktuell",
"snapshotLoadError": "System-Snapshot konnte nicht geladen werden.",
"snapshotLoading": "Snapshot wird geladen...",
"auditTitle": "Letzte Aktivitäten",
diff --git a/messages/en.json b/messages/en.json
index 5831a4c4d..5bc4757a7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4242,6 +4242,12 @@
"snapshotOfflineGeo": "Geo data",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
+ "versionTileTitle": "Version",
+ "versionTileVersion": "Running version",
+ "versionTileBuildSha": "Build SHA",
+ "versionTileBuiltAt": "Built at",
+ "versionTileUpdateAvailable": "Update available: {tag}",
+ "versionTileUpToDate": "Up to date",
"snapshotLoadError": "Failed to load system snapshot.",
"snapshotLoading": "Loading snapshot...",
"auditTitle": "Recent activity",
diff --git a/messages/es.json b/messages/es.json
index 53b7eb981..ff1ca9845 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4242,6 +4242,12 @@
"snapshotOfflineGeo": "Datos geográficos",
"snapshotOfflineGeoOn": "Sin conexión (GeoLite2)",
"snapshotOfflineGeoOff": "Respaldo (ipwho.is)",
+ "versionTileTitle": "Versión",
+ "versionTileVersion": "Versión en ejecución",
+ "versionTileBuildSha": "SHA de compilación",
+ "versionTileBuiltAt": "Compilado el",
+ "versionTileUpdateAvailable": "Actualización disponible: {tag}",
+ "versionTileUpToDate": "Actualizado",
"snapshotLoadError": "No se pudo cargar la instantánea del sistema.",
"snapshotLoading": "Cargando instantánea...",
"auditTitle": "Actividad reciente",
diff --git a/messages/fr.json b/messages/fr.json
index 0cd4dc2be..c227b4220 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4242,6 +4242,12 @@
"snapshotOfflineGeo": "Données géographiques",
"snapshotOfflineGeoOn": "Hors ligne (GeoLite2)",
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
+ "versionTileTitle": "Version",
+ "versionTileVersion": "Version en cours",
+ "versionTileBuildSha": "SHA de build",
+ "versionTileBuiltAt": "Compilé le",
+ "versionTileUpdateAvailable": "Mise à jour disponible : {tag}",
+ "versionTileUpToDate": "À jour",
"snapshotLoadError": "Échec du chargement de l'instantané du système.",
"snapshotLoading": "Chargement de l'instantané...",
"auditTitle": "Activité récente",
diff --git a/messages/it.json b/messages/it.json
index 1c4676998..b3db3d7f8 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4242,6 +4242,12 @@
"snapshotOfflineGeo": "Dati geografici",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Fallback (ipwho.is)",
+ "versionTileTitle": "Versione",
+ "versionTileVersion": "Versione in esecuzione",
+ "versionTileBuildSha": "SHA build",
+ "versionTileBuiltAt": "Compilato il",
+ "versionTileUpdateAvailable": "Aggiornamento disponibile: {tag}",
+ "versionTileUpToDate": "Aggiornato",
"snapshotLoadError": "Impossibile caricare l'istantanea del sistema.",
"snapshotLoading": "Caricamento istantanea...",
"auditTitle": "Attività recente",
diff --git a/messages/pl.json b/messages/pl.json
index 7af88ae4b..968a0a35d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4242,6 +4242,12 @@
"snapshotOfflineGeo": "Dane geograficzne",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Awaryjne (ipwho.is)",
+ "versionTileTitle": "Wersja",
+ "versionTileVersion": "Uruchomiona wersja",
+ "versionTileBuildSha": "SHA kompilacji",
+ "versionTileBuiltAt": "Skompilowano",
+ "versionTileUpdateAvailable": "Dostępna aktualizacja: {tag}",
+ "versionTileUpToDate": "Aktualne",
"snapshotLoadError": "Nie udało się załadować migawki systemu.",
"snapshotLoading": "Ładowanie migawki...",
"auditTitle": "Ostatnia aktywność",
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 457a942ef..f9ec57245 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -4,6 +4,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { AdminShell } from "@/components/admin/admin-shell";
import { SystemStatusSummary } from "@/components/admin/system-status-summary";
+import { VersionTileSection } from "@/components/admin/version-tile-section";
import { RecentAuditPreview } from "@/components/admin/recent-audit-preview";
import { useAdminSettings } from "@/components/admin/_shared";
@@ -61,6 +62,8 @@ export default function AdminOverviewPage() {
+
+
diff --git a/src/components/admin/version-tile-section.tsx b/src/components/admin/version-tile-section.tsx
new file mode 100644
index 000000000..8c02a49df
--- /dev/null
+++ b/src/components/admin/version-tile-section.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+/**
+ * `` — admin-overview version card.
+ *
+ * v1.15.12 H1 — a dedicated tile that surfaces the running build's
+ * version, short build SHA, and build timestamp, plus a best-effort
+ * "update available" hint computed against the latest GitHub release
+ * tag. The release comparison reuses the existing
+ * `GET /api/version/check-updates` proxy (server-side `safeFetch` past
+ * the CSP), so no new route or OpenAPI schema is introduced. The check
+ * is day-stale (`staleTime`) and never blocks the render: on any
+ * failure the tile simply omits the update hint.
+ *
+ * Cookie-admin only by construction — it lives under the admin layout
+ * (`/admin`), which gates on `requireAdmin()`.
+ */
+
+import { ArrowUpCircle, CheckCircle2, Clock, GitCommit, Tag } from "lucide-react";
+import { useQuery } from "@tanstack/react-query";
+import { formatDateTime } from "@/lib/format";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+import { StatusItem, usePublicVersion } from "./_shared";
+
+type CheckUpdatesResult =
+ | {
+ status: "up_to_date";
+ current: string;
+ latest_tag: string;
+ checked_at: string;
+ }
+ | {
+ status: "newer_available";
+ current: string;
+ latest_tag: string;
+ html_url: string | null;
+ published_at: string | null;
+ checked_at: string;
+ }
+ | { status: "unknown"; current: string; reason: string };
+
+async function fetchUpdateCheck(): Promise {
+ const res = await fetch("/api/version/check-updates");
+ if (!res.ok) {
+ return { status: "unknown", current: "", reason: `http_${res.status}` };
+ }
+ return (await res.json()).data as CheckUpdatesResult;
+}
+
+function shortSha(sha: string | null): string | null {
+ if (!sha) return null;
+ return sha.length > 7 ? sha.slice(0, 7) : sha;
+}
+
+export function VersionTileSection() {
+ const { t } = useTranslations();
+ const { data: version } = usePublicVersion();
+
+ // Best-effort, day-stale, never throws into the render. The route is
+ // auth-gated and proxies the GitHub Releases API through the server so
+ // the production CSP doesn't block the call.
+ const { data: update } = useQuery({
+ queryKey: queryKeys.versionUpdateCheck(),
+ queryFn: fetchUpdateCheck,
+ staleTime: 24 * 60 * 60_000,
+ retry: false,
+ });
+
+ const newer = update?.status === "newer_available";
+ const upToDate = update?.status === "up_to_date";
+ const sha = shortSha(version?.buildSha ?? null);
+
+ return (
+
+ );
+}
diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts
index 5dbc5662c..18d414d8e 100644
--- a/src/lib/query-keys.ts
+++ b/src/lib/query-keys.ts
@@ -297,6 +297,9 @@ export const queryKeys = {
apiVersion: () => ["api", "version"] as const,
publicVersion: () => ["public", "version"] as const,
+ /** v1.15.12 H1 — admin overview "update available" check against the
+ * latest GitHub release tag (best-effort, day-stale). */
+ versionUpdateCheck: () => ["version", "update-check"] as const,
researchMode: () => ["research-mode"] as const,
moodlogStatus: () => ["moodlog-status"] as const,
integrationsStatus: () => ["integrations", "status"] as const,