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('/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 (

{t('backup.title')}

); } 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 => { setRunState('pending'); try { await apiPost('/admin/system/backup/run', {}); setRunState('requested'); } catch { setRunState('idle'); } finally { await queryClient.invalidateQueries({ queryKey: BACKUP_QUERY_KEY }); } }; return (
{!view.available &&

{t('backup.unavailable')}

} {view.available && view.status && ( <>

{view.fresh ? t('backup.fresh') : t('backup.stale', { hours: view.maxAgeHours })}

{t('backup.lastSuccess')}
{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')}
{t('backup.lastRun')}
{new Date(view.status.lastRun.finishedAt).toLocaleString()} —{' '} {t(`backup.outcome.${view.status.lastRun.outcome}`)}
{view.status.lastRun.error && ( <>
{t('backup.error')}
{view.status.lastRun.error}
)}
{t('backup.remote.title')}
{!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' && ( <> {' — '} {view.status.remote.lastUpload.error} )} )}
{view.status.mirror && ( <>
{t('backup.mirror.title')}
{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')}: {view.status.mirror.lastRun.error} )}
)}

{t('backup.retention', { days: view.status.retentionDays })}

)}

{runState === 'requested' &&

{t('backup.runRequested')}

}
); } 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(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('/admin/system/backup/settings'), }); const view = query.data; if (!view) return <>; const form = draft ?? toDraft(view); const update = (patch: Partial): 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 => { setBusy('test'); setNotice(null); try { const result = await apiPost( '/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 => { setBusy('save'); setNotice(null); try { await apiPut('/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 (
{ e.preventDefault(); void save(); }} >

{t('backup.settings.title')}

{notice && (

{notice.text}

)}

{t('backup.settings.localRetentionHint')}

{t('backup.settings.baseUrlHint')}

{t('backup.settings.passwordHint')}

); } 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(null); const sets = useQuery({ queryKey: ['admin', 'system', 'backup', 'sets'], queryFn: () => apiGet('/admin/system/backup/sets'), enabled: open, }); const request = async (): Promise => { 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 => (
    {list.map((set) => (
  • ))}
); return (

{t('backup.restore.title')}

{restore && (

{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 ?? '', })}

)} {!open && ( )} {open && ( <>

{t('backup.restore.warning')}

{sets.isPending &&

{t('backup.restore.loading')}

} {sets.data && ( <>

{t('backup.restore.localTitle')}

{sets.data.local.length === 0 &&

{t('backup.restore.empty')}

} {setList('local', sets.data.local)} {sets.data.remoteConfigured && ( <>

{t('backup.restore.remoteTitle')}

{sets.data.remoteError && (

{t('backup.restore.remoteError', { error: sets.data.remoteError })}

)} {!sets.data.remoteError && sets.data.remote.length === 0 && (

{t('backup.restore.empty')}

)} {setList('remote', sets.data.remote)} )} )} {selected && (
)} {state === 'requested' &&

{t('backup.restore.requested')}

} {error &&

{error}

} )}
); }