refactor(admin): share the backup panel logic between desktop and phone

useBackupAdmin now loads the backups and the auto backup schedule and runs create, download, delete, restore and the schedule save. The phone keeps its form when the server answers without settings through the keepSettingsWhenMissing option.
This commit is contained in:
Maurice
2026-10-09 10:22:41 +02:00
parent f1719c3e35
commit 78fa42a0e6
4 changed files with 537 additions and 376 deletions
+9 -194
View File
@@ -1,9 +1,5 @@
import { useState, useEffect, useRef, type ChangeEvent } from 'react'
import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, Loader2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import CustomSelect from '../shared/CustomSelect'
import ConfirmDialog from '../shared/ConfirmDialog'
import EmptyState from '../shared/EmptyState'
@@ -21,200 +17,19 @@ import {
SettingsCard,
StatusPill,
} from '../Settings/settingsKit'
import { getApiErrorMessage } from '../../types'
const INTERVAL_OPTIONS = [
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
{ value: 'daily', labelKey: 'backup.interval.daily' },
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
]
const KEEP_OPTIONS = [
{ value: 1, labelKey: 'backup.keep.1day' },
{ value: 3, labelKey: 'backup.keep.3days' },
{ value: 7, labelKey: 'backup.keep.7days' },
{ value: 14, labelKey: 'backup.keep.14days' },
{ value: 30, labelKey: 'backup.keep.30days' },
{ value: 0, labelKey: 'backup.keep.forever' },
]
const DAYS_OF_WEEK = [
{ value: 0, labelKey: 'backup.dow.sunday' },
{ value: 1, labelKey: 'backup.dow.monday' },
{ value: 2, labelKey: 'backup.dow.tuesday' },
{ value: 3, labelKey: 'backup.dow.wednesday' },
{ value: 4, labelKey: 'backup.dow.thursday' },
{ value: 5, labelKey: 'backup.dow.friday' },
{ value: 6, labelKey: 'backup.dow.saturday' },
]
const HOURS = Array.from({ length: 24 }, (_, i) => i)
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
import { DAYS_OF_MONTH, DAYS_OF_WEEK, HOURS, INTERVAL_OPTIONS, KEEP_OPTIONS, useBackupAdmin } from './useBackupAdmin'
/** The compact buttons of a backup row: white on a hairline, a size under the card's own. */
const ROW_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
interface BackupItem {
filename: string
created_at?: string | null
size?: number | null
}
interface RestoreTarget {
type: 'file' | 'upload'
filename: string
file?: File
}
export default function BackupPanel() {
const [backups, setBackups] = useState<BackupItem[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [restoringFile, setRestoringFile] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const [serverTimezone, setServerTimezone] = useState('')
const [restoreConfirm, setRestoreConfirm] = useState<RestoreTarget | null>(null)
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const toast = useToast()
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const loadBackups = async () => {
setIsLoading(true)
try {
const data = await backupApi.list()
setBackups(data.backups || [])
} catch {
toast.error(t('backup.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const loadAutoSettings = async () => {
try {
const data = await backupApi.getAutoSettings()
setAutoSettings(data.settings)
if (data.timezone) setServerTimezone(data.timezone)
} catch {}
}
useEffect(() => { void loadBackups(); void loadAutoSettings() }, [])
const handleCreate = async () => {
setIsCreating(true)
try {
await backupApi.create()
toast.success(t('backup.toast.created'))
await loadBackups()
} catch {
toast.error(t('backup.toast.createError'))
} finally {
setIsCreating(false)
}
}
const handleRestore = (filename: string) => {
setRestoreConfirm({ type: 'file', filename })
}
const handleUploadRestore = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
setRestoreConfirm({ type: 'upload', filename: file.name, file })
}
const executeRestore = async () => {
if (!restoreConfirm) return
const { type, filename, file } = restoreConfirm
setRestoreConfirm(null)
if (type === 'file') {
setRestoringFile(filename)
try {
await backupApi.restore(filename)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')))
setRestoringFile(null)
}
} else {
setIsUploading(true)
try {
await backupApi.uploadRestore(file)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')))
setIsUploading(false)
}
}
}
// The question is asked in the planner's confirm dialog; the delete itself runs once it is answered.
const handleDelete = (filename: string) => {
setDeleteTarget(filename)
}
const executeDelete = async () => {
const filename = deleteTarget
setDeleteTarget(null)
if (!filename) return
try {
await backupApi.delete(filename)
toast.success(t('backup.toast.deleted'))
setBackups(prev => prev.filter(b => b.filename !== filename))
} catch {
toast.error(t('backup.toast.deleteError'))
}
}
const handleAutoSettingsChange = (key: string, value: unknown) => {
setAutoSettings(prev => ({ ...prev, [key]: value }))
setAutoSettingsDirty(true)
}
const handleSaveAutoSettings = async () => {
setAutoSettingsSaving(true)
try {
const data = await backupApi.setAutoSettings(autoSettings)
setAutoSettings(data.settings)
setAutoSettingsDirty(false)
toast.success(t('backup.toast.settingsSaved'))
} catch {
toast.error(t('backup.toast.settingsError'))
} finally {
setAutoSettingsSaving(false)
}
}
const formatSize = (bytes: number | null | undefined) => {
if (!bytes) return '-'
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return '-'
try {
const opts: Intl.DateTimeFormatOptions = {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
}
if (serverTimezone) opts.timeZone = serverTimezone
return new Date(dateStr).toLocaleString(locale, opts)
} catch { return dateStr }
}
const isAuto = (filename: string) => filename.startsWith('auto-backup-')
const {
backups, isLoading, isCreating, restoringFile, isUploading, autoSettings, autoSettingsSaving, autoSettingsDirty,
serverTimezone, restoreConfirm, setRestoreConfirm, deleteTarget, setDeleteTarget, fileInputRef, is12h,
loadBackups, handleCreate, handleDownload, handleRestore, handleUploadRestore, executeRestore, handleDelete,
executeDelete, handleAutoSettingsChange, handleSaveAutoSettings, formatSize, formatDate, isAuto,
} = useBackupAdmin()
const { t } = useTranslation()
const headerActions = (
<>
@@ -315,7 +130,7 @@ export default function BackupPanel() {
</div>
<div className="flex flex-none items-center gap-1.5">
<button type="button"
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
onClick={() => handleDownload(backup.filename)}
className={ROW_BUTTON}
style={fs(12, 'body')}
>
@@ -0,0 +1,231 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import type { ChangeEvent } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { backupApi } from '../../api/client';
import { useSettingsStore } from '../../store/settingsStore';
import { formatBackupSize, isAutoBackup, useBackupAdmin } from './useBackupAdmin';
// FE-HOOK-BACKUPADMIN-001 to FE-HOOK-BACKUPADMIN-014
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k, locale: 'en-US' }) }));
const SETTINGS = { enabled: true, interval: 'weekly', keep_days: 14, hour: 3, day_of_week: 1, day_of_month: 1 };
const B1 = { filename: 'backup-1.zip', created_at: '2025-06-01T10:00:00Z', size: 2048 };
const B2 = { filename: 'auto-backup-2.zip', created_at: null, size: null };
const reload = vi.fn();
beforeEach(() => {
toast.success.mockReset();
toast.error.mockReset();
reload.mockReset();
vi.spyOn(backupApi, 'list').mockResolvedValue({ backups: [B1, B2] });
vi.spyOn(backupApi, 'getAutoSettings').mockResolvedValue({ settings: SETTINGS, timezone: 'Europe/Berlin' });
vi.spyOn(backupApi, 'create').mockResolvedValue({});
vi.spyOn(backupApi, 'delete').mockResolvedValue({});
vi.spyOn(backupApi, 'restore').mockResolvedValue({});
vi.spyOn(backupApi, 'uploadRestore').mockResolvedValue({});
vi.spyOn(backupApi, 'download').mockResolvedValue(undefined);
vi.spyOn(backupApi, 'setAutoSettings').mockResolvedValue({ settings: { ...SETTINGS, hour: 4 } });
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function loaded(keepSettingsWhenMissing?: boolean) {
const hook = renderHook(() =>
useBackupAdmin(keepSettingsWhenMissing === undefined ? undefined : { keepSettingsWhenMissing })
);
await waitFor(() => expect(hook.result.current.serverTimezone).toBe('Europe/Berlin'));
await waitFor(() => expect(hook.result.current.isLoading).toBe(false));
return hook;
}
/** Stubbed after the initial load, so the setup never runs against a fake location. */
function stubReload() {
vi.stubGlobal('location', { ...window.location, reload });
}
function uploadEvent(file: File | undefined) {
const target = { files: file ? [file] : [], value: 'C:\\fakepath\\x.zip' };
return { event: { target } as unknown as ChangeEvent<HTMLInputElement>, target };
}
describe('useBackupAdmin', () => {
it('FE-HOOK-BACKUPADMIN-001: loads the backups and the schedule once on mount', async () => {
const { result, rerender } = await loaded();
rerender();
expect(backupApi.list).toHaveBeenCalledTimes(1);
expect(backupApi.getAutoSettings).toHaveBeenCalledTimes(1);
expect(result.current.backups).toEqual([B1, B2]);
expect(result.current.autoSettings).toEqual(SETTINGS);
});
it('FE-HOOK-BACKUPADMIN-002: a failed list toasts; a failed schedule load keeps the defaults quietly', async () => {
vi.mocked(backupApi.list).mockRejectedValue(new Error('down'));
vi.mocked(backupApi.getAutoSettings).mockRejectedValue(new Error('down'));
const { result } = renderHook(() => useBackupAdmin());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('backup.toast.loadError'));
expect(toast.error).toHaveBeenCalledTimes(1);
expect(result.current.autoSettings.interval).toBe('daily');
});
it('FE-HOOK-BACKUPADMIN-003: a schedule answer without settings is kept on the phone and taken on desktop', async () => {
vi.mocked(backupApi.getAutoSettings).mockResolvedValue({ timezone: 'Europe/Berlin' });
const phone = await loaded(true);
expect(phone.result.current.autoSettings.interval).toBe('daily');
const desktop = await loaded();
expect(desktop.result.current.autoSettings).toBeUndefined();
});
it('FE-HOOK-BACKUPADMIN-004: create toasts and reloads the list; a failure toasts the create error', async () => {
const { result } = await loaded();
await act(() => result.current.handleCreate());
expect(toast.success).toHaveBeenCalledWith('backup.toast.created');
expect(backupApi.list).toHaveBeenCalledTimes(2);
expect(result.current.isCreating).toBe(false);
vi.mocked(backupApi.create).mockRejectedValue(new Error('x'));
await act(() => result.current.handleCreate());
expect(toast.error).toHaveBeenCalledWith('backup.toast.createError');
});
it('FE-HOOK-BACKUPADMIN-005: a failed download toasts the download error', async () => {
vi.mocked(backupApi.download).mockRejectedValue(new Error('x'));
const { result } = await loaded();
await act(async () => {
await result.current.handleDownload('backup-1.zip');
});
expect(backupApi.download).toHaveBeenCalledWith('backup-1.zip');
expect(toast.error).toHaveBeenCalledWith('backup.toast.downloadError');
});
it('FE-HOOK-BACKUPADMIN-006: delete waits for the confirm, then drops the row', async () => {
const { result } = await loaded();
act(() => result.current.handleDelete('backup-1.zip'));
expect(result.current.deleteTarget).toBe('backup-1.zip');
expect(backupApi.delete).not.toHaveBeenCalled();
await act(() => result.current.executeDelete());
expect(backupApi.delete).toHaveBeenCalledWith('backup-1.zip');
expect(result.current.deleteTarget).toBeNull();
expect(result.current.backups).toEqual([B2]);
expect(toast.success).toHaveBeenCalledWith('backup.toast.deleted');
});
it('FE-HOOK-BACKUPADMIN-007: executing a delete with nothing pending does nothing; a failure toasts', async () => {
const { result } = await loaded();
await act(() => result.current.executeDelete());
expect(backupApi.delete).not.toHaveBeenCalled();
vi.mocked(backupApi.delete).mockRejectedValue(new Error('x'));
act(() => result.current.setDeleteTarget('backup-1.zip'));
await act(() => result.current.executeDelete());
expect(toast.error).toHaveBeenCalledWith('backup.toast.deleteError');
expect(result.current.backups).toEqual([B1, B2]);
});
it('FE-HOOK-BACKUPADMIN-008: restoring a stored file marks it, toasts and reloads the page after 1.5 s', async () => {
const { result } = await loaded();
stubReload();
vi.useFakeTimers();
act(() => result.current.handleRestore('backup-1.zip'));
expect(result.current.restoreConfirm).toEqual({ type: 'file', filename: 'backup-1.zip' });
await act(() => result.current.executeRestore());
expect(backupApi.restore).toHaveBeenCalledWith('backup-1.zip');
expect(result.current.restoreConfirm).toBeNull();
expect(result.current.restoringFile).toBe('backup-1.zip');
expect(toast.success).toHaveBeenCalledWith('backup.toast.restored');
expect(reload).not.toHaveBeenCalled();
act(() => vi.advanceTimersByTime(1500));
expect(reload).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-BACKUPADMIN-009: a failed restore shows the server message and clears the marker', async () => {
vi.mocked(backupApi.restore).mockRejectedValue({ response: { data: { error: 'Corrupt' } } });
const { result } = await loaded();
act(() => result.current.handleRestore('backup-1.zip'));
await act(() => result.current.executeRestore());
expect(toast.error).toHaveBeenCalledWith('Corrupt');
expect(result.current.restoringFile).toBeNull();
});
it('FE-HOOK-BACKUPADMIN-010: picking an upload clears the input and asks before restoring it', async () => {
const { result } = await loaded();
stubReload();
vi.useFakeTimers();
const file = new File(['x'], 'mine.zip');
const { event, target } = uploadEvent(file);
act(() => result.current.handleUploadRestore(event));
expect(target.value).toBe('');
expect(result.current.restoreConfirm).toEqual({ type: 'upload', filename: 'mine.zip', file });
await act(() => result.current.executeRestore());
expect(backupApi.uploadRestore).toHaveBeenCalledWith(file);
expect(result.current.isUploading).toBe(true);
expect(toast.success).toHaveBeenCalledWith('backup.toast.restored');
act(() => vi.advanceTimersByTime(1500));
expect(reload).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-BACKUPADMIN-011: an empty pick does nothing; a failed upload restore toasts and stops', async () => {
const { result } = await loaded();
const empty = uploadEvent(undefined);
act(() => result.current.handleUploadRestore(empty.event));
expect(result.current.restoreConfirm).toBeNull();
expect(empty.target.value).not.toBe('');
vi.mocked(backupApi.uploadRestore).mockRejectedValue(new Error('x'));
act(() => result.current.handleUploadRestore(uploadEvent(new File(['x'], 'mine.zip')).event));
await act(() => result.current.executeRestore());
expect(toast.error).toHaveBeenCalledWith('x');
expect(result.current.isUploading).toBe(false);
});
it('FE-HOOK-BACKUPADMIN-012: editing the schedule marks it dirty; saving takes the answer and toasts', async () => {
const { result } = await loaded();
act(() => result.current.handleAutoSettingsChange('hour', 4));
expect(result.current.autoSettings.hour).toBe(4);
expect(result.current.autoSettingsDirty).toBe(true);
await act(() => result.current.handleSaveAutoSettings());
expect(backupApi.setAutoSettings).toHaveBeenCalledWith({ ...SETTINGS, hour: 4 });
expect(result.current.autoSettingsDirty).toBe(false);
expect(result.current.autoSettingsSaving).toBe(false);
expect(toast.success).toHaveBeenCalledWith('backup.toast.settingsSaved');
});
it('FE-HOOK-BACKUPADMIN-013: a save answer without settings keeps the form on the phone only; a failure toasts', async () => {
vi.mocked(backupApi.setAutoSettings).mockResolvedValue({});
const phone = await loaded(true);
await act(() => phone.result.current.handleSaveAutoSettings());
expect(phone.result.current.autoSettings).toEqual(SETTINGS);
const desktop = await loaded();
await act(() => desktop.result.current.handleSaveAutoSettings());
expect(desktop.result.current.autoSettings).toBeUndefined();
vi.mocked(backupApi.setAutoSettings).mockRejectedValue(new Error('x'));
await act(() => phone.result.current.handleSaveAutoSettings());
expect(toast.error).toHaveBeenCalledWith('backup.toast.settingsError');
});
it('FE-HOOK-BACKUPADMIN-014: formats sizes, dates in the server zone, auto names and the 12h setting', async () => {
useSettingsStore.setState((s) => ({ settings: { ...s.settings, time_format: '12h' } }));
const { result } = await loaded();
expect(result.current.is12h).toBe(true);
expect(formatBackupSize(null)).toBe('-');
expect(formatBackupSize(2048)).toBe('2.0 KB');
expect(formatBackupSize(3 * 1024 * 1024)).toBe('3.0 MB');
expect(isAutoBackup('auto-backup-2.zip')).toBe(true);
expect(isAutoBackup('backup-1.zip')).toBe(false);
expect(result.current.formatDate(null)).toBe('-');
expect(result.current.formatDate('2025-06-01T10:00:00Z')).toBe(
new Date('2025-06-01T10:00:00Z').toLocaleString('en-US', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Berlin',
})
);
});
});
@@ -0,0 +1,280 @@
import { type ChangeEvent, useEffect, useEffectEvent, useRef, useState } from 'react';
import { backupApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { getApiErrorMessage } from '../../types';
import { useToast } from '../shared/Toast';
export const INTERVAL_OPTIONS = [
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
{ value: 'daily', labelKey: 'backup.interval.daily' },
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
];
export const KEEP_OPTIONS = [
{ value: 1, labelKey: 'backup.keep.1day' },
{ value: 3, labelKey: 'backup.keep.3days' },
{ value: 7, labelKey: 'backup.keep.7days' },
{ value: 14, labelKey: 'backup.keep.14days' },
{ value: 30, labelKey: 'backup.keep.30days' },
{ value: 0, labelKey: 'backup.keep.forever' },
];
export const DAYS_OF_WEEK = [
{ value: 0, labelKey: 'backup.dow.sunday' },
{ value: 1, labelKey: 'backup.dow.monday' },
{ value: 2, labelKey: 'backup.dow.tuesday' },
{ value: 3, labelKey: 'backup.dow.wednesday' },
{ value: 4, labelKey: 'backup.dow.thursday' },
{ value: 5, labelKey: 'backup.dow.friday' },
{ value: 6, labelKey: 'backup.dow.saturday' },
];
export const HOURS = Array.from({ length: 24 }, (_, i) => i);
export const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1);
interface BackupItem {
filename: string;
created_at?: string | null;
size?: number | null;
}
interface RestoreTarget {
type: 'file' | 'upload';
filename: string;
file?: File;
}
// A type alias, not an interface: the API takes it as a plain record.
type AutoBackupSettings = {
enabled: boolean;
interval: string;
keep_days: number;
hour: number;
day_of_week: number;
day_of_month: number;
};
/** A backup's size in KB below one megabyte and in MB above, or a dash when unknown. */
export function formatBackupSize(bytes: number | null | undefined): string {
if (!bytes) return '-';
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/** Whether a backup file was written by the schedule rather than by hand. */
export const isAutoBackup = (filename: string) => filename.startsWith('auto-backup-');
interface Options {
/**
* The phone shell keeps the schedule form as it is when the server answers without a
* settings block; the desktop shell takes the answer as it comes.
*/
keepSettingsWhenMissing?: boolean;
}
/**
* The backup admin behind both shells: the backup list and the auto backup schedule
* loaded on mount, create, download, delete and restore (from a stored file or an
* upload) behind their confirm steps, and the schedule form with its save. The desktop
* and phone shells render their own markup and dialogs over it.
*/
export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}) {
const [backups, setBackups] = useState<BackupItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [restoringFile, setRestoringFile] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [autoSettings, setAutoSettings] = useState<AutoBackupSettings>({
enabled: false,
interval: 'daily',
keep_days: 7,
hour: 2,
day_of_week: 0,
day_of_month: 1,
});
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false);
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false);
const [serverTimezone, setServerTimezone] = useState('');
const [restoreConfirm, setRestoreConfirm] = useState<RestoreTarget | null>(null);
// The filename whose delete waits for the answer in the confirm dialog.
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const { t, locale } = useTranslation();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const loadBackups = async () => {
setIsLoading(true);
try {
const data = await backupApi.list();
setBackups(data.backups || []);
} catch {
toast.error(t('backup.toast.loadError'));
} finally {
setIsLoading(false);
}
};
const loadAutoSettings = async () => {
try {
const data = await backupApi.getAutoSettings();
// A 200 without a settings block would blank the whole schedule form.
if (data.settings || !keepSettingsWhenMissing) setAutoSettings(data.settings);
if (data.timezone) setServerTimezone(data.timezone);
} catch {
/* the schedule form keeps its defaults */
}
};
const loadOnMount = useEffectEvent(() => {
void loadBackups();
void loadAutoSettings();
});
useEffect(() => {
loadOnMount();
}, []);
const handleCreate = async () => {
setIsCreating(true);
try {
await backupApi.create();
toast.success(t('backup.toast.created'));
await loadBackups();
} catch {
toast.error(t('backup.toast.createError'));
} finally {
setIsCreating(false);
}
};
const handleDownload = (filename: string) =>
backupApi.download(filename).catch(() => toast.error(t('backup.toast.downloadError')));
const handleRestore = (filename: string) => {
setRestoreConfirm({ type: 'file', filename });
};
const handleUploadRestore = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
setRestoreConfirm({ type: 'upload', filename: file.name, file });
};
const executeRestore = async () => {
if (!restoreConfirm) return;
const { type, filename, file } = restoreConfirm;
setRestoreConfirm(null);
if (type === 'file') {
setRestoringFile(filename);
try {
await backupApi.restore(filename);
toast.success(t('backup.toast.restored'));
setTimeout(() => window.location.reload(), 1500);
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')));
setRestoringFile(null);
}
} else {
setIsUploading(true);
try {
await backupApi.uploadRestore(file);
toast.success(t('backup.toast.restored'));
setTimeout(() => window.location.reload(), 1500);
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')));
setIsUploading(false);
}
}
};
// The question is asked in the confirm dialog; the delete itself runs once it is answered.
const handleDelete = (filename: string) => {
setDeleteTarget(filename);
};
const executeDelete = async () => {
const filename = deleteTarget;
setDeleteTarget(null);
if (!filename) return;
try {
await backupApi.delete(filename);
toast.success(t('backup.toast.deleted'));
setBackups((prev) => prev.filter((b) => b.filename !== filename));
} catch {
toast.error(t('backup.toast.deleteError'));
}
};
const handleAutoSettingsChange = (key: string, value: unknown) => {
setAutoSettings((prev) => ({ ...prev, [key]: value }));
setAutoSettingsDirty(true);
};
const handleSaveAutoSettings = async () => {
setAutoSettingsSaving(true);
try {
const data = await backupApi.setAutoSettings(autoSettings);
if (data.settings || !keepSettingsWhenMissing) setAutoSettings(data.settings);
setAutoSettingsDirty(false);
toast.success(t('backup.toast.settingsSaved'));
} catch {
toast.error(t('backup.toast.settingsError'));
} finally {
setAutoSettingsSaving(false);
}
};
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return '-';
try {
const opts: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
if (serverTimezone) opts.timeZone = serverTimezone;
return new Date(dateStr).toLocaleString(locale, opts);
} catch {
return dateStr;
}
};
return {
backups,
isLoading,
isCreating,
restoringFile,
isUploading,
autoSettings,
autoSettingsSaving,
autoSettingsDirty,
serverTimezone,
restoreConfirm,
setRestoreConfirm,
deleteTarget,
setDeleteTarget,
fileInputRef,
is12h,
loadBackups,
handleCreate,
handleDownload,
handleRestore,
handleUploadRestore,
executeRestore,
handleDelete,
executeDelete,
handleAutoSettingsChange,
handleSaveAutoSettings,
formatSize: formatBackupSize,
formatDate,
isAuto: isAutoBackup,
};
}
@@ -1,4 +1,3 @@
import { useState, useEffect, useRef } from 'react'
import {
Check,
Clock,
@@ -10,196 +9,32 @@ import {
Trash2,
Upload,
} from 'lucide-react'
import { backupApi } from '../../../api/client'
import { useToast } from '../../../components/shared/Toast'
import {
DAYS_OF_MONTH,
DAYS_OF_WEEK,
HOURS,
INTERVAL_OPTIONS,
KEEP_OPTIONS,
useBackupAdmin,
} from '../../../components/Admin/useBackupAdmin'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
import { getApiErrorMessage } from '../../../types'
import MChip from '../../components/MChip'
import MToggle from '../../components/MToggle'
import MConfirmSheet from '../settings/MConfirmSheet'
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminRow } from './MAdminUi'
const INTERVAL_OPTIONS = [
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
{ value: 'daily', labelKey: 'backup.interval.daily' },
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
]
const KEEP_OPTIONS = [
{ value: 1, labelKey: 'backup.keep.1day' },
{ value: 3, labelKey: 'backup.keep.3days' },
{ value: 7, labelKey: 'backup.keep.7days' },
{ value: 14, labelKey: 'backup.keep.14days' },
{ value: 30, labelKey: 'backup.keep.30days' },
{ value: 0, labelKey: 'backup.keep.forever' },
]
const DAYS_OF_WEEK = [
{ value: 0, labelKey: 'backup.dow.sunday' },
{ value: 1, labelKey: 'backup.dow.monday' },
{ value: 2, labelKey: 'backup.dow.tuesday' },
{ value: 3, labelKey: 'backup.dow.wednesday' },
{ value: 4, labelKey: 'backup.dow.thursday' },
{ value: 5, labelKey: 'backup.dow.friday' },
{ value: 6, labelKey: 'backup.dow.saturday' },
]
const HOURS = Array.from({ length: 24 }, (_, i) => i)
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
const SELECT_CLASS =
'h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none focus:border-[color:var(--m-faint)]'
export default function MAdminBackupPanel() {
const [backups, setBackups] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [restoringFile, setRestoringFile] = useState(null)
const [isUploading, setIsUploading] = useState(false)
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const [serverTimezone, setServerTimezone] = useState('')
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
const [deleteConfirm, setDeleteConfirm] = useState(null) // filename pending deletion
const fileInputRef = useRef(null)
const toast = useToast()
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const loadBackups = async () => {
setIsLoading(true)
try {
const data = await backupApi.list()
setBackups(data.backups || [])
} catch {
toast.error(t('backup.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const loadAutoSettings = async () => {
try {
const data = await backupApi.getAutoSettings()
// A 200 without a settings block would blank the whole schedule form.
if (data.settings) setAutoSettings(data.settings)
if (data.timezone) setServerTimezone(data.timezone)
} catch {}
}
useEffect(() => { void loadBackups(); void loadAutoSettings() }, [])
const handleCreate = async () => {
setIsCreating(true)
try {
await backupApi.create()
toast.success(t('backup.toast.created'))
await loadBackups()
} catch {
toast.error(t('backup.toast.createError'))
} finally {
setIsCreating(false)
}
}
const handleRestore = (filename) => {
setRestoreConfirm({ type: 'file', filename })
}
const handleUploadRestore = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
e.target.value = ''
setRestoreConfirm({ type: 'upload', filename: file.name, file })
}
const executeRestore = async () => {
if (!restoreConfirm) return
const { type, filename, file } = restoreConfirm
setRestoreConfirm(null)
if (type === 'file') {
setRestoringFile(filename)
try {
await backupApi.restore(filename)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')))
setRestoringFile(null)
}
} else {
setIsUploading(true)
try {
await backupApi.uploadRestore(file)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')))
setIsUploading(false)
}
}
}
const handleDelete = (filename) => {
setDeleteConfirm(filename)
}
const executeDelete = async () => {
const filename = deleteConfirm
setDeleteConfirm(null)
if (!filename) return
try {
await backupApi.delete(filename)
toast.success(t('backup.toast.deleted'))
setBackups(prev => prev.filter(b => b.filename !== filename))
} catch {
toast.error(t('backup.toast.deleteError'))
}
}
const handleAutoSettingsChange = (key, value) => {
setAutoSettings(prev => ({ ...prev, [key]: value }))
setAutoSettingsDirty(true)
}
const handleSaveAutoSettings = async () => {
setAutoSettingsSaving(true)
try {
const data = await backupApi.setAutoSettings(autoSettings)
if (data.settings) setAutoSettings(data.settings)
setAutoSettingsDirty(false)
toast.success(t('backup.toast.settingsSaved'))
} catch {
toast.error(t('backup.toast.settingsError'))
} finally {
setAutoSettingsSaving(false)
}
}
const formatSize = (bytes) => {
if (!bytes) return '-'
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
const opts: Intl.DateTimeFormatOptions = {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
}
if (serverTimezone) opts.timeZone = serverTimezone
return new Date(dateStr).toLocaleString(locale, opts)
} catch { return dateStr }
}
const isAuto = (filename) => filename.startsWith('auto-backup-')
const {
backups, isLoading, isCreating, restoringFile, isUploading, autoSettings, autoSettingsSaving, autoSettingsDirty,
serverTimezone, restoreConfirm, setRestoreConfirm, deleteTarget: deleteConfirm, setDeleteTarget: setDeleteConfirm,
fileInputRef, is12h, loadBackups, handleCreate, handleDownload, handleRestore, handleUploadRestore,
executeRestore, handleDelete, executeDelete, handleAutoSettingsChange, handleSaveAutoSettings, formatSize,
formatDate, isAuto,
} = useBackupAdmin({ keepSettingsWhenMissing: true })
const { t } = useTranslation()
return (
<div className="space-y-3">
@@ -305,7 +140,7 @@ export default function MAdminBackupPanel() {
<div className="flex items-center gap-2 ps-12">
<MAdminButton
variant="ghost"
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
onClick={() => handleDownload(backup.filename)}
>
<Download size={12} strokeWidth={2.2} />
{t('backup.download')}