import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { Github, Play, Clock, CheckCircle, XCircle, Loader2, ExternalLink, RefreshCw, Download, Upload, Database, History, SkipForward, AlertTriangle, Trash2, RotateCcw, FolderArchive, } from 'lucide-react'; import { api } from '../api/client'; import type { GitHubBackupConfig, GitHubBackupConfigCreate, GitHubBackupLog, GitHubBackupStatus, GitHubBackupTriggerResponse, GitProviderType, LocalBackupFile, LocalBackupStatus, ScheduleType, CloudAuthStatus, Printer, } from '../api/client'; import { Card, CardContent, CardHeader } from './Card'; import { Button } from './Button'; import { Toggle } from './Toggle'; import { ConfirmModal } from './ConfirmModal'; import { useToast } from '../contexts/ToastContext'; import { formatRelativeTime, parseUTCDate } from '../utils/date'; function formatDateTime(dateStr: string | null): string { if (!dateStr) return '-'; const date = parseUTCDate(dateStr); if (!date) return '-'; return date.toLocaleString(); } interface StatusBadgeProps { status: string | null; } function StatusBadge({ status }: StatusBadgeProps) { if (!status) return null; const styles: Record = { success: 'bg-green-500/20 text-green-400', failed: 'bg-red-500/20 text-red-400', skipped: 'bg-yellow-500/20 text-yellow-400', running: 'bg-blue-500/20 text-blue-400', }; const icons: Record = { success: , failed: , skipped: , running: , }; return ( {icons[status]} {status.charAt(0).toUpperCase() + status.slice(1)} ); } const PROVIDER_REPO_URL_I18N_KEY: Record = { github: 'backup.repoUrlPlaceholderGitHub', gitea: 'backup.repoUrlPlaceholderGitea', forgejo: 'backup.repoUrlPlaceholderForgejo', gitlab: 'backup.repoUrlPlaceholderGitLab', }; const PROVIDER_TOKEN_PLACEHOLDER: Record = { github: 'ghp_xxxxxxxxxxxx', gitea: 'your_access_token', forgejo: 'your_access_token', gitlab: 'glpat-xxxxxxxxxxxx', }; interface GitHubBackupAutosaveState { repository_url: string; branch: string; provider: GitProviderType; allow_insecure_http: boolean; schedule_enabled: boolean; schedule_type: ScheduleType; backup_kprofiles: boolean; backup_cloud_profiles: boolean; backup_settings: boolean; backup_spools: boolean; backup_archives: boolean; enabled: boolean; } function serializeAutosaveState(state: GitHubBackupAutosaveState): string { return JSON.stringify(state); } function getChangedAutosaveFields( current: GitHubBackupAutosaveState, previous: GitHubBackupAutosaveState | null ): Partial { if (!previous) { return current; } const changes: Partial = {}; for (const key of Object.keys(current) as Array) { if (current[key] !== previous[key]) { changes[key] = current[key] as never; } } return changes; } export function GitHubBackupSettings() { const queryClient = useQueryClient(); const { showToast } = useToast(); const { t } = useTranslation(); // Local state for form const [repoUrl, setRepoUrl] = useState(''); const [accessToken, setAccessToken] = useState(''); const [branch, setBranch] = useState('main'); const [provider, setProvider] = useState('github'); const [scheduleEnabled, setScheduleEnabled] = useState(false); const [scheduleType, setScheduleType] = useState('daily'); const [backupKProfiles, setBackupKProfiles] = useState(true); const [backupCloudProfiles, setBackupCloudProfiles] = useState(true); const [backupSettings, setBackupSettings] = useState(false); const [backupSpools, setBackupSpools] = useState(false); const [backupArchives, setBackupArchives] = useState(false); const [allowInsecureHttp, setAllowInsecureHttp] = useState(false); const [enabled, setEnabled] = useState(true); // Local backup state const [isExporting, setIsExporting] = useState(false); const [isRestoring, setIsRestoring] = useState(false); const [operationStatus, setOperationStatus] = useState(''); const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); const [restoreFile, setRestoreFile] = useState(null); const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null); const fileInputRef = useRef(null); // Scheduled local backup state const [deleteConfirmFile, setDeleteConfirmFile] = useState(null); const [restoreConfirmFile, setRestoreConfirmFile] = useState(null); const [localBackupPath, setLocalBackupPath] = useState(''); const { data: localBackupStatus, refetch: refetchLocalStatus } = useQuery({ queryKey: ['local-backup-status'], queryFn: api.getLocalBackupStatus, refetchInterval: (query) => query.state.data?.is_running ? 1000 : 10000, }); const { data: localBackups, refetch: refetchLocalBackups } = useQuery({ queryKey: ['local-backup-files'], queryFn: api.getLocalBackups, refetchInterval: 30000, }); // Sync local path state from server useEffect(() => { if (localBackupStatus?.path !== undefined) { setLocalBackupPath(localBackupStatus.path); } }, [localBackupStatus?.path]); const triggerLocalBackupMutation = useMutation({ mutationFn: api.triggerLocalBackup, onSuccess: (data) => { if (data.success) { showToast(t('backup.scheduledBackupComplete')); } else { showToast(data.message, 'error'); } refetchLocalStatus(); refetchLocalBackups(); }, onError: () => showToast(t('backup.scheduledBackupFailed'), 'error'), }); const deleteLocalBackupMutation = useMutation({ mutationFn: (filename: string) => api.deleteLocalBackup(filename), onSuccess: () => { refetchLocalBackups(); setDeleteConfirmFile(null); }, }); const restoreLocalBackupMutation = useMutation({ mutationFn: async (filename: string) => { setRestoreConfirmFile(null); setIsRestoring(true); setRestoreResult(null); setOperationStatus(t('backup.restoring')); return api.restoreLocalBackup(filename); }, onSuccess: (data) => { setIsRestoring(false); setOperationStatus(''); if (data.success) { setRestoreResult({ success: true, message: data.message }); showToast(t('backup.backupRestoredRestart'), 'success'); } else { setRestoreResult({ success: false, message: data.message }); showToast(data.message, 'error'); } }, onError: (e) => { setIsRestoring(false); setOperationStatus(''); const msg = e instanceof Error ? e.message : t('backup.failedToRestore'); setRestoreResult({ success: false, message: msg }); showToast(msg, 'error'); }, }); // Block navigation while backup/restore is in progress useEffect(() => { const isOperationInProgress = isExporting || isRestoring; if (isOperationInProgress) { const handleBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?'; return e.returnValue; }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); } }, [isExporting, isRestoring]); // Test connection state const [testLoading, setTestLoading] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); // Auto-save debounce const settingsAutoSaveTimerRef = useRef | null>(null); const tokenAutoSaveTimerRef = useRef | null>(null); const initializationTimerRef = useRef | null>(null); const lastSavedAutosaveStateRef = useRef(null); const lastTokenScheduledForSaveRef = useRef(''); const [isInitialized, setIsInitialized] = useState(false); const autoSaveState = useMemo(() => ({ repository_url: repoUrl, branch, provider, allow_insecure_http: allowInsecureHttp, schedule_enabled: scheduleEnabled, schedule_type: scheduleType, backup_kprofiles: backupKProfiles, backup_cloud_profiles: backupCloudProfiles, backup_settings: backupSettings, backup_spools: backupSpools, backup_archives: backupArchives, enabled, }), [repoUrl, branch, provider, allowInsecureHttp, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled]); const autoSaveStateFingerprint = useMemo( () => serializeAutosaveState(autoSaveState), [autoSaveState] ); // Queries const { data: config, isLoading: configLoading } = useQuery({ queryKey: ['github-backup-config'], queryFn: api.getGitHubBackupConfig, }); const { data: status } = useQuery({ queryKey: ['github-backup-status'], queryFn: api.getGitHubBackupStatus, refetchInterval: (query) => query.state.data?.is_running ? 500 : 10000, // Poll fast during backup }); const { data: logs } = useQuery({ queryKey: ['github-backup-logs'], queryFn: () => api.getGitHubBackupLogs(20), }); const { data: cloudStatus } = useQuery({ queryKey: ['cloud-status'], queryFn: api.getCloudStatus, }); // Fetch printers and their statuses for K-profile availability const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); // Fetch printer statuses from API (not just cache) to get accurate connection status const printerStatusQueries = useQueries({ queries: (printers ?? []).map(printer => ({ queryKey: ['printerStatus', printer.id], queryFn: () => api.getPrinterStatus(printer.id), staleTime: 10000, // Consider stale after 10s refetchInterval: 30000, // Refresh every 30s })), }); const printerStatuses = (printers ?? []).map((printer, index) => ({ printer, connected: printerStatusQueries[index]?.data?.connected ?? false, })); const totalPrinters = printerStatuses.length; const connectedPrinters = printerStatuses.filter(p => p.connected).length; const noPrintersConnected = totalPrinters > 0 && connectedPrinters === 0; const somePrintersDisconnected = connectedPrinters > 0 && connectedPrinters < totalPrinters; // Initialize form from config useEffect(() => { if (initializationTimerRef.current) { clearTimeout(initializationTimerRef.current); } if (config) { setIsInitialized(false); lastSavedAutosaveStateRef.current = { repository_url: config.repository_url, branch: config.branch, provider: config.provider ?? 'github', allow_insecure_http: config.allow_insecure_http ?? false, schedule_enabled: config.schedule_enabled, schedule_type: config.schedule_type, backup_kprofiles: config.backup_kprofiles, backup_cloud_profiles: config.backup_cloud_profiles, backup_settings: config.backup_settings, backup_spools: config.backup_spools, backup_archives: config.backup_archives, enabled: config.enabled, }; setRepoUrl(config.repository_url); setBranch(config.branch); setProvider(config.provider ?? 'github'); setScheduleEnabled(config.schedule_enabled); setScheduleType(config.schedule_type); setBackupKProfiles(config.backup_kprofiles); setBackupCloudProfiles(config.backup_cloud_profiles); setBackupSettings(config.backup_settings); setBackupSpools(config.backup_spools); setBackupArchives(config.backup_archives); setAllowInsecureHttp(config.allow_insecure_http ?? false); setEnabled(config.enabled); setAccessToken(''); // Don't show stored token // Mark as initialized after a tick to avoid auto-save on initial load initializationTimerRef.current = setTimeout(() => { setIsInitialized(true); }, 100); } else { setIsInitialized(false); lastSavedAutosaveStateRef.current = null; setAccessToken(''); } return () => { if (initializationTimerRef.current) { clearTimeout(initializationTimerRef.current); } }; }, [config]); // Auto-save function for existing configs const autoSave = useCallback(async (includeToken: boolean = false) => { if (!config) return; // Only auto-save if config already exists try { if (includeToken && accessToken) { // Full save with new token await api.saveGitHubBackupConfig({ ...autoSaveState, access_token: accessToken, }); setAccessToken(''); // Clear after save showToast(t('backup.tokenUpdated')); lastTokenScheduledForSaveRef.current = ''; } else { // Update without token await api.updateGitHubBackupConfig(getChangedAutosaveFields( autoSaveState, lastSavedAutosaveStateRef.current )); showToast(t('backup.settingsSaved')); } lastSavedAutosaveStateRef.current = autoSaveState; queryClient.invalidateQueries({ queryKey: ['github-backup-config'] }); queryClient.invalidateQueries({ queryKey: ['github-backup-status'] }); } catch (error) { showToast(t('backup.failedToSave', { message: (error as Error).message }), 'error'); } }, [config, accessToken, autoSaveState, queryClient, showToast, t]); const autoSaveRef = useRef(autoSave); useEffect(() => { autoSaveRef.current = autoSave; }, [autoSave]); // Auto-save effect for existing configs (debounced) useEffect(() => { if (!isInitialized || !config) return; if ( lastSavedAutosaveStateRef.current && autoSaveStateFingerprint === serializeAutosaveState(lastSavedAutosaveStateRef.current) ) return; if (settingsAutoSaveTimerRef.current) { clearTimeout(settingsAutoSaveTimerRef.current); } settingsAutoSaveTimerRef.current = setTimeout(() => { autoSave(false); }, 500); return () => { if (settingsAutoSaveTimerRef.current) { clearTimeout(settingsAutoSaveTimerRef.current); } }; }, [isInitialized, config, autoSaveStateFingerprint, autoSave]); // Auto-save token when it changes (with longer debounce) useEffect(() => { if (!isInitialized || !config || !accessToken) return; if (accessToken === lastTokenScheduledForSaveRef.current) return; lastTokenScheduledForSaveRef.current = accessToken; if (tokenAutoSaveTimerRef.current) { clearTimeout(tokenAutoSaveTimerRef.current); } tokenAutoSaveTimerRef.current = setTimeout(() => { autoSaveRef.current(true); }, 1000); return () => { if (tokenAutoSaveTimerRef.current) { clearTimeout(tokenAutoSaveTimerRef.current); } }; }, [isInitialized, accessToken, config]); // Mutations const saveConfigMutation = useMutation({ mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['github-backup-config'] }); queryClient.invalidateQueries({ queryKey: ['github-backup-status'] }); showToast(t('backup.githubBackupEnabled')); setAccessToken(''); setIsInitialized(true); }, onError: (error: Error) => { showToast(t('backup.failedToSave', { message: error.message }), 'error'); }, }); const triggerBackupMutation = useMutation({ mutationFn: api.triggerGitHubBackup, onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['github-backup-status'] }); queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] }); if (result.success) { if (result.files_changed > 0) { showToast(t('backup.backupCompleteFiles', { count: result.files_changed })); } else { showToast(t('backup.backupSkippedNoChanges')); } } else { showToast(t('backup.backupFailed2', { message: result.message }), 'error'); } }, onError: (error: Error) => { showToast(t('backup.backupFailed2', { message: error.message }), 'error'); }, }); const clearLogsMutation = useMutation<{ deleted: number; message: string }, Error>({ mutationFn: () => api.clearGitHubBackupLogs(0), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] }); showToast(t('backup.clearedLogs', { count: result.deleted })); }, onError: (error: Error) => { showToast(t('backup.failedToClearLogs', { message: error.message }), 'error'); }, }); const handleTestConnection = async () => { setTestLoading(true); setTestResult(null); try { let result; // If user entered a new token, test with those credentials if (accessToken) { if (!repoUrl) { showToast(t('backup.enterRepoUrl'), 'error'); setTestLoading(false); return; } result = await api.testGitHubConnection(repoUrl, accessToken, provider); } else if (config?.has_token) { // Use stored credentials result = await api.testGitHubStoredConnection(); } else { showToast(t('backup.enterRepoAndToken'), 'error'); setTestLoading(false); return; } setTestResult({ success: result.success, message: result.message }); } catch (error) { setTestResult({ success: false, message: (error as Error).message }); } finally { setTestLoading(false); } }; // Initial setup save (only for new configs) const handleInitialSetup = () => { if (!repoUrl) { showToast(t('backup.repoRequired'), 'error'); return; } if (!accessToken) { showToast(t('backup.tokenRequired'), 'error'); return; } saveConfigMutation.mutate({ repository_url: repoUrl, access_token: accessToken, branch, provider, allow_insecure_http: allowInsecureHttp, schedule_enabled: scheduleEnabled, schedule_type: scheduleType, backup_kprofiles: backupKProfiles, backup_cloud_profiles: backupCloudProfiles, backup_settings: backupSettings, backup_spools: backupSpools, backup_archives: backupArchives, enabled, }); }; if (configLoading) { return (
); } return (
{/* Left Column - Git Backup */}

{t('backup.githubBackup')}

{config && (
{t('backup.enabled')}
)}

{t('backup.githubDescription')}

{/* Provider Selection */}
{/* Repository URL */}
{ setRepoUrl(e.target.value); setTestResult(null); }} placeholder={t(PROVIDER_REPO_URL_I18N_KEY[provider])} className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
{/* Access Token */}
{ setAccessToken(e.target.value); setTestResult(null); }} placeholder={config?.has_token ? t('backup.enterNewToken') : PROVIDER_TOKEN_PLACEHOLDER[provider]} className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />

