Files
study-bible-project/src/components/AdminHealth.jsx
T
nmemmertandClaude Sonnet 5.5 bdb04cdedc
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Add cross-reference map, admin health page, passkeys and device list, settings tabs
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 12:52:49 -04:00

138 lines
7.9 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Admin → Health: is the server in good shape? Disk use, snapshots (make, download, restore) and recent failures.
import { useCallback, useEffect, useState } from 'react';
import { adminBackupNow, adminGetHealth, adminRestoreSnapshot, adminSnapshotUrl } from '../syncService.js';
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4';
export const formatBytes = (n) => {
if (n == null) return 'unknown';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n / 1024; let i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
};
export const formatUptime = (s) => {
if (s < 90) return `${s} seconds`;
if (s < 5400) return `${Math.round(s / 60)} minutes`;
if (s < 129600) return `${Math.round(s / 3600)} hours`;
return `${Math.round(s / 86400)} days`;
};
const ago = (at) => {
const m = Math.round((Date.now() - at) / 60000);
if (m < 2) return 'just now';
if (m < 120) return `${m} minutes ago`;
if (m < 2880) return `${Math.round(m / 60)} hours ago`;
return `${Math.round(m / 1440)} days ago`;
};
function Log({ title, empty, rows }) {
return (
<section className={card}>
<h3 className="text-sm font-medium">{title}</h3>
{rows.length === 0 ? <p className="mt-2 text-xs text-[var(--r-sub)]">{empty}</p> : (
<ul className="mt-2 divide-y divide-[var(--r-line)] text-xs">
{rows.map((e, i) => (
<li key={`${e.at}-${i}`} className="flex flex-wrap justify-between gap-x-3 py-1.5">
<span className="min-w-0 break-words">{e.detail}{e.email && e.kind === 'notify' ? ` (${e.email})` : ''}</span>
<span className="shrink-0 text-[var(--r-sub)]">{ago(e.at)}</span>
</li>
))}
</ul>
)}
</section>
);
}
export default function AdminHealth() {
const [state, setState] = useState({ loading: true, error: '', data: null });
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [restoring, setRestoring] = useState(null); // snapshot name awaiting typed confirmation
const [typed, setTyped] = useState('');
const load = useCallback(async () => {
const r = await adminGetHealth();
setState(r.ok ? { loading: false, error: '', data: r.data } : { loading: false, error: r.error ?? 'Could not read server health.', data: null });
}, []);
useEffect(() => { load(); }, [load]);
const backupNow = async () => {
setBusy(true); setMessage('');
const r = await adminBackupNow();
setBusy(false);
setMessage(r.ok ? 'Snapshot saved.' : r.error ?? 'The snapshot could not be made.');
load();
};
const restore = async () => {
setBusy(true); setMessage('');
const r = await adminRestoreSnapshot(restoring);
setBusy(false);
if (r.ok) { setRestoring(null); setTyped(''); setMessage(`Restored ${r.data.restored}. What was there before is saved as ${r.data.safetyCopy}. You may need to sign in again.`); load(); }
else setMessage(r.error ?? 'The snapshot could not be restored.');
};
if (state.loading) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
if (state.error) return <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>;
const { data } = state;
const { backups, disk } = data;
const stale = backups.enabled && (!backups.latest || Date.now() - backups.latest.at > 36 * 3600 * 1000);
const usedPct = disk.total ? Math.round(((disk.total - disk.free) / disk.total) * 100) : null;
return (
<div className="space-y-4">
<section className={card}>
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div><dt className="text-xs text-[var(--r-sub)]">Running for</dt><dd>{formatUptime(data.uptimeSeconds)}</dd></div>
<div><dt className="text-xs text-[var(--r-sub)]">Database</dt><dd>{formatBytes(disk.db)}</dd></div>
<div><dt className="text-xs text-[var(--r-sub)]">Snapshots</dt><dd>{formatBytes(disk.backups)}</dd></div>
<div><dt className="text-xs text-[var(--r-sub)]">Disk free</dt><dd>{formatBytes(disk.free)}{usedPct != null ? ` (${usedPct}% used)` : ''}</dd></div>
</dl>
<p className="mt-3 text-xs text-[var(--r-sub)]">Node {data.node} · IQ Bible {data.iqConfigured ? 'connected' : 'not set up'} · data in {data.dataDir}</p>
</section>
<section className={card}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium">Snapshots</h3>
<button type="button" onClick={backupNow} disabled={busy} className={btn}>{busy && !restoring ? 'Working…' : 'Back up now'}</button>
</div>
{!backups.enabled && <p className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Automatic snapshots are turned off (BACKUP_DISABLED).</p>}
{stale && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{backups.latest ? `The last snapshot is ${ago(backups.latest.at)}.` : 'There are no snapshots yet.'} One should be made every day.</p>}
{data.backupFailures.length > 0 && <p className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Last failure: {data.backupFailures[0].detail} ({ago(data.backupFailures[0].at)})</p>}
{backups.snapshots.length > 0 && (
<ul className="mt-2 divide-y divide-[var(--r-line)] text-sm">
{backups.snapshots.map((s) => (
<li key={s.name} className="flex flex-wrap items-center justify-between gap-2 py-2">
<span className="min-w-0"><span className="break-all">{s.name}</span> <span className="text-xs text-[var(--r-sub)]">· {formatBytes(s.bytes)} · {ago(s.at)}</span></span>
<span className="flex gap-2">
<a href={adminSnapshotUrl(s.name)} download className={btn}>Download</a>
<button type="button" onClick={() => { setRestoring(s.name); setTyped(''); setMessage(''); }} className={btn}>Restore…</button>
</span>
</li>
))}
</ul>
)}
<p className="mt-2 text-xs text-[var(--r-sub)]">Keeps the newest {backups.keep}. A snapshot holds every account’s data.</p>
{restoring && (
<div role="group" aria-label="Confirm restore" className="mt-3 space-y-2 rounded-xl border border-[var(--r-danger,#b42318)] p-3 text-sm">
<p>This replaces <strong>everyone’s</strong> data with what was saved in <span className="break-all">{restoring}</span>. Anything changed since then is lost, though what’s there now is saved as a new snapshot first. Type the snapshot’s name to go ahead.</p>
<input value={typed} onChange={(e) => setTyped(e.target.value)} aria-label="Snapshot name" placeholder={restoring}
className="w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs" />
<div className="flex gap-2">
<button type="button" onClick={restore} disabled={busy || typed !== restoring} className="rounded-lg bg-[var(--r-danger,#b42318)] px-3 py-1.5 text-xs font-medium text-white disabled:opacity-40">{busy ? 'Restoring…' : 'Restore this snapshot'}</button>
<button type="button" onClick={() => setRestoring(null)} className={btn}>Cancel</button>
</div>
</div>
)}
{message && <p role="status" className="mt-2 text-xs text-[var(--r-sub)]">{message}</p>}
</section>
<Log title="Failed sign-ins" empty="None since the server last started." rows={data.signinFailures} />
<Log title="Failed notifications" empty="None since the server last started." rows={data.notifyFailures} />
<p className="text-xs text-[var(--r-sub)]">The failure lists are kept in memory, so they start over when the server restarts.</p>
<button type="button" onClick={load} className={btn}>Refresh</button>
</div>
);
}