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,