GitHubBackupSettings.tsx 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236
  1. import { useState, useEffect, useRef, useCallback } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import {
  5. Github,
  6. Play,
  7. Clock,
  8. CheckCircle,
  9. XCircle,
  10. Loader2,
  11. ExternalLink,
  12. RefreshCw,
  13. Download,
  14. Upload,
  15. Database,
  16. History,
  17. SkipForward,
  18. AlertTriangle,
  19. Trash2,
  20. RotateCcw,
  21. FolderArchive,
  22. } from 'lucide-react';
  23. import { api } from '../api/client';
  24. import type {
  25. GitHubBackupConfig,
  26. GitHubBackupConfigCreate,
  27. GitHubBackupLog,
  28. GitHubBackupStatus,
  29. GitHubBackupTriggerResponse,
  30. LocalBackupFile,
  31. LocalBackupStatus,
  32. ScheduleType,
  33. CloudAuthStatus,
  34. Printer,
  35. } from '../api/client';
  36. import { Card, CardContent, CardHeader } from './Card';
  37. import { Button } from './Button';
  38. import { Toggle } from './Toggle';
  39. import { ConfirmModal } from './ConfirmModal';
  40. import { useToast } from '../contexts/ToastContext';
  41. import { formatRelativeTime, parseUTCDate } from '../utils/date';
  42. function formatDateTime(dateStr: string | null): string {
  43. if (!dateStr) return '-';
  44. const date = parseUTCDate(dateStr);
  45. if (!date) return '-';
  46. return date.toLocaleString();
  47. }
  48. interface StatusBadgeProps {
  49. status: string | null;
  50. }
  51. function StatusBadge({ status }: StatusBadgeProps) {
  52. if (!status) return null;
  53. const styles: Record<string, string> = {
  54. success: 'bg-green-500/20 text-green-400',
  55. failed: 'bg-red-500/20 text-red-400',
  56. skipped: 'bg-yellow-500/20 text-yellow-400',
  57. running: 'bg-blue-500/20 text-blue-400',
  58. };
  59. const icons: Record<string, React.ReactNode> = {
  60. success: <CheckCircle className="w-3 h-3" />,
  61. failed: <XCircle className="w-3 h-3" />,
  62. skipped: <SkipForward className="w-3 h-3" />,
  63. running: <Loader2 className="w-3 h-3 animate-spin" />,
  64. };
  65. return (
  66. <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${styles[status] || 'bg-gray-500/20 text-gray-400'}`}>
  67. {icons[status]}
  68. {status.charAt(0).toUpperCase() + status.slice(1)}
  69. </span>
  70. );
  71. }
  72. export function GitHubBackupSettings() {
  73. const queryClient = useQueryClient();
  74. const { showToast } = useToast();
  75. const { t } = useTranslation();
  76. // Local state for form
  77. const [repoUrl, setRepoUrl] = useState('');
  78. const [accessToken, setAccessToken] = useState('');
  79. const [branch, setBranch] = useState('main');
  80. const [scheduleEnabled, setScheduleEnabled] = useState(false);
  81. const [scheduleType, setScheduleType] = useState<ScheduleType>('daily');
  82. const [backupKProfiles, setBackupKProfiles] = useState(true);
  83. const [backupCloudProfiles, setBackupCloudProfiles] = useState(true);
  84. const [backupSettings, setBackupSettings] = useState(false);
  85. const [backupSpools, setBackupSpools] = useState(false);
  86. const [backupArchives, setBackupArchives] = useState(false);
  87. const [enabled, setEnabled] = useState(true);
  88. // Local backup state
  89. const [isExporting, setIsExporting] = useState(false);
  90. const [isRestoring, setIsRestoring] = useState(false);
  91. const [operationStatus, setOperationStatus] = useState<string>('');
  92. const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
  93. const [restoreFile, setRestoreFile] = useState<File | null>(null);
  94. const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
  95. const fileInputRef = useRef<HTMLInputElement>(null);
  96. // Scheduled local backup state
  97. const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
  98. const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
  99. const [localBackupPath, setLocalBackupPath] = useState('');
  100. const { data: localBackupStatus, refetch: refetchLocalStatus } = useQuery<LocalBackupStatus>({
  101. queryKey: ['local-backup-status'],
  102. queryFn: api.getLocalBackupStatus,
  103. refetchInterval: (query) => query.state.data?.is_running ? 1000 : 10000,
  104. });
  105. const { data: localBackups, refetch: refetchLocalBackups } = useQuery<LocalBackupFile[]>({
  106. queryKey: ['local-backup-files'],
  107. queryFn: api.getLocalBackups,
  108. refetchInterval: 30000,
  109. });
  110. // Sync local path state from server
  111. useEffect(() => {
  112. if (localBackupStatus?.path !== undefined) {
  113. setLocalBackupPath(localBackupStatus.path);
  114. }
  115. }, [localBackupStatus?.path]);
  116. const triggerLocalBackupMutation = useMutation({
  117. mutationFn: api.triggerLocalBackup,
  118. onSuccess: (data) => {
  119. if (data.success) {
  120. showToast(t('backup.scheduledBackupComplete'));
  121. } else {
  122. showToast(data.message, 'error');
  123. }
  124. refetchLocalStatus();
  125. refetchLocalBackups();
  126. },
  127. onError: () => showToast(t('backup.scheduledBackupFailed'), 'error'),
  128. });
  129. const deleteLocalBackupMutation = useMutation({
  130. mutationFn: (filename: string) => api.deleteLocalBackup(filename),
  131. onSuccess: () => {
  132. refetchLocalBackups();
  133. setDeleteConfirmFile(null);
  134. },
  135. });
  136. const restoreLocalBackupMutation = useMutation({
  137. mutationFn: async (filename: string) => {
  138. setRestoreConfirmFile(null);
  139. setIsRestoring(true);
  140. setRestoreResult(null);
  141. setOperationStatus(t('backup.restoring'));
  142. return api.restoreLocalBackup(filename);
  143. },
  144. onSuccess: (data) => {
  145. setIsRestoring(false);
  146. setOperationStatus('');
  147. if (data.success) {
  148. setRestoreResult({ success: true, message: data.message });
  149. showToast(t('backup.backupRestoredRestart'), 'success');
  150. } else {
  151. setRestoreResult({ success: false, message: data.message });
  152. showToast(data.message, 'error');
  153. }
  154. },
  155. onError: (e) => {
  156. setIsRestoring(false);
  157. setOperationStatus('');
  158. const msg = e instanceof Error ? e.message : t('backup.failedToRestore');
  159. setRestoreResult({ success: false, message: msg });
  160. showToast(msg, 'error');
  161. },
  162. });
  163. // Block navigation while backup/restore is in progress
  164. useEffect(() => {
  165. const isOperationInProgress = isExporting || isRestoring;
  166. if (isOperationInProgress) {
  167. const handleBeforeUnload = (e: BeforeUnloadEvent) => {
  168. e.preventDefault();
  169. e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?';
  170. return e.returnValue;
  171. };
  172. window.addEventListener('beforeunload', handleBeforeUnload);
  173. return () => window.removeEventListener('beforeunload', handleBeforeUnload);
  174. }
  175. }, [isExporting, isRestoring]);
  176. // Test connection state
  177. const [testLoading, setTestLoading] = useState(false);
  178. const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
  179. // Auto-save debounce
  180. const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
  181. const isInitializedRef = useRef(false);
  182. // Queries
  183. const { data: config, isLoading: configLoading } = useQuery<GitHubBackupConfig | null>({
  184. queryKey: ['github-backup-config'],
  185. queryFn: api.getGitHubBackupConfig,
  186. });
  187. const { data: status } = useQuery<GitHubBackupStatus>({
  188. queryKey: ['github-backup-status'],
  189. queryFn: api.getGitHubBackupStatus,
  190. refetchInterval: (query) => query.state.data?.is_running ? 500 : 10000, // Poll fast during backup
  191. });
  192. const { data: logs } = useQuery<GitHubBackupLog[]>({
  193. queryKey: ['github-backup-logs'],
  194. queryFn: () => api.getGitHubBackupLogs(20),
  195. });
  196. const { data: cloudStatus } = useQuery<CloudAuthStatus>({
  197. queryKey: ['cloud-status'],
  198. queryFn: api.getCloudStatus,
  199. });
  200. // Fetch printers and their statuses for K-profile availability
  201. const { data: printers } = useQuery<Printer[]>({
  202. queryKey: ['printers'],
  203. queryFn: api.getPrinters,
  204. });
  205. // Fetch printer statuses from API (not just cache) to get accurate connection status
  206. const printerStatusQueries = useQueries({
  207. queries: (printers ?? []).map(printer => ({
  208. queryKey: ['printerStatus', printer.id],
  209. queryFn: () => api.getPrinterStatus(printer.id),
  210. staleTime: 10000, // Consider stale after 10s
  211. refetchInterval: 30000, // Refresh every 30s
  212. })),
  213. });
  214. const printerStatuses = (printers ?? []).map((printer, index) => ({
  215. printer,
  216. connected: printerStatusQueries[index]?.data?.connected ?? false,
  217. }));
  218. const totalPrinters = printerStatuses.length;
  219. const connectedPrinters = printerStatuses.filter(p => p.connected).length;
  220. const noPrintersConnected = totalPrinters > 0 && connectedPrinters === 0;
  221. const somePrintersDisconnected = connectedPrinters > 0 && connectedPrinters < totalPrinters;
  222. // Initialize form from config
  223. useEffect(() => {
  224. if (config) {
  225. setRepoUrl(config.repository_url);
  226. setBranch(config.branch);
  227. setScheduleEnabled(config.schedule_enabled);
  228. setScheduleType(config.schedule_type);
  229. setBackupKProfiles(config.backup_kprofiles);
  230. setBackupCloudProfiles(config.backup_cloud_profiles);
  231. setBackupSettings(config.backup_settings);
  232. setBackupSpools(config.backup_spools);
  233. setBackupArchives(config.backup_archives);
  234. setEnabled(config.enabled);
  235. setAccessToken(''); // Don't show stored token
  236. // Mark as initialized after a tick to avoid auto-save on initial load
  237. setTimeout(() => { isInitializedRef.current = true; }, 100);
  238. }
  239. }, [config]);
  240. // Auto-save function for existing configs
  241. const autoSave = useCallback(async (includeToken: boolean = false) => {
  242. if (!config?.has_token) return; // Only auto-save if config already exists
  243. try {
  244. if (includeToken && accessToken) {
  245. // Full save with new token
  246. await api.saveGitHubBackupConfig({
  247. repository_url: repoUrl,
  248. access_token: accessToken,
  249. branch,
  250. schedule_enabled: scheduleEnabled,
  251. schedule_type: scheduleType,
  252. backup_kprofiles: backupKProfiles,
  253. backup_cloud_profiles: backupCloudProfiles,
  254. backup_settings: backupSettings,
  255. backup_spools: backupSpools,
  256. backup_archives: backupArchives,
  257. enabled,
  258. });
  259. setAccessToken(''); // Clear after save
  260. showToast(t('backup.tokenUpdated'));
  261. } else {
  262. // Update without token
  263. await api.updateGitHubBackupConfig({
  264. repository_url: repoUrl,
  265. branch,
  266. schedule_enabled: scheduleEnabled,
  267. schedule_type: scheduleType,
  268. backup_kprofiles: backupKProfiles,
  269. backup_cloud_profiles: backupCloudProfiles,
  270. backup_settings: backupSettings,
  271. backup_spools: backupSpools,
  272. backup_archives: backupArchives,
  273. enabled,
  274. });
  275. showToast(t('backup.settingsSaved'));
  276. }
  277. queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
  278. queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
  279. } catch (error) {
  280. showToast(t('backup.failedToSave', { message: (error as Error).message }), 'error');
  281. }
  282. }, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, queryClient, showToast, t]);
  283. // Auto-save effect for existing configs (debounced)
  284. useEffect(() => {
  285. if (!isInitializedRef.current || !config?.has_token) return;
  286. if (autoSaveTimerRef.current) {
  287. clearTimeout(autoSaveTimerRef.current);
  288. }
  289. autoSaveTimerRef.current = setTimeout(() => {
  290. autoSave(false);
  291. }, 500);
  292. return () => {
  293. if (autoSaveTimerRef.current) {
  294. clearTimeout(autoSaveTimerRef.current);
  295. }
  296. };
  297. }, [repoUrl, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, autoSave, config?.has_token]);
  298. // Auto-save token when it changes (with longer debounce)
  299. useEffect(() => {
  300. if (!isInitializedRef.current || !config?.has_token || !accessToken) return;
  301. if (autoSaveTimerRef.current) {
  302. clearTimeout(autoSaveTimerRef.current);
  303. }
  304. autoSaveTimerRef.current = setTimeout(() => {
  305. autoSave(true);
  306. }, 1000);
  307. return () => {
  308. if (autoSaveTimerRef.current) {
  309. clearTimeout(autoSaveTimerRef.current);
  310. }
  311. };
  312. }, [accessToken, autoSave, config?.has_token]);
  313. // Mutations
  314. const saveConfigMutation = useMutation({
  315. mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
  316. onSuccess: () => {
  317. queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
  318. queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
  319. showToast(t('backup.githubBackupEnabled'));
  320. setAccessToken('');
  321. isInitializedRef.current = true;
  322. },
  323. onError: (error: Error) => {
  324. showToast(t('backup.failedToSave', { message: error.message }), 'error');
  325. },
  326. });
  327. const triggerBackupMutation = useMutation<GitHubBackupTriggerResponse, Error>({
  328. mutationFn: api.triggerGitHubBackup,
  329. onSuccess: (result) => {
  330. queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
  331. queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
  332. if (result.success) {
  333. if (result.files_changed > 0) {
  334. showToast(t('backup.backupCompleteFiles', { count: result.files_changed }));
  335. } else {
  336. showToast(t('backup.backupSkippedNoChanges'));
  337. }
  338. } else {
  339. showToast(t('backup.backupFailed2', { message: result.message }), 'error');
  340. }
  341. },
  342. onError: (error: Error) => {
  343. showToast(t('backup.backupFailed2', { message: error.message }), 'error');
  344. },
  345. });
  346. const clearLogsMutation = useMutation<{ deleted: number; message: string }, Error>({
  347. mutationFn: () => api.clearGitHubBackupLogs(0),
  348. onSuccess: (result) => {
  349. queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
  350. showToast(t('backup.clearedLogs', { count: result.deleted }));
  351. },
  352. onError: (error: Error) => {
  353. showToast(t('backup.failedToClearLogs', { message: error.message }), 'error');
  354. },
  355. });
  356. const handleTestConnection = async () => {
  357. setTestLoading(true);
  358. setTestResult(null);
  359. try {
  360. let result;
  361. // If user entered a new token, test with those credentials
  362. if (accessToken) {
  363. if (!repoUrl) {
  364. showToast(t('backup.enterRepoUrl'), 'error');
  365. setTestLoading(false);
  366. return;
  367. }
  368. result = await api.testGitHubConnection(repoUrl, accessToken);
  369. } else if (config?.has_token) {
  370. // Use stored credentials
  371. result = await api.testGitHubStoredConnection();
  372. } else {
  373. showToast(t('backup.enterRepoAndToken'), 'error');
  374. setTestLoading(false);
  375. return;
  376. }
  377. setTestResult({ success: result.success, message: result.message });
  378. } catch (error) {
  379. setTestResult({ success: false, message: (error as Error).message });
  380. } finally {
  381. setTestLoading(false);
  382. }
  383. };
  384. // Initial setup save (only for new configs)
  385. const handleInitialSetup = () => {
  386. if (!repoUrl) {
  387. showToast(t('backup.repoRequired'), 'error');
  388. return;
  389. }
  390. if (!accessToken) {
  391. showToast(t('backup.tokenRequired'), 'error');
  392. return;
  393. }
  394. saveConfigMutation.mutate({
  395. repository_url: repoUrl,
  396. access_token: accessToken,
  397. branch,
  398. schedule_enabled: scheduleEnabled,
  399. schedule_type: scheduleType,
  400. backup_kprofiles: backupKProfiles,
  401. backup_cloud_profiles: backupCloudProfiles,
  402. backup_settings: backupSettings,
  403. backup_spools: backupSpools,
  404. backup_archives: backupArchives,
  405. enabled,
  406. });
  407. };
  408. if (configLoading) {
  409. return (
  410. <div className="flex items-center justify-center py-12">
  411. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  412. </div>
  413. );
  414. }
  415. return (
  416. <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
  417. {/* Left Column - GitHub Backup */}
  418. <div className="space-y-6">
  419. <Card id="card-backup-github">
  420. <CardHeader>
  421. <div className="flex items-center justify-between">
  422. <div className="flex items-center gap-2">
  423. <Github className="w-5 h-5 text-gray-400" />
  424. <h2 className="text-lg font-semibold text-white">{t('backup.githubBackup')}</h2>
  425. </div>
  426. {config && (
  427. <div className="flex items-center gap-2">
  428. <span className="text-sm text-bambu-gray">{t('backup.enabled')}</span>
  429. <Toggle
  430. checked={enabled}
  431. onChange={setEnabled}
  432. />
  433. </div>
  434. )}
  435. </div>
  436. </CardHeader>
  437. <CardContent className="space-y-4">
  438. <p className="text-sm text-bambu-gray">
  439. {t('backup.githubDescription')}
  440. </p>
  441. {/* Repository URL */}
  442. <div>
  443. <label className="block text-sm text-bambu-gray mb-1">
  444. {t('backup.repositoryUrl')}
  445. </label>
  446. <input
  447. type="text"
  448. value={repoUrl}
  449. onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); }}
  450. placeholder="https://github.com/username/bambuddy-backup"
  451. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  452. />
  453. </div>
  454. {/* Access Token */}
  455. <div>
  456. <label className="block text-sm text-bambu-gray mb-1">
  457. {t('backup.personalAccessToken')} {config?.has_token && <span className="text-green-400">{t('backup.tokenSaved')}</span>}
  458. </label>
  459. <input
  460. type="password"
  461. value={accessToken}
  462. onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}
  463. placeholder={config?.has_token ? t('backup.enterNewToken') : 'ghp_xxxxxxxxxxxx'}
  464. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  465. />
  466. <p className="text-xs text-bambu-gray mt-1">
  467. {t('backup.tokenHint')}
  468. </p>
  469. </div>
  470. {/* Branch - inline with schedule */}
  471. <div className="grid grid-cols-2 gap-4">
  472. <div>
  473. <label className="block text-sm text-bambu-gray mb-1">{t('backup.branch')}</label>
  474. <input
  475. type="text"
  476. value={branch}
  477. onChange={(e) => setBranch(e.target.value)}
  478. placeholder="main"
  479. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  480. />
  481. </div>
  482. <div>
  483. <label className="block text-sm text-bambu-gray mb-1">{t('backup.autoBackup')}</label>
  484. <select
  485. value={scheduleEnabled ? scheduleType : 'disabled'}
  486. onChange={(e) => {
  487. if (e.target.value === 'disabled') {
  488. setScheduleEnabled(false);
  489. } else {
  490. setScheduleEnabled(true);
  491. setScheduleType(e.target.value as ScheduleType);
  492. }
  493. }}
  494. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  495. >
  496. <option value="disabled">{t('backup.manualOnly')}</option>
  497. <option value="hourly">{t('backup.hourly')}</option>
  498. <option value="daily">{t('backup.daily')}</option>
  499. <option value="weekly">{t('backup.weekly')}</option>
  500. </select>
  501. </div>
  502. </div>
  503. {/* What to backup */}
  504. <div>
  505. <label className="block text-sm text-bambu-gray mb-2">{t('backup.includeInBackup')}</label>
  506. <div className="space-y-2">
  507. <label className={`flex items-start gap-2 ${noPrintersConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
  508. <input
  509. type="checkbox"
  510. checked={backupKProfiles}
  511. onChange={(e) => setBackupKProfiles(e.target.checked)}
  512. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  513. disabled={noPrintersConnected}
  514. />
  515. <div className="flex-1">
  516. <div className="flex items-center gap-2">
  517. <span className={`text-sm ${noPrintersConnected ? 'text-bambu-gray' : 'text-white'}`}>{t('backup.kProfiles')}</span>
  518. {noPrintersConnected && (
  519. <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
  520. <AlertTriangle className="w-3 h-3" />
  521. {t('backup.noPrintersConnected')}
  522. </span>
  523. )}
  524. {somePrintersDisconnected && (
  525. <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
  526. <AlertTriangle className="w-3 h-3" />
  527. {t('backup.printersConnected', { connected: connectedPrinters, total: totalPrinters })}
  528. </span>
  529. )}
  530. </div>
  531. <p className="text-xs text-bambu-gray">{t('backup.kProfilesDescription')}</p>
  532. </div>
  533. </label>
  534. <label className={`flex items-start gap-2 ${!cloudStatus?.is_authenticated ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
  535. <input
  536. type="checkbox"
  537. checked={backupCloudProfiles}
  538. onChange={(e) => setBackupCloudProfiles(e.target.checked)}
  539. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  540. disabled={!cloudStatus?.is_authenticated}
  541. />
  542. <div>
  543. <div className="flex items-center gap-2">
  544. <span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
  545. {!cloudStatus?.is_authenticated && (
  546. <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
  547. <AlertTriangle className="w-3 h-3" />
  548. {t('backup.cloudLoginRequiredShort')}
  549. </span>
  550. )}
  551. </div>
  552. <p className="text-xs text-bambu-gray">{t('backup.cloudProfilesDescription')}</p>
  553. </div>
  554. </label>
  555. <label className="flex items-start gap-2 cursor-pointer">
  556. <input
  557. type="checkbox"
  558. checked={backupSettings}
  559. onChange={(e) => setBackupSettings(e.target.checked)}
  560. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  561. />
  562. <div>
  563. <span className="text-white text-sm">{t('backup.appSettings')}</span>
  564. <p className="text-xs text-bambu-gray">{t('backup.appSettingsDescription')}</p>
  565. </div>
  566. </label>
  567. <label className="flex items-start gap-2 cursor-pointer">
  568. <input
  569. type="checkbox"
  570. checked={backupSpools}
  571. onChange={(e) => setBackupSpools(e.target.checked)}
  572. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  573. />
  574. <div>
  575. <span className="text-white text-sm">{t('backup.spoolInventory')}</span>
  576. <p className="text-xs text-bambu-gray">{t('backup.spoolInventoryDescription')}</p>
  577. </div>
  578. </label>
  579. <label className="flex items-start gap-2 cursor-pointer">
  580. <input
  581. type="checkbox"
  582. checked={backupArchives}
  583. onChange={(e) => setBackupArchives(e.target.checked)}
  584. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  585. />
  586. <div>
  587. <span className="text-white text-sm">{t('backup.printArchives')}</span>
  588. <p className="text-xs text-bambu-gray">{t('backup.printArchivesDescription')}</p>
  589. </div>
  590. </label>
  591. </div>
  592. </div>
  593. {/* Test + Status + Actions */}
  594. <div className="border-t border-bambu-dark-tertiary pt-4 space-y-3">
  595. {/* Status line */}
  596. {status?.configured && (
  597. <div className="flex items-center justify-between text-sm">
  598. <div className="flex items-center gap-2 text-bambu-gray">
  599. {status.last_backup_at ? (
  600. <>
  601. <span>{t('backup.lastBackupAt')} {formatRelativeTime(status.last_backup_at, 'system', t)}</span>
  602. <StatusBadge status={status.last_backup_status} />
  603. </>
  604. ) : (
  605. <span>{t('backup.noBackupsYet')}</span>
  606. )}
  607. </div>
  608. {status.next_scheduled_run && (
  609. <span className="text-bambu-gray">
  610. <Clock className="w-3 h-3 inline mr-1" />
  611. {t('backup.next')} {formatRelativeTime(status.next_scheduled_run, 'system', t)}
  612. </span>
  613. )}
  614. </div>
  615. )}
  616. {/* Test result */}
  617. {testResult && (
  618. <div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
  619. {testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
  620. {testResult.message}
  621. </div>
  622. )}
  623. {/* Action buttons */}
  624. <div className="flex flex-wrap items-center gap-2">
  625. {status?.configured ? (
  626. <>
  627. {(triggerBackupMutation.isPending || status.is_running) ? (
  628. <div className="flex items-center gap-2 text-bambu-green">
  629. <Loader2 className="w-4 h-4 animate-spin" />
  630. <span className="text-sm">{status.progress || t('backup.startingBackup')}</span>
  631. </div>
  632. ) : (
  633. <>
  634. <Button
  635. variant="primary"
  636. size="sm"
  637. onClick={() => triggerBackupMutation.mutate()}
  638. disabled={!config?.enabled}
  639. >
  640. <Play className="w-4 h-4" />
  641. {t('backup.backupNow')}
  642. </Button>
  643. <Button
  644. variant="secondary"
  645. size="sm"
  646. onClick={handleTestConnection}
  647. disabled={testLoading}
  648. >
  649. {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
  650. {t('backup.test')}
  651. </Button>
  652. </>
  653. )}
  654. </>
  655. ) : (
  656. <>
  657. <Button
  658. variant="primary"
  659. size="sm"
  660. onClick={handleInitialSetup}
  661. disabled={saveConfigMutation.isPending || !repoUrl || !accessToken}
  662. >
  663. {saveConfigMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
  664. {t('backup.enableBackup')}
  665. </Button>
  666. <Button
  667. variant="secondary"
  668. size="sm"
  669. onClick={handleTestConnection}
  670. disabled={testLoading || !repoUrl || !accessToken}
  671. >
  672. {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
  673. {t('backup.testConnection')}
  674. </Button>
  675. </>
  676. )}
  677. </div>
  678. </div>
  679. </CardContent>
  680. </Card>
  681. {/* Backup History - only show if configured and has logs */}
  682. {logs && logs.length > 0 && (
  683. <Card id="card-backup-history">
  684. <CardHeader>
  685. <div className="flex items-center justify-between">
  686. <div className="flex items-center gap-2">
  687. <History className="w-5 h-5 text-gray-400" />
  688. <h2 className="text-lg font-semibold text-white">{t('backup.history')}</h2>
  689. </div>
  690. <Button
  691. variant="ghost"
  692. size="sm"
  693. onClick={() => clearLogsMutation.mutate()}
  694. disabled={clearLogsMutation.isPending}
  695. >
  696. <Trash2 className="w-4 h-4" />
  697. {t('backup.clear')}
  698. </Button>
  699. </div>
  700. </CardHeader>
  701. <CardContent>
  702. <div className="overflow-x-auto">
  703. <table className="w-full text-sm">
  704. <thead>
  705. <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
  706. <th className="text-left py-2 px-2">{t('backup.date')}</th>
  707. <th className="text-left py-2 px-2">{t('backup.status')}</th>
  708. <th className="text-left py-2 px-2">{t('backup.commit')}</th>
  709. </tr>
  710. </thead>
  711. <tbody>
  712. {logs.slice(0, 10).map((log) => (
  713. <tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
  714. <td className="py-2 px-2 text-white">{formatDateTime(log.started_at)}</td>
  715. <td className="py-2 px-2"><StatusBadge status={log.status} /></td>
  716. <td className="py-2 px-2">
  717. {log.commit_sha ? (
  718. <a
  719. href={`${config?.repository_url}/commit/${log.commit_sha}`}
  720. target="_blank"
  721. rel="noopener noreferrer"
  722. className="text-bambu-green hover:underline inline-flex items-center gap-1"
  723. >
  724. {log.commit_sha.substring(0, 7)}
  725. <ExternalLink className="w-3 h-3" />
  726. </a>
  727. ) : (
  728. <span className="text-bambu-gray">-</span>
  729. )}
  730. </td>
  731. </tr>
  732. ))}
  733. </tbody>
  734. </table>
  735. </div>
  736. </CardContent>
  737. </Card>
  738. )}
  739. </div>
  740. {/* Right Column - Local Backup */}
  741. <div className="space-y-6">
  742. <Card id="card-backup-local">
  743. <CardHeader>
  744. <div className="flex items-center gap-2">
  745. <Database className="w-5 h-5 text-gray-400" />
  746. <h2 className="text-lg font-semibold text-white">{t('backup.localBackup')}</h2>
  747. </div>
  748. </CardHeader>
  749. <CardContent className="space-y-4">
  750. <p className="text-sm text-bambu-gray">
  751. {t('backup.localBackupDescription')}
  752. </p>
  753. {/* Export */}
  754. <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
  755. <div>
  756. <p className="text-white">{t('backup.downloadBackupLabel')}</p>
  757. <p className="text-sm text-bambu-gray">
  758. {t('backup.completeBackupZip')}
  759. </p>
  760. </div>
  761. <Button
  762. variant="secondary"
  763. size="sm"
  764. disabled={isExporting || isRestoring}
  765. onClick={async () => {
  766. setIsExporting(true);
  767. setOperationStatus(t('backup.preparingBackup'));
  768. try {
  769. setOperationStatus(t('backup.creatingArchive'));
  770. const { blob, filename } = await api.exportBackup();
  771. setOperationStatus(t('backup.downloadingFile'));
  772. const url = URL.createObjectURL(blob);
  773. const a = document.createElement('a');
  774. a.href = url;
  775. a.download = filename;
  776. a.click();
  777. URL.revokeObjectURL(url);
  778. showToast(t('backup.backupDownloaded'));
  779. } catch (e) {
  780. showToast(t('backup.failedToCreateBackup', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
  781. } finally {
  782. setIsExporting(false);
  783. setOperationStatus('');
  784. }
  785. }}
  786. >
  787. <Download className="w-4 h-4" />
  788. {t('backup.download')}
  789. </Button>
  790. </div>
  791. {/* Import */}
  792. <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
  793. <div>
  794. <p className="text-white">{t('backup.restoreBackup')}</p>
  795. <p className="text-sm text-bambu-gray">
  796. {t('backup.restoreDescription')}
  797. </p>
  798. <p className="text-xs text-bambu-gray-light mt-1">
  799. {t('backup.restoreNote')}
  800. </p>
  801. </div>
  802. <input
  803. ref={fileInputRef}
  804. type="file"
  805. accept=".zip"
  806. className="hidden"
  807. onChange={(e) => {
  808. const file = e.target.files?.[0];
  809. if (file) {
  810. setRestoreFile(file);
  811. setShowRestoreConfirm(true);
  812. }
  813. e.target.value = '';
  814. }}
  815. />
  816. <Button
  817. variant="secondary"
  818. size="sm"
  819. disabled={isRestoring || isExporting}
  820. onClick={() => fileInputRef.current?.click()}
  821. >
  822. <Upload className="w-4 h-4" />
  823. {t('backup.restore')}
  824. </Button>
  825. </div>
  826. {/* Restore result message */}
  827. {restoreResult && (
  828. <div className={`p-3 rounded-lg ${restoreResult.success ? 'bg-green-500/10 border border-green-500/30' : 'bg-red-500/10 border border-red-500/30'}`}>
  829. <div className="flex items-start gap-2 text-sm">
  830. {restoreResult.success ? (
  831. <CheckCircle className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
  832. ) : (
  833. <XCircle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
  834. )}
  835. <div className={restoreResult.success ? 'text-green-200' : 'text-red-200'}>
  836. {restoreResult.message}
  837. {restoreResult.success && (
  838. <div className="mt-2">
  839. <Button
  840. size="sm"
  841. onClick={() => window.location.reload()}
  842. >
  843. <RotateCcw className="w-3 h-3" />
  844. {t('backup.reloadNow')}
  845. </Button>
  846. </div>
  847. )}
  848. </div>
  849. </div>
  850. </div>
  851. )}
  852. {/* Warning */}
  853. <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
  854. <div className="flex items-start gap-2 text-sm">
  855. <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
  856. <div className="text-yellow-200">
  857. <span className="font-medium">{t('backup.restoreReplacesAll')}</span>{' '}
  858. <span className="text-yellow-200/70">{t('backup.restoreReplacesAllDetail')}</span>
  859. </div>
  860. </div>
  861. </div>
  862. </CardContent>
  863. </Card>
  864. {/* Scheduled Local Backups */}
  865. <Card id="card-backup-scheduled">
  866. <CardHeader>
  867. <div className="flex items-center justify-between">
  868. <div className="flex items-center gap-2">
  869. <FolderArchive className="w-5 h-5 text-gray-400" />
  870. <h2 className="text-lg font-semibold text-white">{t('backup.scheduledBackup')}</h2>
  871. </div>
  872. <Toggle
  873. checked={localBackupStatus?.enabled ?? false}
  874. onChange={async (checked) => {
  875. try {
  876. await api.updateSettings({ local_backup_enabled: checked });
  877. showToast(t('backup.settingsSaved'));
  878. } catch (e) {
  879. showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
  880. }
  881. refetchLocalStatus();
  882. }}
  883. />
  884. </div>
  885. </CardHeader>
  886. <CardContent className="space-y-4">
  887. <p className="text-sm text-bambu-gray">
  888. {t('backup.scheduledBackupDescription')}
  889. </p>
  890. {localBackupStatus?.enabled && (
  891. <>
  892. {/* Schedule + Time + Retention */}
  893. <div className="grid grid-cols-3 gap-4">
  894. <div>
  895. <label className="block text-sm text-bambu-gray mb-1">{t('backup.frequency')}</label>
  896. <select
  897. value={localBackupStatus?.schedule ?? 'daily'}
  898. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  899. onChange={async (e) => {
  900. try {
  901. await api.updateSettings({ local_backup_schedule: e.target.value });
  902. showToast(t('backup.settingsSaved'));
  903. } catch (e) {
  904. showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
  905. }
  906. refetchLocalStatus();
  907. }}
  908. >
  909. <option value="hourly">{t('backup.hourly')}</option>
  910. <option value="daily">{t('backup.daily')}</option>
  911. <option value="weekly">{t('backup.weekly')}</option>
  912. </select>
  913. </div>
  914. {(localBackupStatus?.schedule ?? 'daily') !== 'hourly' && (
  915. <div>
  916. <label className="block text-sm text-bambu-gray mb-1">{t('backup.backupTime')}</label>
  917. <input
  918. type="time"
  919. value={localBackupStatus?.time ?? '03:00'}
  920. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none [color-scheme:dark]"
  921. onChange={async (e) => {
  922. try {
  923. await api.updateSettings({ local_backup_time: e.target.value });
  924. showToast(t('backup.settingsSaved'));
  925. } catch (err) {
  926. showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');
  927. }
  928. refetchLocalStatus();
  929. }}
  930. />
  931. <p className="text-xs text-bambu-gray-light mt-1">{t('backup.utc')}</p>
  932. </div>
  933. )}
  934. <div>
  935. <label className="block text-sm text-bambu-gray mb-1">{t('backup.retention')}</label>
  936. <input
  937. type="number"
  938. min={1}
  939. max={100}
  940. value={localBackupStatus?.retention ?? 5}
  941. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  942. onChange={async (e) => {
  943. const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 5));
  944. try {
  945. await api.updateSettings({ local_backup_retention: val });
  946. showToast(t('backup.settingsSaved'));
  947. } catch (e) {
  948. showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
  949. }
  950. refetchLocalStatus();
  951. }}
  952. />
  953. <p className="text-xs text-bambu-gray-light mt-1">{t('backup.retentionDescription')}</p>
  954. </div>
  955. </div>
  956. {/* Output Path */}
  957. <div>
  958. <label className="block text-sm text-bambu-gray mb-1">{t('backup.outputPath')}</label>
  959. <input
  960. type="text"
  961. value={localBackupPath}
  962. onChange={(e) => setLocalBackupPath(e.target.value)}
  963. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  964. onBlur={async () => {
  965. try {
  966. await api.updateSettings({ local_backup_path: localBackupPath });
  967. showToast(t('backup.settingsSaved'));
  968. } catch (err) {
  969. showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');
  970. }
  971. refetchLocalStatus();
  972. refetchLocalBackups();
  973. }}
  974. onKeyDown={(e) => {
  975. if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
  976. }}
  977. />
  978. <p className="text-xs text-bambu-gray-light mt-1">
  979. {localBackupPath
  980. ? t('backup.outputPathDescription')
  981. : <>{t('backup.defaultPathLabel')} <code className="text-bambu-gray">{localBackupStatus?.default_path || '...'}</code></>
  982. }
  983. </p>
  984. </div>
  985. {/* Status + Run Now */}
  986. <div className="flex items-center justify-between py-3 border-t border-bambu-dark-tertiary">
  987. <div className="text-sm">
  988. {localBackupStatus?.last_backup_at && (
  989. <div className="flex items-center gap-2 text-bambu-gray">
  990. <span>{t('backup.lastBackup')}:</span>
  991. <StatusBadge status={localBackupStatus.last_status} />
  992. <span>{formatRelativeTime(localBackupStatus.last_backup_at)}</span>
  993. </div>
  994. )}
  995. {localBackupStatus?.next_run && (
  996. <div className="text-bambu-gray mt-1">
  997. <span>{t('backup.nextBackup')}: </span>
  998. <span>{formatDateTime(localBackupStatus.next_run)}</span>
  999. </div>
  1000. )}
  1001. </div>
  1002. <Button
  1003. variant="secondary"
  1004. size="sm"
  1005. disabled={localBackupStatus?.is_running || triggerLocalBackupMutation.isPending}
  1006. onClick={() => triggerLocalBackupMutation.mutate()}
  1007. >
  1008. {localBackupStatus?.is_running || triggerLocalBackupMutation.isPending ? (
  1009. <Loader2 className="w-4 h-4 animate-spin" />
  1010. ) : (
  1011. <Play className="w-4 h-4" />
  1012. )}
  1013. {localBackupStatus?.is_running ? t('backup.backupRunning') : t('backup.runNow')}
  1014. </Button>
  1015. </div>
  1016. {/* Backup Files List */}
  1017. {localBackups && localBackups.length > 0 && (
  1018. <div className="border-t border-bambu-dark-tertiary pt-3">
  1019. <h3 className="text-sm font-medium text-white mb-2">{t('backup.backupFiles')}</h3>
  1020. <div className="space-y-1">
  1021. {localBackups.map((file) => (
  1022. <div key={file.filename} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-bambu-dark-tertiary/50 text-sm">
  1023. <div className="flex-1 min-w-0">
  1024. <span className="text-white truncate block">{file.filename}</span>
  1025. <span className="text-bambu-gray text-xs">
  1026. {(file.size / 1024 / 1024).toFixed(1)} MB &middot; {formatDateTime(file.created_at)}
  1027. </span>
  1028. </div>
  1029. <div className="flex items-center gap-1 flex-shrink-0">
  1030. <button
  1031. className="text-bambu-gray hover:text-bambu-green p-1"
  1032. title={t('backup.download')}
  1033. onClick={async () => {
  1034. try {
  1035. const { blob, filename: fname } = await api.downloadLocalBackup(file.filename);
  1036. const url = URL.createObjectURL(blob);
  1037. const a = document.createElement('a');
  1038. a.href = url;
  1039. a.download = fname;
  1040. a.click();
  1041. URL.revokeObjectURL(url);
  1042. } catch {
  1043. showToast(t('backup.scheduledBackupFailed'), 'error');
  1044. }
  1045. }}
  1046. >
  1047. <Download className="w-3.5 h-3.5" />
  1048. </button>
  1049. <button
  1050. className="text-bambu-gray hover:text-yellow-400 p-1"
  1051. title={t('backup.restore')}
  1052. onClick={() => setRestoreConfirmFile(file.filename)}
  1053. >
  1054. <RotateCcw className="w-3.5 h-3.5" />
  1055. </button>
  1056. <button
  1057. className="text-bambu-gray hover:text-red-400 p-1"
  1058. onClick={() => setDeleteConfirmFile(file.filename)}
  1059. title={t('backup.deleteBackup')}
  1060. >
  1061. <Trash2 className="w-3.5 h-3.5" />
  1062. </button>
  1063. </div>
  1064. </div>
  1065. ))}
  1066. </div>
  1067. </div>
  1068. )}
  1069. {localBackups && localBackups.length === 0 && (
  1070. <p className="text-sm text-bambu-gray text-center py-3 border-t border-bambu-dark-tertiary">
  1071. {t('backup.noScheduledBackups')}
  1072. </p>
  1073. )}
  1074. </>
  1075. )}
  1076. </CardContent>
  1077. </Card>
  1078. </div>
  1079. {/* Delete Backup Confirmation Modal */}
  1080. {deleteConfirmFile && (
  1081. <ConfirmModal
  1082. title={t('backup.deleteBackup')}
  1083. message={t('backup.deleteBackupConfirm')}
  1084. confirmText={t('backup.deleteBackup')}
  1085. variant="danger"
  1086. onConfirm={() => deleteLocalBackupMutation.mutate(deleteConfirmFile)}
  1087. onCancel={() => setDeleteConfirmFile(null)}
  1088. />
  1089. )}
  1090. {/* Restore from Scheduled Backup Confirmation Modal */}
  1091. {restoreConfirmFile && (
  1092. <ConfirmModal
  1093. title={t('backup.restoreConfirmTitle')}
  1094. message={t('backup.restoreConfirmMessage', { filename: restoreConfirmFile })}
  1095. confirmText={t('backup.restoreConfirmButton')}
  1096. variant="danger"
  1097. onConfirm={() => restoreLocalBackupMutation.mutate(restoreConfirmFile)}
  1098. onCancel={() => setRestoreConfirmFile(null)}
  1099. />
  1100. )}
  1101. {/* Restore Confirmation Modal */}
  1102. {showRestoreConfirm && restoreFile && (
  1103. <ConfirmModal
  1104. title={t('backup.restoreConfirmTitle')}
  1105. message={t('backup.restoreConfirmMessage', { filename: restoreFile.name })}
  1106. confirmText={t('backup.restoreConfirmButton')}
  1107. variant="danger"
  1108. onConfirm={async () => {
  1109. setShowRestoreConfirm(false);
  1110. setIsRestoring(true);
  1111. setRestoreResult(null);
  1112. try {
  1113. setOperationStatus(t('backup.uploadingFile'));
  1114. const result = await api.importBackup(restoreFile);
  1115. setRestoreResult(result);
  1116. if (result.success) {
  1117. showToast(t('backup.backupRestoredRestart'), 'success');
  1118. } else {
  1119. showToast(result.message, 'error');
  1120. }
  1121. } catch (e) {
  1122. const message = e instanceof Error ? e.message : t('backup.failedToRestore');
  1123. setRestoreResult({ success: false, message });
  1124. showToast(message, 'error');
  1125. } finally {
  1126. setIsRestoring(false);
  1127. setOperationStatus('');
  1128. setRestoreFile(null);
  1129. }
  1130. }}
  1131. onCancel={() => {
  1132. setShowRestoreConfirm(false);
  1133. setRestoreFile(null);
  1134. }}
  1135. />
  1136. )}
  1137. {/* Blocking overlay during backup/restore operations */}
  1138. {(isExporting || isRestoring) && (
  1139. <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[100]">
  1140. <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-8 max-w-md w-full mx-4 text-center">
  1141. <div className="flex justify-center mb-4">
  1142. <div className="relative">
  1143. <div className="w-16 h-16 border-4 border-bambu-dark-tertiary rounded-full"></div>
  1144. <div className="w-16 h-16 border-4 border-bambu-green border-t-transparent rounded-full absolute inset-0 animate-spin"></div>
  1145. </div>
  1146. </div>
  1147. <h3 className="text-xl font-semibold text-white mb-2">
  1148. {isExporting ? t('backup.creatingBackup') : t('backup.restoringBackup')}
  1149. </h3>
  1150. <p className="text-bambu-gray mb-4">
  1151. {operationStatus || (isExporting ? t('backup.preparing') : t('backup.processing'))}
  1152. </p>
  1153. <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
  1154. <div className="flex items-start gap-2 text-sm">
  1155. <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
  1156. <p className="text-yellow-200 text-left">
  1157. {t('backup.doNotClosePage')}
  1158. </p>
  1159. </div>
  1160. </div>
  1161. </div>
  1162. </div>
  1163. )}
  1164. </div>
  1165. );
  1166. }