'use client'; import { useEffect, useState } from 'react'; import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; interface TelemetryStatus { consent: 'pending' | 'on' | 'off'; consentSource: 'env' | 'file'; endpoint: string; defaultEndpoint: string; consentedAt: string | null; lastSentAt: string | null; nextScheduledAt: string | null; payloadPreview: Record; accountCounts: { total: number; active7d: number }; } 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`; const days = Math.floor(h / 24); return `${days} days ago`; } export default function AdminTelemetryPage() { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(null); const [endpointDraft, setEndpointDraft] = useState(''); const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null); async function refresh(): Promise { setLoading(true); try { const r = await apiFetch('/api/admin/telemetry'); if (!r.ok) throw new Error('failed to load'); const data = (await r.json()) as TelemetryStatus; setStatus(data); setEndpointDraft(data.endpoint); } catch (err) { console.error(err); } finally { setLoading(false); } } useEffect(() => { void refresh(); }, []); async function setConsent(consent: 'on' | 'off'): Promise { setBusy('consent'); try { const r = await apiFetch('/api/admin/telemetry', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'set-consent', consent }), }); if (!r.ok) { const j = (await r.json().catch(() => ({}))) as { error?: string }; alert(j.error ?? 'failed'); } await refresh(); } finally { setBusy(null); } } async function saveEndpoint(): Promise { setBusy('endpoint'); try { const r = await apiFetch('/api/admin/telemetry', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }), }); if (!r.ok) { const j = (await r.json().catch(() => ({}))) as { error?: string }; alert(j.error ?? 'failed'); } await refresh(); } finally { setBusy(null); } } async function sendNow(): Promise { setBusy('send'); setSendResult(null); try { const r = await apiFetch('/api/admin/telemetry', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'send-now' }), }); const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string }; setSendResult({ ok: !!j.ok, msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`, }); await refresh(); } finally { setBusy(null); } } if (loading || !status) { return (
loading…
); } const envOverridden = status.consentSource === 'env'; const isOn = status.consent === 'on'; return (

Anonymous Usage Stats

Bulwark sends one anonymous heartbeat per day so we can see how many instances are running, on what platforms, and which features they use. Enabled by default; one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '} Full schema and policy

Status
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'} {status.consent === 'on' && 'Heartbeats are enabled (default).'} {status.consent === 'off' && 'Heartbeats are off.'} {envOverridden && ( <> Locked by BULWARK_TELEMETRY env var. )}
Last sent
{timeAgo(status.lastSentAt)}
Next scheduled
{timeAgo(status.nextScheduledAt)}
Consented at
{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}
Account activity

Unique accounts that have logged in over the last 90 days. Identities are stored as a per-instance HMAC, never as plaintext usernames. These are the numbers reported in the heartbeat as bucketed ranges.

Total (90d)
{status.accountCounts?.total ?? 0}
Active (7d)
{status.accountCounts?.active7d ?? 0}
Endpoint

Where heartbeats are sent. Defaults to the project's collector. Point at your own collector (open source at bulwarkmail/dashboard) or clear this field to disable sending.

setEndpointDraft(e.target.value)} placeholder={status.defaultEndpoint} className="flex-1 px-3 py-1.5 rounded-md border bg-background" />
Payload preview
Exactly what the next heartbeat would send from this install, right now.
{sendResult && (
{sendResult.ok ? : } {sendResult.msg}
)}
          {JSON.stringify(status.payloadPreview, null, 2)}
        
); }