mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
283 lines
8.7 KiB
TypeScript
283 lines
8.7 KiB
TypeScript
import { type ChangeEvent, useEffect, useEffectEvent, useRef, useState } from 'react';
|
|
|
|
import { backupApi } from '../../api/client';
|
|
import { useFormatDate } from '../../hooks/useDateFormat';
|
|
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 } = useTranslation();
|
|
const { formatDateTime } = useFormatDate();
|
|
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 formatDateTime(new Date(dateStr), 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,
|
|
};
|
|
}
|