dorfteich/apps/web/src/pages/AdminBackupSection.tsx
Claude Fable 5 52192eb05f
All checks were successful
CD / Build and push images (push) Successful in 3m51s
CI / Lint, typecheck, test (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 11s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 12s
CI / Auth e2e pack (push) Successful in 5m52s
CI / Import/export fidelity gate (push) Successful in 47s
Backup mirror to BASEL: rsync of the sets after every successful run (#84)
The operator-level extra beside the admin-configured Nextcloud target
(#103), unblocked now that the ONE→BASEL tunnel is stable again.

- sidecar: optional mirror step (mirror.ts) driven purely by env —
  BACKUP_MIRROR_TARGET (rsync-over-ssh), BACKUP_MIRROR_SSH_KEY (private
  key on the secrets volume, never in image or repo),
  BACKUP_MIRROR_SSH_PORT. Runs after the prune of every successful run,
  so --delete aligns the remote retention with the local one (the
  newest-complete-set guarantee carries over). Only set files travel
  (db-*.dump, files-*.tar.gz); status files and bundles stay local.
  Host key pinned via accept-new into .mirror_known_hosts on the backups
  volume; fixed remote modes (dirs 750, files 640, symbolic --chmod —
  octal needs rsync ≥ 3, macOS dev machines ship 2.6.9). rsync +
  openssh-client added to the sidecar image.
- status: additive `mirror` block in status.json (outcome, transferred
  count, lastSuccessAt carried across failures) — shown on the admin
  backup card; failures alert via a new backupMirrorFailed mail (de+en)
  while the local run still counts as succeeded.
- deploy/backup-basel.md: complete BASEL-side walkthrough — dedicated
  user dorfteich-backup with a /home/ home and a bash login shell,
  explicitly avoiding the Debian backup-user (UID 34) pitfalls
  (nologin shell rejects rsync sessions, /var/backups home), key
  placement through the api container onto the secrets volume, .env
  values, on-demand verification.
- tests: rsync-arg/stats-parsing units plus an integration suite against
  the real rsync binary (local target; skips where rsync is absent) —
  transfer, idempotent re-run (0 files), retention alignment, failure
  path carrying lastSuccessAt.

Verified live against the real BASEL host from a native sidecar run:
initial transfer, host-key pinning, retention alignment after a local
prune, idempotency, and the failure path (surfaced in status.json while
the local run stayed green). BASEL side provisioned per the doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 12:20:32 +02:00

522 lines
18 KiB
TypeScript

import type {
BackupConnectionTestResult,
BackupSetView,
BackupSetsView,
BackupSettingsInput,
BackupSettingsView,
SystemBackupView,
} from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { formatBytes } from '../files/file-format';
import { ApiError, apiGet, apiPost, apiPut } from '../lib/api';
const BACKUP_QUERY_KEY = ['admin', 'system', 'backup'] as const;
/**
* The backup area of the Site-Admin system panel (issue #86 card extended
* by issue #103): status card with a manual trigger, the Nextcloud target
* configuration, and the in-app restore with a type-to-confirm gate.
*/
export function BackupSection(): React.JSX.Element {
const { t } = useTranslation('system');
const query = useQuery({
queryKey: BACKUP_QUERY_KEY,
queryFn: () => apiGet<SystemBackupView>('/admin/system/backup'),
// A requested run/restore progresses in the sidecar — keep the card live.
refetchInterval: 15_000,
});
const view = query.data;
if (!view) return <></>;
return (
<section className="settings-section system-backup">
<h2>{t('backup.title')}</h2>
<StatusCard view={view} />
<BackupSettingsForm />
<RestorePicker restore={view.restore} />
</section>
);
}
function StatusCard({ view }: { view: SystemBackupView }): React.JSX.Element {
const { t } = useTranslation('system');
const queryClient = useQueryClient();
const [runState, setRunState] = useState<'idle' | 'pending' | 'requested'>('idle');
const runNow = async (): Promise<void> => {
setRunState('pending');
try {
await apiPost('/admin/system/backup/run', {});
setRunState('requested');
} catch {
setRunState('idle');
} finally {
await queryClient.invalidateQueries({ queryKey: BACKUP_QUERY_KEY });
}
};
return (
<div className="system-backup__status">
{!view.available && <p className="system-backup__unavailable">{t('backup.unavailable')}</p>}
{view.available && view.status && (
<>
<p>
<span
className={`system-badge ${view.fresh ? 'system-badge--ok' : 'system-badge--error'}`}
>
{view.fresh ? t('backup.fresh') : t('backup.stale', { hours: view.maxAgeHours })}
</span>
</p>
<dl className="system-backup__facts">
<dt>{t('backup.lastSuccess')}</dt>
<dd>
{view.status.lastSuccess
? `${new Date(view.status.lastSuccess.finishedAt).toLocaleString()} (${view.status.lastSuccess.backupId}) — ${t(
'backup.sizes',
{
dump: formatBytes(view.status.lastSuccess.sizes.dumpBytes),
archive: formatBytes(view.status.lastSuccess.sizes.archiveBytes),
},
)}`
: t('backup.never')}
</dd>
<dt>{t('backup.lastRun')}</dt>
<dd>
{new Date(view.status.lastRun.finishedAt).toLocaleString()} {' '}
{t(`backup.outcome.${view.status.lastRun.outcome}`)}
</dd>
{view.status.lastRun.error && (
<>
<dt>{t('backup.error')}</dt>
<dd>
<code>{view.status.lastRun.error}</code>
</dd>
</>
)}
<dt>{t('backup.remote.title')}</dt>
<dd>
{!view.remoteConfigured && t('backup.remote.notConfigured')}
{view.remoteConfigured && (
<>
{t('backup.remote.lastSuccessfulUpload')}:{' '}
{view.status.remote?.lastSuccessfulUpload
? `${new Date(
view.status.remote.lastSuccessfulUpload.finishedAt,
).toLocaleString()} (${view.status.remote.lastSuccessfulUpload.backupId}, ${t(
'backup.remote.bundleSize',
{
size: formatBytes(view.status.remote.lastSuccessfulUpload.sizeBytes),
},
)})`
: t('backup.never')}
{view.status.remote?.lastUpload.outcome === 'failed' && (
<>
{' — '}
<code>{view.status.remote.lastUpload.error}</code>
</>
)}
</>
)}
</dd>
{view.status.mirror && (
<>
<dt>{t('backup.mirror.title')}</dt>
<dd>
{t('backup.mirror.lastSuccess')}:{' '}
{view.status.mirror.lastSuccessAt
? new Date(view.status.mirror.lastSuccessAt).toLocaleString()
: t('backup.never')}
{view.status.mirror.lastRun.outcome === 'failed' && (
<>
{' — '}
{t('backup.mirror.failed')}: <code>{view.status.mirror.lastRun.error}</code>
</>
)}
</dd>
</>
)}
</dl>
<p className="system-backup__retention">
{t('backup.retention', { days: view.status.retentionDays })}
</p>
</>
)}
<p>
<button
type="button"
className="button system-backup__run"
disabled={runState === 'pending'}
onClick={() => void runNow()}
>
{runState === 'pending' ? t('backup.runPending') : t('backup.runNow')}
</button>
</p>
{runState === 'requested' && <p role="status">{t('backup.runRequested')}</p>}
</div>
);
}
interface SettingsDraft {
localRetentionDays: string;
remoteRetentionDays: string;
enabled: boolean;
baseUrl: string;
username: string;
password: string;
folder: string;
uploadSchedule: 'off' | 'daily' | 'weekly';
}
function toDraft(view: BackupSettingsView): SettingsDraft {
return {
localRetentionDays: view.localRetentionDays === null ? '' : String(view.localRetentionDays),
remoteRetentionDays: String(view.remoteRetentionDays),
enabled: view.nextcloud.enabled,
baseUrl: view.nextcloud.baseUrl,
username: view.nextcloud.username,
password: '',
folder: view.nextcloud.folder,
uploadSchedule: view.nextcloud.uploadSchedule,
};
}
function toInput(draft: SettingsDraft): BackupSettingsInput {
return {
localRetentionDays: draft.localRetentionDays.trim() ? Number(draft.localRetentionDays) : null,
remoteRetentionDays: Number(draft.remoteRetentionDays) || 30,
nextcloud: {
enabled: draft.enabled,
baseUrl: draft.baseUrl.trim(),
username: draft.username.trim(),
folder: draft.folder.trim() || 'dorfteich-backups',
uploadSchedule: draft.uploadSchedule,
...(draft.password ? { password: draft.password } : {}),
},
};
}
function BackupSettingsForm(): React.JSX.Element {
const { t } = useTranslation('system');
const { t: tErrors } = useTranslation('errors');
const queryClient = useQueryClient();
const [draft, setDraft] = useState<SettingsDraft | null>(null);
const [notice, setNotice] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
const [busy, setBusy] = useState<'test' | 'save' | null>(null);
const query = useQuery({
queryKey: ['admin', 'system', 'backup', 'settings'],
queryFn: () => apiGet<BackupSettingsView>('/admin/system/backup/settings'),
});
const view = query.data;
if (!view) return <></>;
const form = draft ?? toDraft(view);
const update = (patch: Partial<SettingsDraft>): void => setDraft({ ...form, ...patch });
const describeError = (error: unknown): string => {
if (error instanceof ApiError) {
const detail = (error.body.details as { nextcloud?: string[] } | undefined)?.nextcloud?.[0];
const translated = tErrors(error.body.code, { defaultValue: error.body.code });
return detail ? `${translated} ${detail}` : translated;
}
return String(error);
};
const test = async (): Promise<void> => {
setBusy('test');
setNotice(null);
try {
const result = await apiPost<BackupConnectionTestResult>(
'/admin/system/backup/nextcloud/test',
{
baseUrl: form.baseUrl.trim(),
username: form.username.trim(),
folder: form.folder.trim() || 'dorfteich-backups',
...(form.password ? { password: form.password } : {}),
},
);
setNotice(
result.ok
? { kind: 'ok', text: t('backup.settings.testOk') }
: { kind: 'error', text: result.error ?? t('backup.settings.test') },
);
} catch (error) {
setNotice({ kind: 'error', text: describeError(error) });
} finally {
setBusy(null);
}
};
const save = async (): Promise<void> => {
setBusy('save');
setNotice(null);
try {
await apiPut<BackupSettingsView>('/admin/system/backup/settings', toInput(form));
setDraft(null);
setNotice({ kind: 'ok', text: t('backup.settings.saved') });
await queryClient.invalidateQueries({ queryKey: ['admin', 'system', 'backup'] });
} catch (error) {
setNotice({ kind: 'error', text: describeError(error) });
} finally {
setBusy(null);
}
};
return (
<form
className="system-backup__settings"
onSubmit={(e) => {
e.preventDefault();
void save();
}}
>
<h3>{t('backup.settings.title')}</h3>
{notice && (
<p
role={notice.kind === 'error' ? 'alert' : 'status'}
className={`system-backup__notice system-backup__notice--${notice.kind}`}
>
{notice.text}
</p>
)}
<label>
{t('backup.settings.localRetention')}
<input
type="number"
min={1}
value={form.localRetentionDays}
onChange={(e) => update({ localRetentionDays: e.target.value })}
/>
</label>
<p className="system-backup__hint">{t('backup.settings.localRetentionHint')}</p>
<label className="system-backup__checkbox">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
{t('backup.settings.enabled')}
</label>
<fieldset disabled={!form.enabled} className="system-backup__nextcloud">
<label>
{t('backup.settings.baseUrl')}
<input
type="url"
value={form.baseUrl}
placeholder="https://cloud.example.com"
onChange={(e) => update({ baseUrl: e.target.value })}
/>
</label>
<p className="system-backup__hint">{t('backup.settings.baseUrlHint')}</p>
<label>
{t('backup.settings.username')}
<input
type="text"
value={form.username}
autoComplete="off"
onChange={(e) => update({ username: e.target.value })}
/>
</label>
<label>
{t('backup.settings.password')}
<input
type="password"
value={form.password}
autoComplete="new-password"
placeholder={view.nextcloud.passwordSet ? t('backup.settings.passwordStored') : ''}
onChange={(e) => update({ password: e.target.value })}
/>
</label>
<p className="system-backup__hint">{t('backup.settings.passwordHint')}</p>
<label>
{t('backup.settings.folder')}
<input
type="text"
value={form.folder}
onChange={(e) => update({ folder: e.target.value })}
/>
</label>
<label>
{t('backup.settings.schedule')}
<select
value={form.uploadSchedule}
onChange={(e) =>
update({ uploadSchedule: e.target.value as SettingsDraft['uploadSchedule'] })
}
>
{(['off', 'daily', 'weekly'] as const).map((option) => (
<option key={option} value={option}>
{t(`backup.settings.scheduleOptions.${option}`)}
</option>
))}
</select>
</label>
<label>
{t('backup.settings.remoteRetention')}
<input
type="number"
min={1}
value={form.remoteRetentionDays}
onChange={(e) => update({ remoteRetentionDays: e.target.value })}
/>
</label>
<button
type="button"
className="button system-backup__test"
disabled={busy !== null}
onClick={() => void test()}
>
{busy === 'test' ? t('backup.settings.testPending') : t('backup.settings.test')}
</button>
</fieldset>
<button type="submit" className="button system-backup__save" disabled={busy !== null}>
{busy === 'save' ? t('backup.settings.savePending') : t('backup.settings.save')}
</button>
</form>
);
}
function RestorePicker({ restore }: { restore: SystemBackupView['restore'] }): React.JSX.Element {
const { t } = useTranslation('system');
const { t: tErrors } = useTranslation('errors');
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<{
source: 'local' | 'remote';
set: BackupSetView;
} | null>(null);
const [confirm, setConfirm] = useState('');
const [state, setState] = useState<'idle' | 'pending' | 'requested'>('idle');
const [error, setError] = useState<string | null>(null);
const sets = useQuery({
queryKey: ['admin', 'system', 'backup', 'sets'],
queryFn: () => apiGet<BackupSetsView>('/admin/system/backup/sets'),
enabled: open,
});
const request = async (): Promise<void> => {
if (!selected) return;
setState('pending');
setError(null);
try {
await apiPost('/admin/system/backup/restore', {
source: selected.source,
backupId: selected.set.backupId,
confirm,
});
setState('requested');
} catch (err) {
setState('idle');
setError(
err instanceof ApiError
? tErrors(err.body.code, { defaultValue: err.body.code })
: String(err),
);
}
};
const setList = (source: 'local' | 'remote', list: BackupSetView[]): React.JSX.Element => (
<ul className="system-backup__sets">
{list.map((set) => (
<li key={`${source}-${set.backupId}`}>
<label>
<input
type="radio"
name="restore-set"
checked={selected?.source === source && selected.set.backupId === set.backupId}
onChange={() => {
setSelected({ source, set });
setConfirm('');
}}
/>
{set.backupId} {new Date(set.startedAt).toLocaleString()}
{set.sizeBytes !== null && <> ({formatBytes(set.sizeBytes)})</>}
</label>
</li>
))}
</ul>
);
return (
<div className="system-backup__restore">
<h3>{t('backup.restore.title')}</h3>
{restore && (
<p
role="status"
className={`system-backup__restore-status system-backup__restore-status--${restore.state}`}
>
{t(`backup.restore.status.${restore.state}`, {
id: restore.backupId,
startedAt: new Date(restore.startedAt).toLocaleString(),
finishedAt: restore.finishedAt ? new Date(restore.finishedAt).toLocaleString() : '',
error: restore.error ?? '',
})}
</p>
)}
{!open && (
<button
type="button"
className="button system-backup__restore-open"
onClick={() => setOpen(true)}
>
{t('backup.restore.open')}
</button>
)}
{open && (
<>
<p className="system-backup__warning" role="alert">
{t('backup.restore.warning')}
</p>
{sets.isPending && <p>{t('backup.restore.loading')}</p>}
{sets.data && (
<>
<h4>{t('backup.restore.localTitle')}</h4>
{sets.data.local.length === 0 && <p>{t('backup.restore.empty')}</p>}
{setList('local', sets.data.local)}
{sets.data.remoteConfigured && (
<>
<h4>{t('backup.restore.remoteTitle')}</h4>
{sets.data.remoteError && (
<p role="alert">
{t('backup.restore.remoteError', { error: sets.data.remoteError })}
</p>
)}
{!sets.data.remoteError && sets.data.remote.length === 0 && (
<p>{t('backup.restore.empty')}</p>
)}
{setList('remote', sets.data.remote)}
</>
)}
</>
)}
{selected && (
<div className="system-backup__confirm">
<label>
{t('backup.restore.confirmLabel', { id: selected.set.backupId })}
<input
type="text"
value={confirm}
autoComplete="off"
onChange={(e) => setConfirm(e.target.value)}
/>
</label>
<button
type="button"
className="button button--danger system-backup__restore-go"
disabled={confirm !== selected.set.backupId || state !== 'idle'}
onClick={() => void request()}
>
{state === 'pending' ? t('backup.restore.pending') : t('backup.restore.button')}
</button>
</div>
)}
{state === 'requested' && <p role="status">{t('backup.restore.requested')}</p>}
{error && <p role="alert">{error}</p>}
</>
)}
</div>
);
}