{t('backup.tokenHint')}

{/* Branch - inline with schedule */}
setBranch(e.target.value)} placeholder="main" className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
{/* What to backup */}
{/* Test + Status + Actions */}
{/* Status line */} {status?.configured && (
{status.last_backup_at ? ( <> {t('backup.lastBackupAt')} {formatRelativeTime(status.last_backup_at, 'system', t)} ) : ( {t('backup.noBackupsYet')} )}
{status.next_scheduled_run && ( {t('backup.next')} {formatRelativeTime(status.next_scheduled_run, 'system', t)} )}
)} {/* Test result */} {testResult && (
{testResult.success ? : } {testResult.message}
)} {/* Action buttons */}
{status?.configured ? ( <> {(triggerBackupMutation.isPending || status.is_running) ? (
{status.progress || t('backup.startingBackup')}
) : ( <> )} ) : ( <> )}
{/* Backup History - only show if configured and has logs */} {logs && logs.length > 0 && (

{t('backup.history')}

{logs.slice(0, 10).map((log) => ( ))}
{t('backup.date')} {t('backup.status')} {t('backup.commit')}
{formatDateTime(log.started_at)} {log.commit_sha ? ( {log.commit_sha.substring(0, 7)} ) : ( - )}
)}
{/* Right Column - Local Backup */}

