mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
1026 lines
46 KiB
TypeScript
1026 lines
46 KiB
TypeScript
import { createPortal } from 'preact/compat';
|
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
import {
|
|
type AdminBackupImportResponse,
|
|
type AdminBackupRunResponse,
|
|
type AdminBackupSettings,
|
|
type BackupFileIntegrityCheckResult,
|
|
type BackupDestinationRecord,
|
|
type BackupDestinationType,
|
|
type RemoteBackupBrowserResponse,
|
|
verifyBackupFileIntegrity,
|
|
} from '@/lib/api/backup';
|
|
import {
|
|
REMOTE_BROWSER_ITEMS_PER_PAGE,
|
|
compareRemoteItems,
|
|
createDraftBackupSettings,
|
|
createDraftDestinationRecord,
|
|
getDestinationById,
|
|
getFirstVisibleDestinationId,
|
|
getRemoteBrowserCacheKey,
|
|
getVisibleDestinations,
|
|
invalidateRemoteBrowserCacheForDestination,
|
|
isReplaceRequiredError,
|
|
loadPersistedRemoteBrowserState,
|
|
persistRemoteBrowserState,
|
|
} from '@/lib/backup-center';
|
|
import { BACKUP_PROGRESS_EVENT, type BackupProgressDetail, type BackupProgressOperation } from '@/lib/backup-restore-progress';
|
|
import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations';
|
|
import { t } from '@/lib/i18n';
|
|
import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail';
|
|
import { BackupDestinationSidebar } from './backup-center/BackupDestinationSidebar';
|
|
import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar';
|
|
|
|
interface BackupCenterPageProps {
|
|
currentUserId: string | null;
|
|
onExport: (includeAttachments?: boolean) => Promise<void>;
|
|
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
|
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
|
onLoadSettings: () => Promise<AdminBackupSettings>;
|
|
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
|
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
|
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
|
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
|
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
|
}
|
|
|
|
type PendingRestoreIntegrity =
|
|
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
|
|
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
|
|
|
|
interface BackupProgressPhase {
|
|
titleKey: string;
|
|
detailKey: string;
|
|
}
|
|
|
|
interface BackupProgressState {
|
|
operation: BackupProgressOperation;
|
|
source: 'local' | 'remote' | null;
|
|
includeAttachments: boolean;
|
|
fileLabel: string;
|
|
startedAt: number;
|
|
phaseIndex: number;
|
|
phases: BackupProgressPhase[];
|
|
currentTitleKey: string;
|
|
currentDetailKey: string;
|
|
}
|
|
|
|
const LOCAL_RESTORE_PHASES: BackupProgressPhase[] = [
|
|
{ titleKey: 'txt_backup_restore_progress_local_upload_title', detailKey: 'txt_backup_restore_progress_local_upload_detail' },
|
|
{ titleKey: 'txt_backup_restore_progress_local_shadow_title', detailKey: 'txt_backup_restore_progress_local_shadow_detail' },
|
|
{ titleKey: 'txt_backup_restore_progress_local_data_title', detailKey: 'txt_backup_restore_progress_local_data_detail' },
|
|
{ titleKey: 'txt_backup_restore_progress_local_files_title', detailKey: 'txt_backup_restore_progress_local_files_detail' },
|
|
{ titleKey: 'txt_backup_restore_progress_local_finalize_title', detailKey: 'txt_backup_restore_progress_local_finalize_detail' },
|
|
];
|
|
|
|
const REMOTE_RESTORE_PHASES: BackupProgressPhase[] = [
|
|
{ titleKey: 'txt_backup_restore_progress_remote_fetch_title', detailKey: 'txt_backup_restore_progress_remote_fetch_detail' },
|
|
{ titleKey: 'txt_backup_restore_progress_remote_shadow_title', detailKey: 'txt_backup_restore_progress_remote_shadow_detail' },
|
|
{ titleKey: 'txt_backup_restore_progress_remote_data_title', detailKey: 'txt_backup_restore_progress_remote_data_detail' },
|
|
{ titleKey: 'txt_backup_restore_progress_remote_files_title', detailKey: 'txt_backup_restore_progress_remote_files_detail' },
|
|
{ titleKey: 'txt_backup_restore_progress_remote_finalize_title', detailKey: 'txt_backup_restore_progress_remote_finalize_detail' },
|
|
];
|
|
|
|
const EXPORT_PROGRESS_PHASES: BackupProgressPhase[] = [
|
|
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_detail' },
|
|
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_detail' },
|
|
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
|
|
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
|
|
];
|
|
|
|
const EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES: BackupProgressPhase[] = [
|
|
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
|
|
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
|
|
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
|
|
{ titleKey: 'txt_backup_export_progress_fetch_attachments_title', detailKey: 'txt_backup_export_progress_fetch_attachments_detail' },
|
|
{ titleKey: 'txt_backup_export_progress_rebuild_title', detailKey: 'txt_backup_export_progress_rebuild_detail' },
|
|
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
|
|
];
|
|
|
|
const REMOTE_RUN_PROGRESS_PHASES: BackupProgressPhase[] = [
|
|
{ titleKey: 'txt_backup_remote_run_progress_prepare_title', detailKey: 'txt_backup_remote_run_progress_prepare_detail' },
|
|
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
|
|
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
|
|
{ titleKey: 'txt_backup_remote_run_progress_sync_attachments_title', detailKey: 'txt_backup_remote_run_progress_sync_attachments_detail' },
|
|
{ titleKey: 'txt_backup_remote_run_progress_upload_title', detailKey: 'txt_backup_remote_run_progress_upload_detail' },
|
|
{ titleKey: 'txt_backup_remote_run_progress_verify_title', detailKey: 'txt_backup_remote_run_progress_verify_detail' },
|
|
{ titleKey: 'txt_backup_remote_run_progress_cleanup_title', detailKey: 'txt_backup_remote_run_progress_cleanup_detail' },
|
|
];
|
|
|
|
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
|
|
const skipped = result.skipped;
|
|
if (!skipped || !skipped.attachments) return null;
|
|
return t('txt_backup_restore_skipped_summary', {
|
|
reason: skipped.reason || t('txt_backup_restore_skipped_reason_default'),
|
|
attachments: String(skipped.attachments),
|
|
});
|
|
}
|
|
|
|
function buildIntegrityStatusMessage(result: BackupFileIntegrityCheckResult, options?: { remote?: boolean }): string {
|
|
if (!result.hasChecksumPrefix) {
|
|
return t(options?.remote ? 'txt_backup_remote_restore_completed_without_checksum' : 'txt_backup_restore_completed_without_checksum');
|
|
}
|
|
return t(options?.remote ? 'txt_backup_remote_restore_completed_verified' : 'txt_backup_restore_completed_verified');
|
|
}
|
|
|
|
function buildIntegrityWarningMessage(entry: PendingRestoreIntegrity): string {
|
|
if (entry.source === 'remote') {
|
|
return t('txt_backup_remote_restore_checksum_warning_message', {
|
|
name: entry.fileName,
|
|
expected: entry.result.expectedPrefix || '-----',
|
|
actual: entry.result.actualPrefix,
|
|
});
|
|
}
|
|
return t('txt_backup_restore_checksum_warning_message', {
|
|
name: entry.fileName,
|
|
expected: entry.result.expectedPrefix || '-----',
|
|
actual: entry.result.actualPrefix,
|
|
});
|
|
}
|
|
|
|
function getBackupProgressPhases(
|
|
operation: BackupProgressOperation,
|
|
source: 'local' | 'remote' | null,
|
|
includeAttachments: boolean
|
|
): BackupProgressPhase[] {
|
|
if (operation === 'backup-restore') {
|
|
return source === 'remote' ? REMOTE_RESTORE_PHASES : LOCAL_RESTORE_PHASES;
|
|
}
|
|
if (operation === 'backup-export') {
|
|
return includeAttachments ? EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES : EXPORT_PROGRESS_PHASES;
|
|
}
|
|
return REMOTE_RUN_PROGRESS_PHASES;
|
|
}
|
|
|
|
function getBackupProgressTitleKey(state: BackupProgressState): string {
|
|
if (state.operation === 'backup-export') return 'txt_backup_export_progress_title';
|
|
if (state.operation === 'backup-remote-run') return 'txt_backup_remote_run_progress_title';
|
|
return state.source === 'remote'
|
|
? 'txt_backup_restore_progress_remote_title'
|
|
: 'txt_backup_restore_progress_local_title';
|
|
}
|
|
|
|
export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId));
|
|
const persistedRemoteState = persistedRemoteStateRef.current;
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const restoreProgressTimerRef = useRef<number | null>(null);
|
|
const restoreProgressPendingRef = useRef<BackupProgressState | null>(null);
|
|
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [exportIncludeAttachments, setExportIncludeAttachments] = useState(false);
|
|
const [importing, setImporting] = useState(false);
|
|
const [loadingSettings, setLoadingSettings] = useState(true);
|
|
const [savingSettings, setSavingSettings] = useState(false);
|
|
const [runningRemoteBackup, setRunningRemoteBackup] = useState(false);
|
|
const [loadingRemoteBrowser, setLoadingRemoteBrowser] = useState(false);
|
|
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
|
|
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
|
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
|
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
|
const [localError, setLocalError] = useState('');
|
|
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
|
|
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
|
|
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
|
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
|
|
const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false);
|
|
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
|
|
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
|
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
|
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
|
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
|
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
|
const [savedSettings, setSavedSettings] = useState<AdminBackupSettings | null>(null);
|
|
const [settings, setSettings] = useState<AdminBackupSettings>(createDraftBackupSettings);
|
|
const [selectedDestinationId, setSelectedDestinationId] = useState<string | null>(persistedRemoteState.selectedDestinationId);
|
|
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
|
|
const [remoteBrowserCache, setRemoteBrowserCache] = useState<Record<string, RemoteBackupBrowserResponse>>(persistedRemoteState.cache);
|
|
const [remoteBrowserPathByDestination, setRemoteBrowserPathByDestination] = useState<Record<string, string>>(persistedRemoteState.pathByDestination);
|
|
const [remoteBrowserPageByKey, setRemoteBrowserPageByKey] = useState<Record<string, number>>(persistedRemoteState.pageByKey);
|
|
const [showAddChooser, setShowAddChooser] = useState(false);
|
|
|
|
const visibleDestinations = getVisibleDestinations(settings);
|
|
const selectedDestination = getDestinationById(settings, selectedDestinationId);
|
|
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
|
|
const selectedDestinationIsSaved = !!savedSelectedDestination;
|
|
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup;
|
|
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
|
|
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
|
|
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
|
|
const remoteBrowserItems = remoteBrowser?.items || [];
|
|
const remoteBrowserTotalPages = Math.max(1, Math.ceil(remoteBrowserItems.length / REMOTE_BROWSER_ITEMS_PER_PAGE));
|
|
const currentRemoteBrowserPage = Math.min(remoteBrowserPageByKey[currentRemoteBrowserKey] || 1, remoteBrowserTotalPages);
|
|
const remoteBrowserVisibleItems = remoteBrowserItems.slice(
|
|
(currentRemoteBrowserPage - 1) * REMOTE_BROWSER_ITEMS_PER_PAGE,
|
|
currentRemoteBrowserPage * REMOTE_BROWSER_ITEMS_PER_PAGE
|
|
);
|
|
|
|
const selectedRecommendedProvider = RECOMMENDED_PROVIDERS.find((provider) => provider.id === selectedProviderId) || null;
|
|
const recommendedWebDavProviders = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 'webdav');
|
|
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
|
|
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
|
|
const canBrowseSelectedDestination = !!savedSelectedDestination;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoadingSettings(true);
|
|
void props.onLoadSettings()
|
|
.then((loaded) => {
|
|
if (cancelled) return;
|
|
setSavedSettings(loaded);
|
|
setSettings(loaded);
|
|
const nextSelectedDestinationId =
|
|
(persistedRemoteState.selectedDestinationId
|
|
&& getVisibleDestinations(loaded).some((destination) => destination.id === persistedRemoteState.selectedDestinationId)
|
|
? persistedRemoteState.selectedDestinationId
|
|
: null)
|
|
|| getFirstVisibleDestinationId(loaded);
|
|
setSelectedDestinationId(nextSelectedDestinationId);
|
|
setLocalError('');
|
|
})
|
|
.catch((error) => {
|
|
if (cancelled) return;
|
|
const message = error instanceof Error ? error.message : t('txt_backup_settings_load_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoadingSettings(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
persistRemoteBrowserState(props.currentUserId, {
|
|
cache: remoteBrowserCache,
|
|
pathByDestination: remoteBrowserPathByDestination,
|
|
pageByKey: remoteBrowserPageByKey,
|
|
selectedDestinationId,
|
|
});
|
|
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]);
|
|
|
|
useEffect(() => {
|
|
if (!restoreProgress) {
|
|
setRestoreElapsedSeconds(0);
|
|
return;
|
|
}
|
|
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
|
|
const tickTimer = window.setInterval(() => {
|
|
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
|
|
}, 1000);
|
|
return () => window.clearInterval(tickTimer);
|
|
}, [restoreProgress]);
|
|
|
|
useEffect(() => {
|
|
const handleProgress = (event: Event) => {
|
|
const detail = (event as CustomEvent<BackupProgressDetail>).detail;
|
|
if (!detail) return;
|
|
const pending = restoreProgressPendingRef.current;
|
|
const operation = detail.operation || pending?.operation || 'backup-restore';
|
|
const source = (detail.source || pending?.source || null) as 'local' | 'remote' | null;
|
|
const includeAttachments = pending?.includeAttachments || false;
|
|
const phases = getBackupProgressPhases(operation, source, includeAttachments);
|
|
const matchedPhaseIndex = phases.findIndex((phase) => phase.titleKey === detail.stageTitle);
|
|
const phaseIndex = matchedPhaseIndex >= 0 ? matchedPhaseIndex : 0;
|
|
const nextState: BackupProgressState = {
|
|
operation,
|
|
source,
|
|
includeAttachments,
|
|
fileLabel: detail.fileName || pending?.fileLabel || '',
|
|
startedAt: pending?.operation === operation
|
|
? pending.startedAt
|
|
: Date.now(),
|
|
phaseIndex,
|
|
phases,
|
|
currentTitleKey: detail.stageTitle || phases[Math.max(0, phaseIndex)].titleKey,
|
|
currentDetailKey: detail.stageDetail || phases[Math.max(0, phaseIndex)].detailKey,
|
|
};
|
|
restoreProgressPendingRef.current = nextState;
|
|
if (restoreProgressTimerRef.current === null) {
|
|
setRestoreProgress(nextState);
|
|
}
|
|
if (detail.done) {
|
|
window.setTimeout(() => {
|
|
setRestoreProgress((current) => (
|
|
current && current.fileLabel === (detail.fileName || current.fileLabel) ? null : current
|
|
));
|
|
setRestoreElapsedSeconds(0);
|
|
}, detail.ok === false ? 1200 : 900);
|
|
}
|
|
};
|
|
window.addEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
|
|
return () => window.removeEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
|
|
}, []);
|
|
|
|
function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) {
|
|
setSettings((current) => {
|
|
const next = mutator(current);
|
|
if (selectedDestinationId && !next.destinations.some((destination) => destination.id === selectedDestinationId)) {
|
|
setSelectedDestinationId(getFirstVisibleDestinationId(next));
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function updateSelectedDestination(mutator: (destination: BackupDestinationRecord) => BackupDestinationRecord) {
|
|
if (!selectedDestinationId) return;
|
|
updateSettings((current) => ({
|
|
...current,
|
|
destinations: current.destinations.map((destination) => (
|
|
destination.id === selectedDestinationId ? mutator(destination) : destination
|
|
)),
|
|
}));
|
|
}
|
|
|
|
async function loadRemoteBrowser(destinationId: string, path: string = '', options?: { force?: boolean }): Promise<void> {
|
|
const cacheKey = getRemoteBrowserCacheKey(destinationId, path);
|
|
setRemoteBrowserPathByDestination((current) => ({ ...current, [destinationId]: path }));
|
|
if (!options?.force && remoteBrowserCache[cacheKey]) return;
|
|
|
|
setLoadingRemoteBrowser(true);
|
|
try {
|
|
const browser = await props.onListRemoteBackups(destinationId, path);
|
|
const nextBrowser = {
|
|
...browser,
|
|
items: browser.items.slice().sort(compareRemoteItems),
|
|
};
|
|
setRemoteBrowserCache((current) => ({ ...current, [cacheKey]: nextBrowser }));
|
|
setRemoteBrowserPageByKey((current) => ({ ...current, [cacheKey]: 1 }));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_load_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
} finally {
|
|
setLoadingRemoteBrowser(false);
|
|
}
|
|
}
|
|
|
|
function showRemoteBrowserPath(destinationId: string, path: string = ''): void {
|
|
setRemoteBrowserPathByDestination((current) => ({ ...current, [destinationId]: path }));
|
|
}
|
|
|
|
function buildSettingsPayloadForSelectedDestination(): AdminBackupSettings {
|
|
if (!selectedDestinationId || !selectedDestination) {
|
|
return savedSettings || { destinations: [] };
|
|
}
|
|
const persistedDestinations = (savedSettings?.destinations || []).filter((destination) => destination.id !== selectedDestinationId);
|
|
return {
|
|
destinations: [...persistedDestinations, selectedDestination],
|
|
};
|
|
}
|
|
|
|
function applySavedDestinationToDrafts(saved: AdminBackupSettings, destinationId: string | null) {
|
|
if (!destinationId) {
|
|
setSettings((current) => ({
|
|
destinations: current.destinations.filter((destination) => !savedSettings?.destinations.some((savedDestination) => savedDestination.id === destination.id)),
|
|
}));
|
|
return;
|
|
}
|
|
const savedDestination = getDestinationById(saved, destinationId);
|
|
setSettings((current) => ({
|
|
destinations: current.destinations.map((destination) => (
|
|
destination.id === destinationId && savedDestination ? savedDestination : destination
|
|
)),
|
|
}));
|
|
}
|
|
|
|
function resetSelectedFile() {
|
|
setSelectedFile(null);
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
}
|
|
|
|
function resetPendingIntegrityWarning() {
|
|
setPendingRestoreIntegrity(null);
|
|
setConfirmIntegrityWarningOpen(false);
|
|
}
|
|
|
|
function startRestoreProgress(
|
|
operation: BackupProgressOperation,
|
|
fileLabel: string,
|
|
options?: { source?: 'local' | 'remote' | null; includeAttachments?: boolean; delayMs?: number }
|
|
) {
|
|
if (restoreProgressTimerRef.current !== null) {
|
|
window.clearTimeout(restoreProgressTimerRef.current);
|
|
restoreProgressTimerRef.current = null;
|
|
}
|
|
setRestoreElapsedSeconds(0);
|
|
const source = options?.source || null;
|
|
const includeAttachments = !!options?.includeAttachments;
|
|
const phases = getBackupProgressPhases(operation, source, includeAttachments);
|
|
restoreProgressPendingRef.current = {
|
|
operation,
|
|
source,
|
|
includeAttachments,
|
|
fileLabel,
|
|
startedAt: Date.now(),
|
|
phaseIndex: 0,
|
|
phases,
|
|
currentTitleKey: phases[0].titleKey,
|
|
currentDetailKey: phases[0].detailKey,
|
|
};
|
|
restoreProgressTimerRef.current = window.setTimeout(() => {
|
|
restoreProgressTimerRef.current = null;
|
|
if (!restoreProgressPendingRef.current) return;
|
|
setRestoreProgress(restoreProgressPendingRef.current);
|
|
}, options?.delayMs ?? 480);
|
|
}
|
|
|
|
function clearRestoreProgress() {
|
|
if (restoreProgressTimerRef.current !== null) {
|
|
window.clearTimeout(restoreProgressTimerRef.current);
|
|
restoreProgressTimerRef.current = null;
|
|
}
|
|
restoreProgressPendingRef.current = null;
|
|
setRestoreProgress(null);
|
|
setRestoreElapsedSeconds(0);
|
|
}
|
|
|
|
async function inspectLocalBackupFile(file: File): Promise<BackupFileIntegrityCheckResult> {
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
return verifyBackupFileIntegrity(bytes, file.name || '');
|
|
}
|
|
|
|
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
|
const payload = await props.onInspectRemoteBackup(destinationId, path);
|
|
return {
|
|
source: 'remote',
|
|
path,
|
|
fileName: payload.fileName || path.split('/').pop() || path,
|
|
result: payload.integrity,
|
|
};
|
|
}
|
|
|
|
function handleAddDestination(type: BackupDestinationType) {
|
|
updateSettings((current) => {
|
|
const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1);
|
|
setSelectedProviderId(null);
|
|
setSelectedDestinationId(nextDestination.id);
|
|
return {
|
|
...current,
|
|
destinations: [...current.destinations, nextDestination],
|
|
};
|
|
});
|
|
setShowAddChooser(false);
|
|
}
|
|
|
|
async function handleDeleteDestination() {
|
|
if (!selectedDestinationId || savingSettings) return;
|
|
const destinationIdToDelete = selectedDestinationId;
|
|
const nextSettings: AdminBackupSettings = {
|
|
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
|
|
};
|
|
|
|
setSavingSettings(true);
|
|
setLocalError('');
|
|
try {
|
|
const saved = await props.onSaveSettings(nextSettings);
|
|
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
|
|
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
|
|
setSavedSettings(saved);
|
|
setSettings({ destinations: nextDraftDestinations });
|
|
setRemoteBrowserCache((current) => invalidateRemoteBrowserCacheForDestination(
|
|
destinationIdToDelete,
|
|
current,
|
|
remoteBrowserPathByDestination,
|
|
remoteBrowserPageByKey
|
|
).cache);
|
|
setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToDelete)));
|
|
setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToDelete}:`))));
|
|
setSelectedDestinationId(nextSelected);
|
|
setConfirmDeleteDestinationOpen(false);
|
|
props.onNotify('success', t('txt_backup_destination_deleted'));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
} finally {
|
|
setSavingSettings(false);
|
|
}
|
|
}
|
|
|
|
async function handleExport() {
|
|
setLocalError('');
|
|
setExporting(true);
|
|
try {
|
|
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
|
await props.onExport(exportIncludeAttachments);
|
|
props.onNotify('success', t('txt_backup_export_success'));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
}
|
|
|
|
async function runLocalRestore(
|
|
replaceExisting: boolean,
|
|
allowChecksumMismatch: boolean = false,
|
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
|
) {
|
|
if (importing) return;
|
|
if (!selectedFile) {
|
|
const message = t('txt_backup_file_required');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
return;
|
|
}
|
|
setLocalError('');
|
|
setConfirmLocalRestoreOpen(false);
|
|
setConfirmReplaceOpen(false);
|
|
setConfirmIntegrityWarningOpen(false);
|
|
setImporting(true);
|
|
try {
|
|
const integrity = knownIntegrity || await inspectLocalBackupFile(selectedFile);
|
|
startRestoreProgress('backup-restore', selectedFile.name || t('txt_backup_import'), {
|
|
source: 'local',
|
|
delayMs: replaceExisting ? 480 : 1400,
|
|
});
|
|
const result = allowChecksumMismatch
|
|
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting)
|
|
: await props.onImport(selectedFile, replaceExisting);
|
|
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
|
|
const skippedMessage = buildSkippedImportMessage(result);
|
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
|
resetSelectedFile();
|
|
setConfirmLocalRestoreOpen(false);
|
|
setConfirmReplaceOpen(false);
|
|
resetPendingIntegrityWarning();
|
|
} catch (error) {
|
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
|
clearRestoreProgress();
|
|
setConfirmLocalRestoreOpen(false);
|
|
setConfirmReplaceOpen(true);
|
|
return;
|
|
}
|
|
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
}
|
|
|
|
async function handleSaveSettings() {
|
|
const payload = buildSettingsPayloadForSelectedDestination();
|
|
const destinationIdToInvalidate = selectedDestinationId;
|
|
setSavingSettings(true);
|
|
setLocalError('');
|
|
try {
|
|
const saved = await props.onSaveSettings(payload);
|
|
const nextSelected =
|
|
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|
|
|| getFirstVisibleDestinationId(saved)
|
|
|| null;
|
|
setSavedSettings(saved);
|
|
applySavedDestinationToDrafts(saved, nextSelected);
|
|
if (destinationIdToInvalidate) {
|
|
setRemoteBrowserCache((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`))));
|
|
setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToInvalidate)));
|
|
setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`))));
|
|
}
|
|
setSelectedDestinationId(nextSelected);
|
|
props.onNotify('success', t('txt_backup_settings_saved'));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
} finally {
|
|
setSavingSettings(false);
|
|
}
|
|
}
|
|
|
|
function handleToggleSelectedSchedule() {
|
|
if (!selectedDestination) return;
|
|
updateSelectedDestination((destination) => ({
|
|
...destination,
|
|
schedule: {
|
|
...destination.schedule,
|
|
enabled: !destination.schedule.enabled,
|
|
},
|
|
}));
|
|
}
|
|
|
|
async function handleRunRemoteBackup() {
|
|
if (!selectedDestination) return;
|
|
setRunningRemoteBackup(true);
|
|
setLocalError('');
|
|
try {
|
|
startRestoreProgress('backup-remote-run', selectedDestination.name || t('txt_backup_run_now'), {
|
|
source: 'remote',
|
|
includeAttachments: !!selectedDestination.includeAttachments,
|
|
});
|
|
const result = await props.onRunRemoteBackup(selectedDestination.id);
|
|
setSavedSettings(result.settings);
|
|
setSettings(result.settings);
|
|
setSelectedDestinationId(selectedDestination.id);
|
|
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
|
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
|
} finally {
|
|
setRunningRemoteBackup(false);
|
|
}
|
|
}
|
|
|
|
async function handleDownloadRemote(path: string) {
|
|
if (!savedSelectedDestination) return;
|
|
setDownloadingRemotePath(path);
|
|
setDownloadingRemotePercent(null);
|
|
setLocalError('');
|
|
try {
|
|
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
} finally {
|
|
setDownloadingRemotePath('');
|
|
setDownloadingRemotePercent(null);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteRemote(path: string) {
|
|
if (deletingRemotePath) return;
|
|
if (!savedSelectedDestination) return;
|
|
setDeletingRemotePath(path);
|
|
setLocalError('');
|
|
try {
|
|
await props.onDeleteRemoteBackup(savedSelectedDestination.id, path);
|
|
setConfirmRemoteDeleteOpen(false);
|
|
setPendingRemoteDeletePath('');
|
|
await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true });
|
|
props.onNotify('success', t('txt_backup_remote_delete_success'));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
} finally {
|
|
setDeletingRemotePath('');
|
|
}
|
|
}
|
|
|
|
async function handleSelectedLocalFile(nextFile: File | null) {
|
|
setSelectedFile(nextFile);
|
|
setLocalError('');
|
|
resetPendingIntegrityWarning();
|
|
setConfirmLocalRestoreOpen(false);
|
|
if (!nextFile) return;
|
|
|
|
try {
|
|
const integrity = await inspectLocalBackupFile(nextFile);
|
|
if (!integrity.matches) {
|
|
setPendingRestoreIntegrity({
|
|
source: 'local',
|
|
fileName: nextFile.name,
|
|
result: integrity,
|
|
});
|
|
setConfirmIntegrityWarningOpen(true);
|
|
return;
|
|
}
|
|
setConfirmLocalRestoreOpen(true);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
}
|
|
}
|
|
|
|
async function handlePromptRemoteRestore(path: string) {
|
|
if (!savedSelectedDestination) return;
|
|
setLocalError('');
|
|
resetPendingIntegrityWarning();
|
|
try {
|
|
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
|
if (!integrity.result.matches) {
|
|
setPendingRestoreIntegrity(integrity);
|
|
setConfirmIntegrityWarningOpen(true);
|
|
return;
|
|
}
|
|
await runRemoteRestore(path, false, false, integrity.result);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
}
|
|
}
|
|
|
|
async function runRemoteRestore(
|
|
path: string,
|
|
replaceExisting: boolean,
|
|
allowChecksumMismatch: boolean = false,
|
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
|
) {
|
|
if (restoringRemotePath) return;
|
|
if (!savedSelectedDestination) return;
|
|
setConfirmRemoteReplaceOpen(false);
|
|
setConfirmIntegrityWarningOpen(false);
|
|
setRestoringRemotePath(path);
|
|
setLocalError('');
|
|
try {
|
|
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
|
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
|
|
source: 'remote',
|
|
delayMs: replaceExisting ? 480 : 1400,
|
|
});
|
|
const result = allowChecksumMismatch
|
|
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting)
|
|
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
|
setConfirmRemoteReplaceOpen(false);
|
|
setPendingRemoteRestorePath('');
|
|
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
|
|
const skippedMessage = buildSkippedImportMessage(result);
|
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
|
resetPendingIntegrityWarning();
|
|
} catch (error) {
|
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
|
setPendingRemoteRestorePath(path);
|
|
setConfirmRemoteReplaceOpen(true);
|
|
clearRestoreProgress();
|
|
return;
|
|
}
|
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
|
|
setLocalError(message);
|
|
props.onNotify('error', message);
|
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
|
} finally {
|
|
setRestoringRemotePath('');
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="backup-grid">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
hidden
|
|
accept=".zip,application/zip"
|
|
disabled={disableWhileBusy}
|
|
onChange={(event) => {
|
|
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
|
void handleSelectedLocalFile(nextFile);
|
|
}}
|
|
/>
|
|
|
|
<BackupOperationsSidebar
|
|
disableWhileBusy={disableWhileBusy}
|
|
exporting={exporting}
|
|
importing={importing}
|
|
exportIncludeAttachments={exportIncludeAttachments}
|
|
selectedProviderId={selectedProviderId}
|
|
recommendedWebDavProviders={recommendedWebDavProviders}
|
|
recommendedS3Providers={recommendedS3Providers}
|
|
onExport={() => void handleExport()}
|
|
onImport={() => fileInputRef.current?.click()}
|
|
onExportIncludeAttachmentsChange={setExportIncludeAttachments}
|
|
onSelectProvider={(providerId) => setSelectedProviderId(providerId)}
|
|
/>
|
|
|
|
<BackupDestinationSidebar
|
|
destinations={visibleDestinations}
|
|
selectedDestinationId={selectedDestinationId}
|
|
disableWhileBusy={disableWhileBusy}
|
|
showAddChooser={showAddChooser}
|
|
onSelectDestination={(destinationId) => {
|
|
setSelectedProviderId(null);
|
|
setSelectedDestinationId(destinationId);
|
|
}}
|
|
onToggleAddChooser={() => setShowAddChooser((current) => !current)}
|
|
onAddDestination={handleAddDestination}
|
|
/>
|
|
|
|
<BackupDestinationDetail
|
|
selectedRecommendedProvider={selectedRecommendedProvider}
|
|
selectedDestination={selectedDestination}
|
|
selectedDestinationIsSaved={selectedDestinationIsSaved}
|
|
canRunSelectedDestination={canRunSelectedDestination}
|
|
canBrowseSelectedDestination={canBrowseSelectedDestination}
|
|
disableWhileBusy={disableWhileBusy}
|
|
loadingSettings={loadingSettings}
|
|
savingSettings={savingSettings}
|
|
runningRemoteBackup={runningRemoteBackup}
|
|
availableTimeZones={selectedDestination?.schedule.timezone ? [selectedDestination.schedule.timezone] : []}
|
|
remoteBrowser={remoteBrowser}
|
|
remoteBrowserVisibleItems={remoteBrowserVisibleItems}
|
|
remoteBrowserCurrentPage={currentRemoteBrowserPage}
|
|
remoteBrowserTotalPages={remoteBrowserTotalPages}
|
|
loadingRemoteBrowser={loadingRemoteBrowser}
|
|
downloadingRemotePath={downloadingRemotePath}
|
|
downloadingRemotePercent={downloadingRemotePercent}
|
|
restoringRemotePath={restoringRemotePath}
|
|
deletingRemotePath={deletingRemotePath}
|
|
onSaveSettings={() => void handleSaveSettings()}
|
|
onToggleSchedule={handleToggleSelectedSchedule}
|
|
onRunRemoteBackup={() => void handleRunRemoteBackup()}
|
|
onPromptDeleteDestination={() => setConfirmDeleteDestinationOpen(true)}
|
|
onUpdateDestination={updateSelectedDestination}
|
|
onRefreshRemoteBrowser={() => {
|
|
if (savedSelectedDestination) {
|
|
void loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true });
|
|
}
|
|
}}
|
|
onShowRemoteBrowserPath={(path) => {
|
|
if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path);
|
|
}}
|
|
onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)}
|
|
onRestoreRemoteBackup={(path) => void handlePromptRemoteRestore(path)}
|
|
onPromptDeleteRemoteBackup={(path) => {
|
|
setPendingRemoteDeletePath(path);
|
|
setConfirmRemoteDeleteOpen(true);
|
|
}}
|
|
onChangeRemoteBrowserPage={(page) => {
|
|
if (!currentRemoteBrowserKey) return;
|
|
setRemoteBrowserPageByKey((current) => ({ ...current, [currentRemoteBrowserKey]: page }));
|
|
}}
|
|
/>
|
|
|
|
{localError ? <div className="local-error">{localError}</div> : null}
|
|
{restoreProgress && typeof document !== 'undefined' ? createPortal((
|
|
<div className="restore-progress-overlay" aria-live="polite">
|
|
<section className="restore-progress-card restore-progress-modal">
|
|
<div className="restore-progress-head">
|
|
<div>
|
|
<div className="restore-progress-kicker">{t('txt_backup_progress_kicker')}</div>
|
|
<h3 className="restore-progress-title">
|
|
{t(getBackupProgressTitleKey(restoreProgress))}
|
|
</h3>
|
|
<p className="restore-progress-subtitle">
|
|
{t('txt_backup_progress_subject', { name: restoreProgress.fileLabel })}
|
|
</p>
|
|
</div>
|
|
<div className="restore-progress-elapsed">
|
|
{t('txt_backup_restore_progress_elapsed', { seconds: String(restoreElapsedSeconds) })}
|
|
</div>
|
|
</div>
|
|
<div className="restore-progress-meter">
|
|
<span
|
|
className="restore-progress-meter-bar"
|
|
style={{
|
|
width: `${((restoreProgress.phaseIndex + 1) / restoreProgress.phases.length) * 100}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="restore-progress-current">
|
|
<strong>{t(restoreProgress.currentTitleKey)}</strong>
|
|
<p>{t(restoreProgress.currentDetailKey)}</p>
|
|
</div>
|
|
<ol className="restore-progress-list">
|
|
{restoreProgress.phases.map((phase, index) => {
|
|
const status = index < restoreProgress.phaseIndex ? 'done' : index === restoreProgress.phaseIndex ? 'active' : 'pending';
|
|
return (
|
|
<li key={phase.titleKey} className={`restore-progress-item ${status}`}>
|
|
<span className="restore-progress-dot" />
|
|
<span className="restore-progress-item-text">{t(phase.titleKey)}</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
</section>
|
|
</div>
|
|
), document.body) : null}
|
|
|
|
<ConfirmDialog
|
|
open={confirmLocalRestoreOpen}
|
|
title={t('txt_backup_import')}
|
|
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
|
|
confirmText={t('txt_backup_import')}
|
|
cancelText={t('txt_cancel')}
|
|
confirmDisabled={importing}
|
|
cancelDisabled={importing}
|
|
danger
|
|
onConfirm={() => void runLocalRestore(false)}
|
|
onCancel={() => {
|
|
if (importing) return;
|
|
setConfirmLocalRestoreOpen(false);
|
|
resetSelectedFile();
|
|
resetPendingIntegrityWarning();
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={confirmReplaceOpen}
|
|
title={t('txt_backup_replace_confirm_title')}
|
|
message={t('txt_backup_replace_confirm_message')}
|
|
confirmText={importing ? t('txt_backup_restoring') : t('txt_backup_clear_and_restore')}
|
|
cancelText={t('txt_cancel')}
|
|
confirmDisabled={importing}
|
|
cancelDisabled={importing}
|
|
danger
|
|
onConfirm={() => void runLocalRestore(
|
|
true,
|
|
pendingRestoreIntegrity?.source === 'local',
|
|
pendingRestoreIntegrity?.source === 'local' ? pendingRestoreIntegrity.result : undefined
|
|
)}
|
|
onCancel={() => {
|
|
if (importing) return;
|
|
setConfirmReplaceOpen(false);
|
|
resetSelectedFile();
|
|
resetPendingIntegrityWarning();
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={confirmRemoteReplaceOpen}
|
|
title={t('txt_backup_replace_confirm_title')}
|
|
message={t('txt_backup_replace_confirm_message')}
|
|
confirmText={restoringRemotePath ? t('txt_backup_restoring') : t('txt_backup_clear_and_restore')}
|
|
cancelText={t('txt_cancel')}
|
|
confirmDisabled={!!restoringRemotePath}
|
|
cancelDisabled={!!restoringRemotePath}
|
|
danger
|
|
onConfirm={() => void runRemoteRestore(
|
|
pendingRemoteRestorePath,
|
|
true,
|
|
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath,
|
|
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath
|
|
? pendingRestoreIntegrity.result
|
|
: undefined
|
|
)}
|
|
onCancel={() => {
|
|
if (restoringRemotePath) return;
|
|
setConfirmRemoteReplaceOpen(false);
|
|
setPendingRemoteRestorePath('');
|
|
resetPendingIntegrityWarning();
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={confirmIntegrityWarningOpen}
|
|
title={t('txt_backup_restore_checksum_warning_title')}
|
|
message={pendingRestoreIntegrity ? buildIntegrityWarningMessage(pendingRestoreIntegrity) : t('txt_backup_restore_checksum_warning_message_fallback')}
|
|
variant="warning"
|
|
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
|
|
cancelText={t('txt_cancel')}
|
|
confirmDisabled={importing || !!restoringRemotePath}
|
|
cancelDisabled={importing || !!restoringRemotePath}
|
|
danger
|
|
onConfirm={() => {
|
|
if (!pendingRestoreIntegrity) return;
|
|
setConfirmIntegrityWarningOpen(false);
|
|
if (pendingRestoreIntegrity.source === 'local') {
|
|
void runLocalRestore(false, true, pendingRestoreIntegrity.result);
|
|
return;
|
|
}
|
|
void runRemoteRestore(pendingRestoreIntegrity.path, false, true, pendingRestoreIntegrity.result);
|
|
}}
|
|
onCancel={() => {
|
|
if (importing || restoringRemotePath) return;
|
|
resetPendingIntegrityWarning();
|
|
setPendingRemoteRestorePath('');
|
|
setConfirmLocalRestoreOpen(false);
|
|
resetSelectedFile();
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={confirmRemoteDeleteOpen}
|
|
title={t('txt_delete')}
|
|
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
|
|
confirmText={t('txt_delete')}
|
|
cancelText={t('txt_cancel')}
|
|
confirmDisabled={!!deletingRemotePath}
|
|
cancelDisabled={!!deletingRemotePath}
|
|
danger
|
|
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
|
|
onCancel={() => {
|
|
if (deletingRemotePath) return;
|
|
setConfirmRemoteDeleteOpen(false);
|
|
setPendingRemoteDeletePath('');
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={confirmDeleteDestinationOpen}
|
|
title={t('txt_delete')}
|
|
message={t('txt_backup_delete_destination_confirm_message', {
|
|
name: selectedDestination?.name || t('txt_backup_delete_destination'),
|
|
})}
|
|
confirmText={t('txt_delete')}
|
|
cancelText={t('txt_cancel')}
|
|
confirmDisabled={savingSettings}
|
|
cancelDisabled={savingSettings}
|
|
danger
|
|
onConfirm={() => void handleDeleteDestination()}
|
|
onCancel={() => {
|
|
if (savingSettings) return;
|
|
setConfirmDeleteDestinationOpen(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|