266 lines
27 KiB
TypeScript
266 lines
27 KiB
TypeScript
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 />);
|