{t('backup.localBackup')}

{t('backup.localBackupDescription')}

{/* Export */}

{t('backup.downloadBackupLabel')}

{t('backup.completeBackupZip')}

{/* Import */}

{t('backup.restoreBackup')}

{t('backup.restoreDescription')}

{t('backup.restoreNote')}

{ const file = e.target.files?.[0]; if (file) { setRestoreFile(file); setShowRestoreConfirm(true); } e.target.value = ''; }} />
{/* Restore result message */} {restoreResult && (
{restoreResult.success ? ( ) : ( )}
{restoreResult.message} {restoreResult.success && (
)}
)} {/* Warning */}
{t('backup.restoreReplacesAll')}{' '} {t('backup.restoreReplacesAllDetail')}
{/* Scheduled Local Backups */}

{t('backup.scheduledBackup')}

{ try { await api.updateSettings({ local_backup_enabled: checked }); queryClient.invalidateQueries({ queryKey: ['settings'] }); showToast(t('backup.settingsSaved')); } catch (e) { showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error'); } refetchLocalStatus(); }} />

{t('backup.scheduledBackupDescription')}

{localBackupStatus?.enabled && ( <> {/* Schedule + Time + Retention */}
{(localBackupStatus?.schedule ?? 'daily') !== 'hourly' && (
{ try { await api.updateSettings({ local_backup_time: e.target.value }); showToast(t('backup.settingsSaved')); } catch (err) { showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error'); } refetchLocalStatus(); }} />

{t('backup.utc')}

)}
{ const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 5)); try { await api.updateSettings({ local_backup_retention: val }); showToast(t('backup.settingsSaved')); } catch (e) { showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error'); } refetchLocalStatus(); }} />

{t('backup.retentionDescription')}

{/* Output Path */}
setLocalBackupPath(e.target.value)} className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" onBlur={async () => { try { await api.updateSettings({ local_backup_path: localBackupPath }); showToast(t('backup.settingsSaved')); } catch (err) { showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error'); } refetchLocalStatus(); refetchLocalBackups(); }} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />

{localBackupPath ? t('backup.outputPathDescription') : <>{t('backup.defaultPathLabel')} {localBackupStatus?.default_path || '...'} }

{/* Status + Run Now */}
{localBackupStatus?.last_backup_at && (
{t('backup.lastBackup')}: {formatRelativeTime(localBackupStatus.last_backup_at)}
)} {localBackupStatus?.next_run && (
{t('backup.nextBackup')}: {formatDateTime(localBackupStatus.next_run)}
)}
{/* Backup Files List */} {localBackups && localBackups.length > 0 && (

{t('backup.backupFiles')}

{localBackups.map((file) => (
{file.filename} {(file.size / 1024 / 1024).toFixed(1)} MB · {formatDateTime(file.created_at)}
))}
)} {localBackups && localBackups.length === 0 && (

{t('backup.noScheduledBackups')}

)} )}
{/* Delete Backup Confirmation Modal */} {deleteConfirmFile && ( deleteLocalBackupMutation.mutate(deleteConfirmFile)} onCancel={() => setDeleteConfirmFile(null)} /> )} {/* Restore from Scheduled Backup Confirmation Modal */} {restoreConfirmFile && ( restoreLocalBackupMutation.mutate(restoreConfirmFile)} onCancel={() => setRestoreConfirmFile(null)} /> )} {/* Restore Confirmation Modal */} {showRestoreConfirm && restoreFile && ( { setShowRestoreConfirm(false); setIsRestoring(true); setRestoreResult(null); try { setOperationStatus(t('backup.uploadingFile')); const result = await api.importBackup(restoreFile); setRestoreResult(result); if (result.success) { showToast(t('backup.backupRestoredRestart'), 'success'); } else { showToast(result.message, 'error'); } } catch (e) { const message = e instanceof Error ? e.message : t('backup.failedToRestore'); setRestoreResult({ success: false, message }); showToast(message, 'error'); } finally { setIsRestoring(false); setOperationStatus(''); setRestoreFile(null); } }} onCancel={() => { setShowRestoreConfirm(false); setRestoreFile(null); }} /> )} {/* Blocking overlay during backup/restore operations */} {(isExporting || isRestoring) && (

{isExporting ? t('backup.creatingBackup') : t('backup.restoringBackup')}

{operationStatus || (isExporting ? t('backup.preparing') : t('backup.processing'))}

{t('backup.doNotClosePage')}

)}
); }