GitHubBackupSettings.tsx 35 KB

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