GitHubBackupSettings.tsx 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916
  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. } from 'lucide-react';
  22. import { api } from '../api/client';
  23. import type {
  24. GitHubBackupConfig,
  25. GitHubBackupConfigCreate,
  26. GitHubBackupLog,
  27. GitHubBackupStatus,
  28. GitHubBackupTriggerResponse,
  29. ScheduleType,
  30. CloudAuthStatus,
  31. Printer,
  32. } from '../api/client';
  33. import { Card, CardContent, CardHeader } from './Card';
  34. import { Button } from './Button';
  35. import { Toggle } from './Toggle';
  36. import { ConfirmModal } from './ConfirmModal';
  37. import { useToast } from '../contexts/ToastContext';
  38. import { formatRelativeTime, parseUTCDate } from '../utils/date';
  39. function formatDateTime(dateStr: string | null): string {
  40. if (!dateStr) return '-';
  41. const date = parseUTCDate(dateStr);
  42. if (!date) return '-';
  43. return date.toLocaleString();
  44. }
  45. interface StatusBadgeProps {
  46. status: string | null;
  47. }
  48. function StatusBadge({ status }: StatusBadgeProps) {
  49. if (!status) return null;
  50. const styles: Record<string, string> = {
  51. success: 'bg-green-500/20 text-green-400',
  52. failed: 'bg-red-500/20 text-red-400',
  53. skipped: 'bg-yellow-500/20 text-yellow-400',
  54. running: 'bg-blue-500/20 text-blue-400',
  55. };
  56. const icons: Record<string, React.ReactNode> = {
  57. success: <CheckCircle className="w-3 h-3" />,
  58. failed: <XCircle className="w-3 h-3" />,
  59. skipped: <SkipForward className="w-3 h-3" />,
  60. running: <Loader2 className="w-3 h-3 animate-spin" />,
  61. };
  62. return (
  63. <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'}`}>
  64. {icons[status]}
  65. {status.charAt(0).toUpperCase() + status.slice(1)}
  66. </span>
  67. );
  68. }
  69. export function GitHubBackupSettings() {
  70. const queryClient = useQueryClient();
  71. const { showToast } = useToast();
  72. const { t } = useTranslation();
  73. // Local state for form
  74. const [repoUrl, setRepoUrl] = useState('');
  75. const [accessToken, setAccessToken] = useState('');
  76. const [branch, setBranch] = useState('main');
  77. const [scheduleEnabled, setScheduleEnabled] = useState(false);
  78. const [scheduleType, setScheduleType] = useState<ScheduleType>('daily');
  79. const [backupKProfiles, setBackupKProfiles] = useState(true);
  80. const [backupCloudProfiles, setBackupCloudProfiles] = useState(true);
  81. const [backupSettings, setBackupSettings] = useState(false);
  82. const [backupSpools, setBackupSpools] = useState(false);
  83. const [backupArchives, setBackupArchives] = useState(false);
  84. const [enabled, setEnabled] = useState(true);
  85. // Local backup state
  86. const [isExporting, setIsExporting] = useState(false);
  87. const [isRestoring, setIsRestoring] = useState(false);
  88. const [operationStatus, setOperationStatus] = useState<string>('');
  89. const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
  90. const [restoreFile, setRestoreFile] = useState<File | null>(null);
  91. const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
  92. const fileInputRef = useRef<HTMLInputElement>(null);
  93. // Block navigation while backup/restore is in progress
  94. useEffect(() => {
  95. const isOperationInProgress = isExporting || isRestoring;
  96. if (isOperationInProgress) {
  97. const handleBeforeUnload = (e: BeforeUnloadEvent) => {
  98. e.preventDefault();
  99. e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?';
  100. return e.returnValue;
  101. };
  102. window.addEventListener('beforeunload', handleBeforeUnload);
  103. return () => window.removeEventListener('beforeunload', handleBeforeUnload);
  104. }
  105. }, [isExporting, isRestoring]);
  106. // Test connection state
  107. const [testLoading, setTestLoading] = useState(false);
  108. const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
  109. // Auto-save debounce
  110. const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
  111. const isInitializedRef = useRef(false);
  112. // Queries
  113. const { data: config, isLoading: configLoading } = useQuery<GitHubBackupConfig | null>({
  114. queryKey: ['github-backup-config'],
  115. queryFn: api.getGitHubBackupConfig,
  116. });
  117. const { data: status } = useQuery<GitHubBackupStatus>({
  118. queryKey: ['github-backup-status'],
  119. queryFn: api.getGitHubBackupStatus,
  120. refetchInterval: (query) => query.state.data?.is_running ? 500 : 10000, // Poll fast during backup
  121. });
  122. const { data: logs } = useQuery<GitHubBackupLog[]>({
  123. queryKey: ['github-backup-logs'],
  124. queryFn: () => api.getGitHubBackupLogs(20),
  125. });
  126. const { data: cloudStatus } = useQuery<CloudAuthStatus>({
  127. queryKey: ['cloud-status'],
  128. queryFn: api.getCloudStatus,
  129. });
  130. // Fetch printers and their statuses for K-profile availability
  131. const { data: printers } = useQuery<Printer[]>({
  132. queryKey: ['printers'],
  133. queryFn: api.getPrinters,
  134. });
  135. // Fetch printer statuses from API (not just cache) to get accurate connection status
  136. const printerStatusQueries = useQueries({
  137. queries: (printers ?? []).map(printer => ({
  138. queryKey: ['printerStatus', printer.id],
  139. queryFn: () => api.getPrinterStatus(printer.id),
  140. staleTime: 10000, // Consider stale after 10s
  141. refetchInterval: 30000, // Refresh every 30s
  142. })),
  143. });
  144. const printerStatuses = (printers ?? []).map((printer, index) => ({
  145. printer,
  146. connected: printerStatusQueries[index]?.data?.connected ?? false,
  147. }));
  148. const totalPrinters = printerStatuses.length;
  149. const connectedPrinters = printerStatuses.filter(p => p.connected).length;
  150. const noPrintersConnected = totalPrinters > 0 && connectedPrinters === 0;
  151. const somePrintersDisconnected = connectedPrinters > 0 && connectedPrinters < totalPrinters;
  152. // Initialize form from config
  153. useEffect(() => {
  154. if (config) {
  155. setRepoUrl(config.repository_url);
  156. setBranch(config.branch);
  157. setScheduleEnabled(config.schedule_enabled);
  158. setScheduleType(config.schedule_type);
  159. setBackupKProfiles(config.backup_kprofiles);
  160. setBackupCloudProfiles(config.backup_cloud_profiles);
  161. setBackupSettings(config.backup_settings);
  162. setBackupSpools(config.backup_spools);
  163. setBackupArchives(config.backup_archives);
  164. setEnabled(config.enabled);
  165. setAccessToken(''); // Don't show stored token
  166. // Mark as initialized after a tick to avoid auto-save on initial load
  167. setTimeout(() => { isInitializedRef.current = true; }, 100);
  168. }
  169. }, [config]);
  170. // Auto-save function for existing configs
  171. const autoSave = useCallback(async (includeToken: boolean = false) => {
  172. if (!config?.has_token) return; // Only auto-save if config already exists
  173. try {
  174. if (includeToken && accessToken) {
  175. // Full save with new token
  176. await api.saveGitHubBackupConfig({
  177. repository_url: repoUrl,
  178. access_token: accessToken,
  179. branch,
  180. schedule_enabled: scheduleEnabled,
  181. schedule_type: scheduleType,
  182. backup_kprofiles: backupKProfiles,
  183. backup_cloud_profiles: backupCloudProfiles,
  184. backup_settings: backupSettings,
  185. backup_spools: backupSpools,
  186. backup_archives: backupArchives,
  187. enabled,
  188. });
  189. setAccessToken(''); // Clear after save
  190. showToast(t('backup.tokenUpdated'));
  191. } else {
  192. // Update without token
  193. await api.updateGitHubBackupConfig({
  194. repository_url: repoUrl,
  195. branch,
  196. schedule_enabled: scheduleEnabled,
  197. schedule_type: scheduleType,
  198. backup_kprofiles: backupKProfiles,
  199. backup_cloud_profiles: backupCloudProfiles,
  200. backup_settings: backupSettings,
  201. backup_spools: backupSpools,
  202. backup_archives: backupArchives,
  203. enabled,
  204. });
  205. showToast(t('backup.settingsSaved'));
  206. }
  207. queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
  208. queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
  209. } catch (error) {
  210. showToast(t('backup.failedToSave', { message: (error as Error).message }), 'error');
  211. }
  212. }, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, queryClient, showToast, t]);
  213. // Auto-save effect for existing configs (debounced)
  214. useEffect(() => {
  215. if (!isInitializedRef.current || !config?.has_token) return;
  216. if (autoSaveTimerRef.current) {
  217. clearTimeout(autoSaveTimerRef.current);
  218. }
  219. autoSaveTimerRef.current = setTimeout(() => {
  220. autoSave(false);
  221. }, 500);
  222. return () => {
  223. if (autoSaveTimerRef.current) {
  224. clearTimeout(autoSaveTimerRef.current);
  225. }
  226. };
  227. }, [repoUrl, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, autoSave, config?.has_token]);
  228. // Auto-save token when it changes (with longer debounce)
  229. useEffect(() => {
  230. if (!isInitializedRef.current || !config?.has_token || !accessToken) return;
  231. if (autoSaveTimerRef.current) {
  232. clearTimeout(autoSaveTimerRef.current);
  233. }
  234. autoSaveTimerRef.current = setTimeout(() => {
  235. autoSave(true);
  236. }, 1000);
  237. return () => {
  238. if (autoSaveTimerRef.current) {
  239. clearTimeout(autoSaveTimerRef.current);
  240. }
  241. };
  242. }, [accessToken, autoSave, config?.has_token]);
  243. // Mutations
  244. const saveConfigMutation = useMutation({
  245. mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
  246. onSuccess: () => {
  247. queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
  248. queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
  249. showToast(t('backup.githubBackupEnabled'));
  250. setAccessToken('');
  251. isInitializedRef.current = true;
  252. },
  253. onError: (error: Error) => {
  254. showToast(t('backup.failedToSave', { message: error.message }), 'error');
  255. },
  256. });
  257. const triggerBackupMutation = useMutation<GitHubBackupTriggerResponse, Error>({
  258. mutationFn: api.triggerGitHubBackup,
  259. onSuccess: (result) => {
  260. queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
  261. queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
  262. if (result.success) {
  263. if (result.files_changed > 0) {
  264. showToast(t('backup.backupCompleteFiles', { count: result.files_changed }));
  265. } else {
  266. showToast(t('backup.backupSkippedNoChanges'));
  267. }
  268. } else {
  269. showToast(t('backup.backupFailed2', { message: result.message }), 'error');
  270. }
  271. },
  272. onError: (error: Error) => {
  273. showToast(t('backup.backupFailed2', { message: error.message }), 'error');
  274. },
  275. });
  276. const clearLogsMutation = useMutation<{ deleted: number; message: string }, Error>({
  277. mutationFn: () => api.clearGitHubBackupLogs(0),
  278. onSuccess: (result) => {
  279. queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
  280. showToast(t('backup.clearedLogs', { count: result.deleted }));
  281. },
  282. onError: (error: Error) => {
  283. showToast(t('backup.failedToClearLogs', { message: error.message }), 'error');
  284. },
  285. });
  286. const handleTestConnection = async () => {
  287. setTestLoading(true);
  288. setTestResult(null);
  289. try {
  290. let result;
  291. // If user entered a new token, test with those credentials
  292. if (accessToken) {
  293. if (!repoUrl) {
  294. showToast(t('backup.enterRepoUrl'), 'error');
  295. setTestLoading(false);
  296. return;
  297. }
  298. result = await api.testGitHubConnection(repoUrl, accessToken);
  299. } else if (config?.has_token) {
  300. // Use stored credentials
  301. result = await api.testGitHubStoredConnection();
  302. } else {
  303. showToast(t('backup.enterRepoAndToken'), 'error');
  304. setTestLoading(false);
  305. return;
  306. }
  307. setTestResult({ success: result.success, message: result.message });
  308. } catch (error) {
  309. setTestResult({ success: false, message: (error as Error).message });
  310. } finally {
  311. setTestLoading(false);
  312. }
  313. };
  314. // Initial setup save (only for new configs)
  315. const handleInitialSetup = () => {
  316. if (!repoUrl) {
  317. showToast(t('backup.repoRequired'), 'error');
  318. return;
  319. }
  320. if (!accessToken) {
  321. showToast(t('backup.tokenRequired'), 'error');
  322. return;
  323. }
  324. saveConfigMutation.mutate({
  325. repository_url: repoUrl,
  326. access_token: accessToken,
  327. branch,
  328. schedule_enabled: scheduleEnabled,
  329. schedule_type: scheduleType,
  330. backup_kprofiles: backupKProfiles,
  331. backup_cloud_profiles: backupCloudProfiles,
  332. backup_settings: backupSettings,
  333. backup_spools: backupSpools,
  334. backup_archives: backupArchives,
  335. enabled,
  336. });
  337. };
  338. if (configLoading) {
  339. return (
  340. <div className="flex items-center justify-center py-12">
  341. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  342. </div>
  343. );
  344. }
  345. return (
  346. <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
  347. {/* Left Column - GitHub Backup */}
  348. <div className="space-y-6">
  349. <Card>
  350. <CardHeader>
  351. <div className="flex items-center justify-between">
  352. <div className="flex items-center gap-2">
  353. <Github className="w-5 h-5 text-gray-400" />
  354. <h2 className="text-lg font-semibold text-white">{t('backup.githubBackup')}</h2>
  355. </div>
  356. {config && (
  357. <div className="flex items-center gap-2">
  358. <span className="text-sm text-bambu-gray">{t('backup.enabled')}</span>
  359. <Toggle
  360. checked={enabled}
  361. onChange={setEnabled}
  362. />
  363. </div>
  364. )}
  365. </div>
  366. </CardHeader>
  367. <CardContent className="space-y-4">
  368. <p className="text-sm text-bambu-gray">
  369. {t('backup.githubDescription')}
  370. </p>
  371. {/* Repository URL */}
  372. <div>
  373. <label className="block text-sm text-bambu-gray mb-1">
  374. {t('backup.repositoryUrl')}
  375. </label>
  376. <input
  377. type="text"
  378. value={repoUrl}
  379. onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); }}
  380. placeholder="https://github.com/username/bambuddy-backup"
  381. 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"
  382. />
  383. </div>
  384. {/* Access Token */}
  385. <div>
  386. <label className="block text-sm text-bambu-gray mb-1">
  387. {t('backup.personalAccessToken')} {config?.has_token && <span className="text-green-400">{t('backup.tokenSaved')}</span>}
  388. </label>
  389. <input
  390. type="password"
  391. value={accessToken}
  392. onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}
  393. placeholder={config?.has_token ? t('backup.enterNewToken') : 'ghp_xxxxxxxxxxxx'}
  394. 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"
  395. />
  396. <p className="text-xs text-bambu-gray mt-1">
  397. {t('backup.tokenHint')}
  398. </p>
  399. </div>
  400. {/* Branch - inline with schedule */}
  401. <div className="grid grid-cols-2 gap-4">
  402. <div>
  403. <label className="block text-sm text-bambu-gray mb-1">{t('backup.branch')}</label>
  404. <input
  405. type="text"
  406. value={branch}
  407. onChange={(e) => setBranch(e.target.value)}
  408. placeholder="main"
  409. 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"
  410. />
  411. </div>
  412. <div>
  413. <label className="block text-sm text-bambu-gray mb-1">{t('backup.autoBackup')}</label>
  414. <select
  415. value={scheduleEnabled ? scheduleType : 'disabled'}
  416. onChange={(e) => {
  417. if (e.target.value === 'disabled') {
  418. setScheduleEnabled(false);
  419. } else {
  420. setScheduleEnabled(true);
  421. setScheduleType(e.target.value as ScheduleType);
  422. }
  423. }}
  424. 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"
  425. >
  426. <option value="disabled">{t('backup.manualOnly')}</option>
  427. <option value="hourly">{t('backup.hourly')}</option>
  428. <option value="daily">{t('backup.daily')}</option>
  429. <option value="weekly">{t('backup.weekly')}</option>
  430. </select>
  431. </div>
  432. </div>
  433. {/* What to backup */}
  434. <div>
  435. <label className="block text-sm text-bambu-gray mb-2">{t('backup.includeInBackup')}</label>
  436. <div className="space-y-2">
  437. <label className={`flex items-start gap-2 ${noPrintersConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
  438. <input
  439. type="checkbox"
  440. checked={backupKProfiles}
  441. onChange={(e) => setBackupKProfiles(e.target.checked)}
  442. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  443. disabled={noPrintersConnected}
  444. />
  445. <div className="flex-1">
  446. <div className="flex items-center gap-2">
  447. <span className={`text-sm ${noPrintersConnected ? 'text-bambu-gray' : 'text-white'}`}>{t('backup.kProfiles')}</span>
  448. {noPrintersConnected && (
  449. <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
  450. <AlertTriangle className="w-3 h-3" />
  451. {t('backup.noPrintersConnected')}
  452. </span>
  453. )}
  454. {somePrintersDisconnected && (
  455. <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
  456. <AlertTriangle className="w-3 h-3" />
  457. {t('backup.printersConnected', { connected: connectedPrinters, total: totalPrinters })}
  458. </span>
  459. )}
  460. </div>
  461. <p className="text-xs text-bambu-gray">{t('backup.kProfilesDescription')}</p>
  462. </div>
  463. </label>
  464. <label className={`flex items-start gap-2 ${!cloudStatus?.is_authenticated ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
  465. <input
  466. type="checkbox"
  467. checked={backupCloudProfiles}
  468. onChange={(e) => setBackupCloudProfiles(e.target.checked)}
  469. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  470. disabled={!cloudStatus?.is_authenticated}
  471. />
  472. <div>
  473. <div className="flex items-center gap-2">
  474. <span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
  475. {!cloudStatus?.is_authenticated && (
  476. <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
  477. <AlertTriangle className="w-3 h-3" />
  478. {t('backup.cloudLoginRequiredShort')}
  479. </span>
  480. )}
  481. </div>
  482. <p className="text-xs text-bambu-gray">{t('backup.cloudProfilesDescription')}</p>
  483. </div>
  484. </label>
  485. <label className="flex items-start gap-2 cursor-pointer">
  486. <input
  487. type="checkbox"
  488. checked={backupSettings}
  489. onChange={(e) => setBackupSettings(e.target.checked)}
  490. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  491. />
  492. <div>
  493. <span className="text-white text-sm">{t('backup.appSettings')}</span>
  494. <p className="text-xs text-bambu-gray">{t('backup.appSettingsDescription')}</p>
  495. </div>
  496. </label>
  497. <label className="flex items-start gap-2 cursor-pointer">
  498. <input
  499. type="checkbox"
  500. checked={backupSpools}
  501. onChange={(e) => setBackupSpools(e.target.checked)}
  502. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  503. />
  504. <div>
  505. <span className="text-white text-sm">{t('backup.spoolInventory')}</span>
  506. <p className="text-xs text-bambu-gray">{t('backup.spoolInventoryDescription')}</p>
  507. </div>
  508. </label>
  509. <label className="flex items-start gap-2 cursor-pointer">
  510. <input
  511. type="checkbox"
  512. checked={backupArchives}
  513. onChange={(e) => setBackupArchives(e.target.checked)}
  514. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  515. />
  516. <div>
  517. <span className="text-white text-sm">{t('backup.printArchives')}</span>
  518. <p className="text-xs text-bambu-gray">{t('backup.printArchivesDescription')}</p>
  519. </div>
  520. </label>
  521. </div>
  522. </div>
  523. {/* Test + Status + Actions */}
  524. <div className="border-t border-bambu-dark-tertiary pt-4 space-y-3">
  525. {/* Status line */}
  526. {status?.configured && (
  527. <div className="flex items-center justify-between text-sm">
  528. <div className="flex items-center gap-2 text-bambu-gray">
  529. {status.last_backup_at ? (
  530. <>
  531. <span>{t('backup.lastBackupAt')} {formatRelativeTime(status.last_backup_at, 'system', t)}</span>
  532. <StatusBadge status={status.last_backup_status} />
  533. </>
  534. ) : (
  535. <span>{t('backup.noBackupsYet')}</span>
  536. )}
  537. </div>
  538. {status.next_scheduled_run && (
  539. <span className="text-bambu-gray">
  540. <Clock className="w-3 h-3 inline mr-1" />
  541. {t('backup.next')} {formatRelativeTime(status.next_scheduled_run, 'system', t)}
  542. </span>
  543. )}
  544. </div>
  545. )}
  546. {/* Test result */}
  547. {testResult && (
  548. <div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
  549. {testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
  550. {testResult.message}
  551. </div>
  552. )}
  553. {/* Action buttons */}
  554. <div className="flex flex-wrap items-center gap-2">
  555. {status?.configured ? (
  556. <>
  557. {(triggerBackupMutation.isPending || status.is_running) ? (
  558. <div className="flex items-center gap-2 text-bambu-green">
  559. <Loader2 className="w-4 h-4 animate-spin" />
  560. <span className="text-sm">{status.progress || t('backup.startingBackup')}</span>
  561. </div>
  562. ) : (
  563. <>
  564. <Button
  565. variant="primary"
  566. size="sm"
  567. onClick={() => triggerBackupMutation.mutate()}
  568. disabled={!config?.enabled}
  569. >
  570. <Play className="w-4 h-4" />
  571. {t('backup.backupNow')}
  572. </Button>
  573. <Button
  574. variant="secondary"
  575. size="sm"
  576. onClick={handleTestConnection}
  577. disabled={testLoading}
  578. >
  579. {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
  580. {t('backup.test')}
  581. </Button>
  582. </>
  583. )}
  584. </>
  585. ) : (
  586. <>
  587. <Button
  588. variant="primary"
  589. size="sm"
  590. onClick={handleInitialSetup}
  591. disabled={saveConfigMutation.isPending || !repoUrl || !accessToken}
  592. >
  593. {saveConfigMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
  594. {t('backup.enableBackup')}
  595. </Button>
  596. <Button
  597. variant="secondary"
  598. size="sm"
  599. onClick={handleTestConnection}
  600. disabled={testLoading || !repoUrl || !accessToken}
  601. >
  602. {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
  603. {t('backup.testConnection')}
  604. </Button>
  605. </>
  606. )}
  607. </div>
  608. </div>
  609. </CardContent>
  610. </Card>
  611. {/* Backup History - only show if configured and has logs */}
  612. {logs && logs.length > 0 && (
  613. <Card>
  614. <CardHeader>
  615. <div className="flex items-center justify-between">
  616. <div className="flex items-center gap-2">
  617. <History className="w-5 h-5 text-gray-400" />
  618. <h2 className="text-lg font-semibold text-white">{t('backup.history')}</h2>
  619. </div>
  620. <Button
  621. variant="ghost"
  622. size="sm"
  623. onClick={() => clearLogsMutation.mutate()}
  624. disabled={clearLogsMutation.isPending}
  625. >
  626. <Trash2 className="w-4 h-4" />
  627. {t('backup.clear')}
  628. </Button>
  629. </div>
  630. </CardHeader>
  631. <CardContent>
  632. <div className="overflow-x-auto">
  633. <table className="w-full text-sm">
  634. <thead>
  635. <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
  636. <th className="text-left py-2 px-2">{t('backup.date')}</th>
  637. <th className="text-left py-2 px-2">{t('backup.status')}</th>
  638. <th className="text-left py-2 px-2">{t('backup.commit')}</th>
  639. </tr>
  640. </thead>
  641. <tbody>
  642. {logs.slice(0, 10).map((log) => (
  643. <tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
  644. <td className="py-2 px-2 text-white">{formatDateTime(log.started_at)}</td>
  645. <td className="py-2 px-2"><StatusBadge status={log.status} /></td>
  646. <td className="py-2 px-2">
  647. {log.commit_sha ? (
  648. <a
  649. href={`${config?.repository_url}/commit/${log.commit_sha}`}
  650. target="_blank"
  651. rel="noopener noreferrer"
  652. className="text-bambu-green hover:underline inline-flex items-center gap-1"
  653. >
  654. {log.commit_sha.substring(0, 7)}
  655. <ExternalLink className="w-3 h-3" />
  656. </a>
  657. ) : (
  658. <span className="text-bambu-gray">-</span>
  659. )}
  660. </td>
  661. </tr>
  662. ))}
  663. </tbody>
  664. </table>
  665. </div>
  666. </CardContent>
  667. </Card>
  668. )}
  669. </div>
  670. {/* Right Column - Local Backup */}
  671. <div className="space-y-6">
  672. <Card>
  673. <CardHeader>
  674. <div className="flex items-center gap-2">
  675. <Database className="w-5 h-5 text-gray-400" />
  676. <h2 className="text-lg font-semibold text-white">{t('backup.localBackup')}</h2>
  677. </div>
  678. </CardHeader>
  679. <CardContent className="space-y-4">
  680. <p className="text-sm text-bambu-gray">
  681. {t('backup.localBackupDescription')}
  682. </p>
  683. {/* Export */}
  684. <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
  685. <div>
  686. <p className="text-white">{t('backup.downloadBackupLabel')}</p>
  687. <p className="text-sm text-bambu-gray">
  688. {t('backup.completeBackupZip')}
  689. </p>
  690. </div>
  691. <Button
  692. variant="secondary"
  693. size="sm"
  694. disabled={isExporting || isRestoring}
  695. onClick={async () => {
  696. setIsExporting(true);
  697. setOperationStatus(t('backup.preparingBackup'));
  698. try {
  699. setOperationStatus(t('backup.creatingArchive'));
  700. const { blob, filename } = await api.exportBackup();
  701. setOperationStatus(t('backup.downloadingFile'));
  702. const url = URL.createObjectURL(blob);
  703. const a = document.createElement('a');
  704. a.href = url;
  705. a.download = filename;
  706. a.click();
  707. URL.revokeObjectURL(url);
  708. showToast(t('backup.backupDownloaded'));
  709. } catch (e) {
  710. showToast(t('backup.failedToCreateBackup', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
  711. } finally {
  712. setIsExporting(false);
  713. setOperationStatus('');
  714. }
  715. }}
  716. >
  717. <Download className="w-4 h-4" />
  718. {t('backup.download')}
  719. </Button>
  720. </div>
  721. {/* Import */}
  722. <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
  723. <div>
  724. <p className="text-white">{t('backup.restoreBackup')}</p>
  725. <p className="text-sm text-bambu-gray">
  726. {t('backup.restoreDescription')}
  727. </p>
  728. <p className="text-xs text-bambu-gray-light mt-1">
  729. {t('backup.restoreNote')}
  730. </p>
  731. </div>
  732. <input
  733. ref={fileInputRef}
  734. type="file"
  735. accept=".zip"
  736. className="hidden"
  737. onChange={(e) => {
  738. const file = e.target.files?.[0];
  739. if (file) {
  740. setRestoreFile(file);
  741. setShowRestoreConfirm(true);
  742. }
  743. e.target.value = '';
  744. }}
  745. />
  746. <Button
  747. variant="secondary"
  748. size="sm"
  749. disabled={isRestoring || isExporting}
  750. onClick={() => fileInputRef.current?.click()}
  751. >
  752. <Upload className="w-4 h-4" />
  753. {t('backup.restore')}
  754. </Button>
  755. </div>
  756. {/* Restore result message */}
  757. {restoreResult && (
  758. <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'}`}>
  759. <div className="flex items-start gap-2 text-sm">
  760. {restoreResult.success ? (
  761. <CheckCircle className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
  762. ) : (
  763. <XCircle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
  764. )}
  765. <div className={restoreResult.success ? 'text-green-200' : 'text-red-200'}>
  766. {restoreResult.message}
  767. {restoreResult.success && (
  768. <div className="mt-2">
  769. <Button
  770. size="sm"
  771. onClick={() => window.location.reload()}
  772. >
  773. <RotateCcw className="w-3 h-3" />
  774. {t('backup.reloadNow')}
  775. </Button>
  776. </div>
  777. )}
  778. </div>
  779. </div>
  780. </div>
  781. )}
  782. {/* Warning */}
  783. <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
  784. <div className="flex items-start gap-2 text-sm">
  785. <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
  786. <div className="text-yellow-200">
  787. <span className="font-medium">{t('backup.restoreReplacesAll')}</span>{' '}
  788. <span className="text-yellow-200/70">{t('backup.restoreReplacesAllDetail')}</span>
  789. </div>
  790. </div>
  791. </div>
  792. </CardContent>
  793. </Card>
  794. </div>
  795. {/* Restore Confirmation Modal */}
  796. {showRestoreConfirm && restoreFile && (
  797. <ConfirmModal
  798. title={t('backup.restoreConfirmTitle')}
  799. message={t('backup.restoreConfirmMessage', { filename: restoreFile.name })}
  800. confirmText={t('backup.restoreConfirmButton')}
  801. variant="danger"
  802. onConfirm={async () => {
  803. setShowRestoreConfirm(false);
  804. setIsRestoring(true);
  805. setRestoreResult(null);
  806. try {
  807. setOperationStatus(t('backup.uploadingFile'));
  808. const result = await api.importBackup(restoreFile);
  809. setRestoreResult(result);
  810. if (result.success) {
  811. showToast(t('backup.backupRestoredRestart'), 'success');
  812. } else {
  813. showToast(result.message, 'error');
  814. }
  815. } catch (e) {
  816. const message = e instanceof Error ? e.message : t('backup.failedToRestore');
  817. setRestoreResult({ success: false, message });
  818. showToast(message, 'error');
  819. } finally {
  820. setIsRestoring(false);
  821. setOperationStatus('');
  822. setRestoreFile(null);
  823. }
  824. }}
  825. onCancel={() => {
  826. setShowRestoreConfirm(false);
  827. setRestoreFile(null);
  828. }}
  829. />
  830. )}
  831. {/* Blocking overlay during backup/restore operations */}
  832. {(isExporting || isRestoring) && (
  833. <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[100]">
  834. <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-8 max-w-md w-full mx-4 text-center">
  835. <div className="flex justify-center mb-4">
  836. <div className="relative">
  837. <div className="w-16 h-16 border-4 border-bambu-dark-tertiary rounded-full"></div>
  838. <div className="w-16 h-16 border-4 border-bambu-green border-t-transparent rounded-full absolute inset-0 animate-spin"></div>
  839. </div>
  840. </div>
  841. <h3 className="text-xl font-semibold text-white mb-2">
  842. {isExporting ? t('backup.creatingBackup') : t('backup.restoringBackup')}
  843. </h3>
  844. <p className="text-bambu-gray mb-4">
  845. {operationStatus || (isExporting ? t('backup.preparing') : t('backup.processing'))}
  846. </p>
  847. <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
  848. <div className="flex items-start gap-2 text-sm">
  849. <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
  850. <p className="text-yellow-200 text-left">
  851. {t('backup.doNotClosePage')}
  852. </p>
  853. </div>
  854. </div>
  855. </div>
  856. </div>
  857. )}
  858. </div>
  859. );
  860. }