Files
backup-manager/web/src/main.tsx
2026-09-04 23:59:11 +02:00

266 lines
27 KiB
TypeScript
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.

import { FormEvent, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
type Tab = 'overview' | 'hosts' | 'jobs' | 'settings';
type Host = { id: number; name: string; hostname: string; port: number; username: string; fingerprint: string; authType: string };
type StepType = 'dockerCommand' | 'remoteCommand' | 'directory' | 'postgres' | 'mysql';
type StepDraft = {
id: string; name: string; type: StepType; continueOnError: boolean; timeoutSeconds: string;
executable: string; arguments: string; workingDirectory: string; container: string; dockerUser: string;
collectOutput: boolean; outputPath: string; outputName: string; archive: boolean; directoryPath: string;
database: string; databaseUsername: string; databaseHost: string; databasePort: string; databasePassword: string;
};
type Job = { id: number; name: string; hostName: string; config: { steps: Array<{ type: string }> }; schedule?: string; nextRunAt?: string; retentionCount: number };
type Run = { id: number; jobName: string; status: string; trigger: string; createdAt: string; startedAt?: string; finishedAt?: string; error?: string; artifactCount: number };
type RunDetail = { run: { id: number; status: string; log: string; error?: string }; artifacts: Array<{ id: number; name: string; size: number; checksum: string }> };
async function api<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
headers: { 'content-type': 'application/json', ...options?.headers },
});
if (response.status === 204) return undefined as T;
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Request failed');
return body as T;
}
function Login({ onLogin }: { onLogin: () => void }) {
const [error, setError] = useState('');
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError('');
const password = String(new FormData(event.currentTarget).get('password'));
try {
await api('/api/login', { method: 'POST', body: JSON.stringify({ password }) });
onLogin();
} catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)); }
}
return <main className="login-shell">
<section className="login-card">
<div className="mark">BM</div>
<p className="eyebrow">HOME LAB OPERATIONS</p>
<h1>Backup Manager</h1>
<p className="muted">Secure remote jobs. Local, verifiable artifacts.</p>
<form onSubmit={submit}>
<label>Administrator password<input name="password" type="password" autoFocus required /></label>
{error && <p className="error">{error}</p>}
<button type="submit">Unlock console</button>
</form>
</section>
</main>;
}
function App() {
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [tab, setTab] = useState<Tab>('overview');
useEffect(() => { void api<{ authenticated: boolean }>('/api/session').then((value) => setAuthenticated(value.authenticated)); }, []);
if (authenticated === null) return <div className="loading">Starting console...</div>;
if (!authenticated) return <Login onLogin={() => setAuthenticated(true)} />;
return <div className="app-shell">
<aside>
<div className="brand"><span className="mark small">BM</span><div><strong>Backup</strong><small>MANAGER</small></div></div>
<nav>
{(['overview', 'hosts', 'jobs', 'settings'] as Tab[]).map((item) => <button className={tab === item ? 'active' : ''} onClick={() => setTab(item)} key={item}><span>{item === 'overview' ? '01' : item === 'hosts' ? '02' : item === 'jobs' ? '03' : '04'}</span>{item}</button>)}
</nav>
<button className="logout" onClick={() => void api('/api/logout', { method: 'POST' }).then(() => setAuthenticated(false))}>Lock console</button>
</aside>
<main className="content">
{tab === 'overview' && <Overview />}
{tab === 'hosts' && <Hosts />}
{tab === 'jobs' && <Jobs />}
{tab === 'settings' && <Settings />}
</main>
</div>;
}
function PageTitle({ eyebrow, title, detail }: { eyebrow: string; title: string; detail: string }) {
return <header className="page-title"><p className="eyebrow">{eyebrow}</p><h1>{title}</h1><p>{detail}</p></header>;
}
function Overview() {
const [runs, setRuns] = useState<Run[]>([]);
const [detail, setDetail] = useState<RunDetail>();
async function load() { setRuns(await api<Run[]>('/api/runs')); }
useEffect(() => {
void load();
const timer = setInterval(() => void load(), 5_000);
return () => clearInterval(timer);
}, []);
const active = runs.filter((run) => run.status === 'running' || run.status === 'queued').length;
const failed = runs.filter((run) => run.status === 'failed').length;
const successful = runs.filter((run) => run.status === 'succeeded' || run.status === 'succeeded_with_warnings').length;
return <>
<PageTitle eyebrow="OPERATIONS / LIVE" title="Run overview" detail="Recent backup activity across every remote system." />
<section className="metrics">
<Metric label="Active" value={active} accent />
<Metric label="Successful" value={successful} />
<Metric label="Failed" value={failed} warning={failed > 0} />
<Metric label="Artifacts" value={runs.reduce((sum, run) => sum + run.artifactCount, 0)} />
</section>
<section className="panel"><div className="panel-head"><h2>Run history</h2><span>latest 100</span></div>
<div className="table-wrap"><table><thead><tr><th>Run</th><th>Job</th><th>Trigger</th><th>Status</th><th>Started</th><th /></tr></thead>
<tbody>{runs.map((run) => <tr key={run.id}><td className="mono">#{run.id}</td><td>{run.jobName}</td><td>{run.trigger}</td><td><Status value={run.status} /></td><td>{formatDate(run.startedAt ?? run.createdAt)}</td><td><button className="text-button" onClick={() => void api<RunDetail>(`/api/runs/${run.id}`).then(setDetail)}>inspect</button></td></tr>)}</tbody>
</table>{runs.length === 0 && <Empty text="No runs yet. Create a job and trigger the first backup." />}</div>
</section>
{detail && <div className="modal-backdrop" onMouseDown={() => setDetail(undefined)}><section className="modal" onMouseDown={(event) => event.stopPropagation()}><div className="panel-head"><div><p className="eyebrow">RUN #{detail.run.id}</p><h2>Execution detail</h2></div><button className="close" onClick={() => setDetail(undefined)}>Close</button></div><Status value={detail.run.status} />{detail.run.error && <p className="error">{detail.run.error}</p>}<pre>{detail.run.log || 'Waiting for worker...'}</pre><h3>Artifacts</h3>{detail.artifacts.map((artifact) => <a className="artifact" href={`/api/artifacts/${artifact.id}/download`} key={artifact.id}><span>{artifact.name}<small>{artifact.checksum}</small></span><strong>{formatBytes(artifact.size)} </strong></a>)}</section></div>}
</>;
}
function Metric({ label, value, accent, warning }: { label: string; value: number; accent?: boolean; warning?: boolean }) {
return <div className={`metric ${accent ? 'accent' : ''} ${warning ? 'warning' : ''}`}><span>{label}</span><strong>{String(value).padStart(2, '0')}</strong></div>;
}
function Status({ value }: { value: string }) { return <span className={`status ${value}`}>{value}<i /></span>; }
function Hosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [authType, setAuthType] = useState('password');
const [message, setMessage] = useState('');
async function load() { setHosts(await api<Host[]>('/api/hosts')); }
useEffect(() => { void load(); }, []);
async function probe(form: HTMLFormElement) {
const data = new FormData(form);
setMessage('Reading remote fingerprint...');
try {
const result = await api<{ fingerprint: string }>('/api/hosts/probe', { method: 'POST', body: JSON.stringify({ hostname: data.get('hostname'), port: Number(data.get('port')) }) });
const input = form.elements.namedItem('fingerprint') as HTMLInputElement;
input.value = result.fingerprint;
setMessage('Fingerprint received. Verify it against the server before saving.');
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
try {
await api('/api/hosts', { method: 'POST', body: JSON.stringify({
name: data.get('name'), hostname: data.get('hostname'), port: Number(data.get('port')), username: data.get('username'),
fingerprint: data.get('fingerprint'), authType, password: data.get('password') || undefined,
privateKey: data.get('privateKey') || undefined, passphrase: data.get('passphrase') || undefined,
}) });
form.reset(); setMessage('Host saved.'); await load();
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
}
return <>
<PageTitle eyebrow="INVENTORY / SSH" title="Remote hosts" detail="Pinned connections used to execute and retrieve backups." />
<div className="split"><section className="panel form-panel"><div className="panel-head"><h2>Add host</h2><span>encrypted at rest</span></div><form onSubmit={submit}>
<div className="field-row"><label>Display name<input name="name" required placeholder="media-node" /></label><label>SSH username<input name="username" required placeholder="backup" /></label></div>
<div className="field-row wide"><label>Hostname or IP<input name="hostname" required placeholder="192.168.1.20" /></label><label className="port">Port<input name="port" type="number" defaultValue="22" required /></label></div>
<label>Host-key fingerprint<div className="input-action"><input name="fingerprint" required placeholder="SHA256:..." /><button type="button" onClick={(event) => void probe(event.currentTarget.form!)}>Probe</button></div></label>
<div className="segmented"><button type="button" className={authType === 'password' ? 'selected' : ''} onClick={() => setAuthType('password')}>Password</button><button type="button" className={authType === 'privateKey' ? 'selected' : ''} onClick={() => setAuthType('privateKey')}>Private key</button></div>
{authType === 'password' ? <label>Password<input name="password" type="password" required /></label> : <><label>PEM private key<textarea name="privateKey" rows={7} required placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /></label><label>Key passphrase <small>optional</small><input name="passphrase" type="password" /></label></>}
{message && <p className="form-message">{message}</p>}<button type="submit">Save encrypted host</button>
</form></section>
<section className="panel"><div className="panel-head"><h2>Known hosts</h2><span>{hosts.length} configured</span></div>{hosts.map((host) => <article className="host-card" key={host.id}><div className="host-icon">{host.name.slice(0, 2).toUpperCase()}</div><div><h3>{host.name}</h3><p className="mono">{host.username}@{host.hostname}:{host.port}</p><small>{host.authType === 'privateKey' ? 'PRIVATE KEY' : 'PASSWORD'} · {host.fingerprint}</small></div><div className="card-actions"><button onClick={() => void api(`/api/hosts/${host.id}/test`, { method: 'POST' }).then(() => setMessage(`${host.name}: connection successful`)).catch((error) => setMessage(error.message))}>Test</button><button className="danger" onClick={() => confirm(`Delete ${host.name}?`) && void api(`/api/hosts/${host.id}`, { method: 'DELETE' }).then(load).catch((error) => setMessage(error.message))}>Delete</button></div></article>)}{hosts.length === 0 && <Empty text="No remote hosts configured." />}</section></div>
</>;
}
function Jobs() {
const [jobs, setJobs] = useState<Job[]>([]);
const [hosts, setHosts] = useState<Host[]>([]);
const [addType, setAddType] = useState<StepType>('dockerCommand');
const [steps, setSteps] = useState<StepDraft[]>([createStep('dockerCommand')]);
const [message, setMessage] = useState('');
async function load() { const [jobData, hostData] = await Promise.all([api<Job[]>('/api/jobs'), api<Host[]>('/api/hosts')]); setJobs(jobData); setHosts(hostData); }
useEffect(() => { void load(); }, []);
function updateStep(id: string, values: Partial<StepDraft>) { setSteps((current) => current.map((step) => step.id === id ? { ...step, ...values } : step)); }
function moveStep(index: number, direction: number) {
const target = index + direction;
if (target < 0 || target >= steps.length) return;
setSteps((current) => { const next = [...current]; [next[index], next[target]] = [next[target]!, next[index]!]; return next; });
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const stepSecrets: Record<string, string> = {};
const configuredSteps = steps.map((step) => {
const base = { id: step.id, name: step.name, type: step.type, continueOnError: step.continueOnError, timeoutSeconds: Number(step.timeoutSeconds) };
const outputs = step.collectOutput ? [{ path: step.outputPath, name: step.outputName, archive: step.archive }] : [];
if (step.type === 'dockerCommand') return { ...base, container: step.container, executable: step.executable, arguments: parseArguments(step.arguments), user: step.dockerUser || undefined, workingDirectory: step.workingDirectory || undefined, outputs };
if (step.type === 'remoteCommand') return { ...base, executable: step.executable, arguments: parseArguments(step.arguments), workingDirectory: step.workingDirectory || undefined, outputs };
if (step.type === 'directory') return { ...base, path: step.directoryPath, outputName: step.outputName };
if (step.databasePassword) stepSecrets[step.id] = step.databasePassword;
return { ...base, database: step.database, username: step.databaseUsername, databaseHost: step.databaseHost, databasePort: Number(step.databasePort), outputName: step.outputName };
});
try {
await api('/api/jobs', { method: 'POST', body: JSON.stringify({
name: data.get('name'), hostId: Number(data.get('hostId')), config: { steps: configuredSteps }, stepSecrets,
schedule: data.get('schedule') || undefined,
timezone: data.get('timezone'), enabled: true, retentionCount: Number(data.get('retentionCount')),
}) });
form.reset(); setSteps([createStep('dockerCommand')]); setMessage('Job created.'); await load();
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
}
return <>
<PageTitle eyebrow="AUTOMATION / JOBS" title="Backup definitions" detail="Ordered remote operations with explicit outputs and failure policy." />
<div className="split jobs-split"><section className="panel form-panel"><div className="panel-head"><h2>New job</h2><span>runs remotely</span></div>
{hosts.length === 0 ? <Empty text="Add an SSH host before creating a job." /> : <form onSubmit={submit}>
<div className="field-row"><label>Job name<input name="name" required placeholder="immich-db" /></label><label>Remote host<select name="hostId" required>{hosts.map((host) => <option value={host.id} key={host.id}>{host.name}</option>)}</select></label></div>
<div className="step-list">{steps.map((step, index) => <StepEditor step={step} index={index} count={steps.length} update={updateStep} move={moveStep} remove={(id) => setSteps((current) => current.filter((item) => item.id !== id))} key={step.id} />)}</div>
<div className="add-step"><select value={addType} onChange={(event) => setAddType(event.target.value as StepType)}><option value="dockerCommand">Docker command</option><option value="remoteCommand">Remote command</option><option value="directory">Directory archive</option><option value="postgres">PostgreSQL dump</option><option value="mysql">MySQL dump</option></select><button type="button" onClick={() => setSteps((current) => [...current, createStep(addType)])}>+ Add step</button></div>
<fieldset><legend>Job policy</legend><div className="field-row"><label>Cron schedule <small>optional</small><input name="schedule" placeholder="0 2 * * *" /></label><label>Timezone<input name="timezone" required defaultValue="UTC" /></label></div><label>Successful runs to retain<input name="retentionCount" type="number" min="1" defaultValue="10" required /></label></fieldset>
{message && <p className="form-message">{message}</p>}<button type="submit">Create backup job</button>
</form>}
</section><section className="panel"><div className="panel-head"><h2>Configured jobs</h2><span>{jobs.length} total</span></div>{jobs.map((job) => <article className="job-card" key={job.id}><div><span className="job-type">{job.config.steps.length} step{job.config.steps.length === 1 ? '' : 's'}</span><h3>{job.name}</h3><p>{job.hostName} · keep {job.retentionCount}</p><small>{job.schedule ? `${job.schedule} · next ${formatDate(job.nextRunAt)}` : 'MANUAL ONLY'}</small></div><div className="card-actions"><button className="run" onClick={() => void api<{ runId: number }>(`/api/jobs/${job.id}/run`, { method: 'POST' }).then((value) => setMessage(`Run #${value.runId} queued`)).catch((error) => setMessage(error.message))}>Run now</button><button className="danger" onClick={() => confirm(`Delete ${job.name} and its run history?`) && void api(`/api/jobs/${job.id}`, { method: 'DELETE' }).then(load).catch((error) => setMessage(error.message))}>Delete</button></div></article>)}{jobs.length === 0 && <Empty text="No backup jobs defined." />}</section></div>
</>;
}
function StepEditor({ step, index, count, update, move, remove }: { step: StepDraft; index: number; count: number; update: (id: string, values: Partial<StepDraft>) => void; move: (index: number, direction: number) => void; remove: (id: string) => void }) {
const set = (values: Partial<StepDraft>) => update(step.id, values);
const command = step.type === 'dockerCommand' || step.type === 'remoteCommand';
return <fieldset className="step-card"><legend>Step {index + 1}</legend><div className="step-head"><input aria-label="Step name" value={step.name} onChange={(event) => set({ name: event.target.value })} required /><span className="job-type">{stepTypeLabel(step.type)}</span><div className="step-actions"><button type="button" disabled={index === 0} onClick={() => move(index, -1)}></button><button type="button" disabled={index === count - 1} onClick={() => move(index, 1)}></button><button type="button" disabled={count === 1} onClick={() => remove(step.id)}>×</button></div></div>
{step.type === 'dockerCommand' && <><div className="field-row"><label>Container<input value={step.container} onChange={(event) => set({ container: event.target.value })} required placeholder="immich_server" /></label><label>Executable<input value={step.executable} onChange={(event) => set({ executable: event.target.value })} required placeholder="/app/backup" /></label></div><div className="field-row"><label>Container user <small>optional</small><input value={step.dockerUser} onChange={(event) => set({ dockerUser: event.target.value })} /></label><label>Working directory <small>optional</small><input value={step.workingDirectory} onChange={(event) => set({ workingDirectory: event.target.value })} placeholder="/app" /></label></div></>}
{step.type === 'remoteCommand' && <><label>Executable<input value={step.executable} onChange={(event) => set({ executable: event.target.value })} required placeholder="/usr/local/bin/prepare-backup" /></label><label>Working directory <small>optional</small><input value={step.workingDirectory} onChange={(event) => set({ workingDirectory: event.target.value })} placeholder="/srv/app" /></label></>}
{command && <><label>Arguments <small>one argument per line</small><textarea value={step.arguments} onChange={(event) => set({ arguments: event.target.value })} rows={3} placeholder={'--output\n/tmp/export'} /></label><label className="check"><input type="checkbox" checked={step.collectOutput} onChange={(event) => set({ collectOutput: event.target.checked })} /> Collect an output from this command</label>{step.collectOutput && <><div className="field-row"><label>{step.type === 'dockerCommand' ? 'Container' : 'Remote'} output path<input value={step.outputPath} onChange={(event) => set({ outputPath: event.target.value })} required placeholder="/tmp/export" /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="export" /></label></div><label className="check"><input type="checkbox" checked={step.archive} onChange={(event) => set({ archive: event.target.checked })} /> Archive output as tar.gz</label></>}</>}
{step.type === 'directory' && <div className="field-row"><label>Absolute remote path<input value={step.directoryPath} onChange={(event) => set({ directoryPath: event.target.value })} required placeholder="/srv/documents" /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="documents" /></label></div>}
{(step.type === 'postgres' || step.type === 'mysql') && <><div className="field-row"><label>Database host<input value={step.databaseHost} onChange={(event) => set({ databaseHost: event.target.value })} required /></label><label>Port<input value={step.databasePort} onChange={(event) => set({ databasePort: event.target.value })} type="number" required /></label></div><div className="field-row"><label>Database<input value={step.database} onChange={(event) => set({ database: event.target.value })} required /></label><label>Username<input value={step.databaseUsername} onChange={(event) => set({ databaseUsername: event.target.value })} required /></label></div><div className="field-row"><label>Password<input value={step.databasePassword} onChange={(event) => set({ databasePassword: event.target.value })} type="password" required /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="database" /></label></div></>}
<div className="field-row step-policy"><label>Timeout (seconds)<input value={step.timeoutSeconds} onChange={(event) => set({ timeoutSeconds: event.target.value })} type="number" min="1" required /></label><label className="check"><input type="checkbox" checked={step.continueOnError} onChange={(event) => set({ continueOnError: event.target.checked })} /> Continue if this step fails</label></div>
</fieldset>;
}
function createStep(type: StepType): StepDraft {
const id = crypto.randomUUID();
return { id, type, name: stepTypeLabel(type), continueOnError: false, timeoutSeconds: '3600', executable: '', arguments: '', workingDirectory: '', container: '', dockerUser: '', collectOutput: type === 'dockerCommand', outputPath: '', outputName: '', archive: true, directoryPath: '', database: '', databaseUsername: '', databaseHost: 'localhost', databasePort: type === 'mysql' ? '3306' : '5432', databasePassword: '' };
}
function stepTypeLabel(type: StepType): string { return { dockerCommand: 'Docker command', remoteCommand: 'Remote command', directory: 'Directory archive', postgres: 'PostgreSQL dump', mysql: 'MySQL dump' }[type]; }
function parseArguments(value: string): string[] { return value.split('\n').map((argument) => argument.trim()).filter(Boolean); }
function Settings() {
const [message, setMessage] = useState('');
useEffect(() => { void api<Record<string, unknown>>('/api/settings/notifications').then((data) => {
const form = document.querySelector<HTMLFormElement>('#notification-form');
if (!form) return;
const setField = (name: string, value: unknown) => {
const element = form.elements.namedItem(name) as HTMLInputElement | null;
if (!element) return;
if (element.type === 'checkbox') element.checked = Boolean(value);
else element.value = String(value ?? '');
};
setField('webhookUrl', data.webhookUrl);
setField('notifySuccess', data.notifySuccess);
setField('notifyFailure', data.notifyFailure);
const smtp = data.smtp as Record<string, unknown> | undefined;
setField('smtpEnabled', Boolean(smtp));
if (smtp) for (const [key, value] of Object.entries(smtp)) setField(`smtp${key[0]!.toUpperCase()}${key.slice(1)}`, value);
}); }, []);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const data = new FormData(event.currentTarget);
const smtpEnabled = data.get('smtpEnabled') === 'on';
try { await api('/api/settings/notifications', { method: 'PUT', body: JSON.stringify({
webhookUrl: data.get('webhookUrl') || '', notifySuccess: data.get('notifySuccess') === 'on', notifyFailure: data.get('notifyFailure') === 'on',
smtp: smtpEnabled ? { host: data.get('smtpHost'), port: Number(data.get('smtpPort')), secure: data.get('smtpSecure') === 'on', username: data.get('smtpUsername') || undefined, password: data.get('smtpPassword') || undefined, from: data.get('smtpFrom'), to: data.get('smtpTo') } : undefined,
}) }); setMessage('Notification settings saved.'); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
}
return <><PageTitle eyebrow="SYSTEM / DELIVERY" title="Notifications" detail="Send a concise result after remote jobs complete." /><section className="panel form-panel settings-panel"><form id="notification-form" onSubmit={submit}><fieldset><legend>Events</legend><label className="check"><input name="notifyFailure" type="checkbox" defaultChecked /> Notify when a backup fails</label><label className="check"><input name="notifySuccess" type="checkbox" /> Notify when a backup succeeds</label></fieldset><fieldset><legend>Webhook</legend><label>Endpoint URL<input name="webhookUrl" type="url" placeholder="https://hooks.example/backup" /></label></fieldset><fieldset><legend>SMTP email</legend><label className="check"><input name="smtpEnabled" type="checkbox" /> Enable SMTP delivery</label><div className="field-row wide"><label>SMTP host<input name="smtpHost" /></label><label className="port">Port<input name="smtpPort" type="number" defaultValue="587" /></label></div><label className="check"><input name="smtpSecure" type="checkbox" /> Use implicit TLS</label><div className="field-row"><label>Username<input name="smtpUsername" /></label><label>Password<input name="smtpPassword" type="password" /></label></div><div className="field-row"><label>From<input name="smtpFrom" type="email" /></label><label>To<input name="smtpTo" type="email" /></label></div></fieldset>{message && <p className="form-message">{message}</p>}<button type="submit">Save settings</button></form></section></>;
}
function Empty({ text }: { text: string }) { return <div className="empty">{text}</div>; }
function formatDate(value?: string) { return value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) : '—'; }
function formatBytes(value: number) { return value < 1024 * 1024 ? `${(value / 1024).toFixed(1)} KB` : `${(value / 1024 / 1024).toFixed(1)} MB`; }
createRoot(document.getElementById('root')!).render(<App />);