'use client'; import { useEffect, useState } from 'react'; import { Loader2, RefreshCw, CheckCircle2, AlertTriangle, ShieldAlert, ExternalLink, } from 'lucide-react'; import { SettingsSection, SettingItem } from '@/components/settings/settings-section'; import { apiFetch } from '@/lib/browser-navigation'; import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types'; interface VersionAdminStatus { current: string; build: string; endpoint: string; defaultEndpoint: string; disabledByEnv: boolean; lastCheckedAt: string | null; lastSuccessAt: string | null; nextScheduledAt: string | null; status: UpdateStatus | null; } function timeAgo(iso: string | null): string { if (!iso) return 'never'; const d = Date.now() - new Date(iso).getTime(); if (d < 0) return new Date(iso).toLocaleString(); const m = Math.floor(d / 60000); if (m < 1) return 'just now'; if (m < 60) return `${m} min ago`; const h = Math.floor(m / 60); if (h < 48) return `${h} hours ago`; return `${Math.floor(h / 24)} days ago`; } function severityChip(severity: UpdateSeverity) { switch (severity) { case 'security': return { label: 'Security update', className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30', Icon: ShieldAlert, }; case 'deprecated': return { label: 'Deprecated', className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30', Icon: ShieldAlert, }; case 'normal': return { label: 'Update available', className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30', Icon: AlertTriangle, }; case 'unknown': return { label: 'Unknown', className: 'bg-muted text-muted-foreground border-border', Icon: AlertTriangle, }; case 'none': default: return { label: 'Up to date', className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30', Icon: CheckCircle2, }; } } export function VersionTab() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [checking, setChecking] = useState(false); const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null); async function refresh(): Promise { setLoading(true); try { const r = await apiFetch('/api/admin/version'); if (!r.ok) throw new Error('failed to load'); setData((await r.json()) as VersionAdminStatus); } catch (err) { console.error(err); } finally { setLoading(false); } } useEffect(() => { void refresh(); }, []); async function checkNow(): Promise { setChecking(true); setCheckResult(null); try { const r = await apiFetch('/api/admin/version', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'check-now' }), }); const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string }; setCheckResult({ ok: !!j.ok, msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`, }); await refresh(); } finally { setChecking(false); } } if (loading || !data) { return (
loading…
); } const status = data.status; const chip = severityChip(status?.severity ?? 'none'); const ChipIcon = chip.Icon; const releaseUrl = status?.url ?? null; const newer = status?.latest && status.latest !== data.current ? status.latest : null; return (

Version

Hourly check against the Bulwark version server. Severity is decided server-side and disable with BULWARK_UPDATE_CHECK=off.

{checkResult && (
{checkResult.msg}
)} {chip.label} {data.current} {newer && ( {releaseUrl ? ( {newer} ) : ( {newer} )} )} {status?.advisory && ( {status.advisory} )} {timeAgo(data.lastCheckedAt)} {timeAgo(data.lastSuccessAt)} {timeAgo(data.nextScheduledAt)} {status?.checkedAt && ( {new Date(status.checkedAt).toLocaleString()} )} {data.endpoint} {data.disabledByEnv ? 'Yes' : 'No'}
); }