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
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
522 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|