GitHubBackupSettings.tsx 36 KB

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