feat: add update-available detection

This commit is contained in:
Linus Rath
2026-05-02 01:58:30 +02:00
parent 599fa66822
commit 5319562c94
16 changed files with 969 additions and 49 deletions
+27 -4
View File
@@ -15,6 +15,7 @@ import {
Puzzle,
SwatchBook,
Activity,
Package,
Mail,
Calendar,
BookUser,
@@ -30,6 +31,7 @@ import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch } from '@/lib/browser-navigation';
const NAV_GROUPS = [
@@ -59,6 +61,7 @@ const NAV_GROUPS = [
{
label: 'System',
items: [
{ href: '/admin/version', label: 'Version', icon: Package },
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
],
@@ -78,6 +81,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
// Match the navigation rail: red for security/deprecated, amber for normal.
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
useEffect(() => {
setMobileNavOpen(false);
}, [pathname]);
@@ -170,6 +180,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</div>
{group.items.map(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
const showDot = href === '/admin/version' && hasUpdate;
return (
<Link
key={href}
@@ -181,10 +192,22 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
<span className="relative shrink-0">
<Icon className={cn(
'w-4 h-4',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{showDot && (
<span
className={cn(
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
active ? 'ring-accent' : 'ring-background',
updateImportant ? 'bg-red-500' : 'bg-amber-500',
)}
aria-label={updateImportant ? 'Important update available' : 'Update available'}
/>
)}
</span>
{label}
</Link>
);
+237
View File
@@ -0,0 +1,237 @@
'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 default function AdminVersionPage() {
const [data, setData] = useState<VersionAdminStatus | null>(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<void> {
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<void> {
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 (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading
</div>
);
}
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 (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
<p className="text-sm text-muted-foreground mt-1">
Hourly check against the Bulwark version server. Severity is decided server-side and
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
</p>
</div>
<button
type="button"
disabled={checking}
onClick={() => void checkNow()}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Check now
</button>
</div>
{checkResult && (
<div
className={`text-sm rounded-md px-3 py-2 ${
checkResult.ok
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'bg-destructive/10 text-destructive'
}`}
>
{checkResult.msg}
</div>
)}
<SettingsSection title="Status">
<SettingItem label="Severity">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
>
<ChipIcon className="h-3 w-3" />
{chip.label}
</span>
</SettingItem>
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
<span className="text-sm font-mono text-foreground">{data.current}</span>
</SettingItem>
{newer && (
<SettingItem label="Latest release">
{releaseUrl ? (
<a
href={releaseUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
>
{newer} <ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-sm font-mono text-foreground">{newer}</span>
)}
</SettingItem>
)}
{status?.advisory && (
<SettingItem label="Advisory">
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
<SettingItem label="Last checked">
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
</SettingItem>
<SettingItem label="Last success">
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
</SettingItem>
<SettingItem label="Next scheduled">
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
</SettingItem>
{status?.checkedAt && (
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Source">
<SettingItem
label="Endpoint"
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
>
<a
href={data.endpoint}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
>
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
</SettingItem>
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
{data.disabledByEnv ? 'Yes' : 'No'}
</span>
</SettingItem>
</SettingsSection>
</div>
);
}