GitHubBackupSettings.tsx 57 KB

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