Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- 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>
138 lines
7.9 KiB
React
138 lines
7.9 KiB
React
// 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>
|
||
);
|
||
}
|