GitHubBackupSettings.tsx 71 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614
  1. import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import {
  5. Github,
  6. Play,
  7. Clock,
  8. CheckCircle,
  9. XCircle,
  10. Loader2,
  11. ExternalLink,
  12. RefreshCw,
  13. Download,
  14. Upload,
  15. Database,
  16. History,
  17. SkipForward,
  18. AlertTriangle,
  19. Trash2,
  20. RotateCcw,
  21. FolderArchive,
  22. } from 'lucide-react';
  23. import { api } from '../api/client';
  24. import type {
  25. GitHubBackupConfig,
  26. GitHubBackupConfigCreate,
  27. GitHubBackupLog,
  28. GitHubBackupStatus,
  29. GitHubBackupTriggerResponse,
  30. GitProviderType,
  31. LocalBackupFile,
  32. LocalBackupPathCheck,
  33. LocalBackupStatus,
  34. ScheduleType,
  35. CloudAccountCounts,
  36. CloudAuthStatus,
  37. Printer,
  38. } from '../api/client';
  39. import { useAuth } from '../contexts/AuthContext';
  40. import { Card, CardContent, CardHeader } from './Card';
  41. import { Button } from './Button';
  42. import { Toggle } from './Toggle';
  43. import { ConfirmModal } from './ConfirmModal';
  44. import { GitHubRestoreModal } from './GitHubRestoreModal';
  45. import { useToast } from '../contexts/ToastContext';
  46. import { formatRelativeTime, parseUTCDate } from '../utils/date';
  47. import { NumberInput } from './NumberInput';
  48. function formatDateTime(dateStr: string | null): string {
  49. if (!dateStr) return '-';
  50. const date = parseUTCDate(dateStr);
  51. if (!date) return '-';
  52. return date.toLocaleString();
  53. }
  54. interface StatusBadgeProps {
  55. status: string | null;
  56. }
  57. function StatusBadge({ status }: StatusBadgeProps) {
  58. if (!status) return null;
  59. const styles: Record<string, string> = {
  60. success: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400',
  61. failed: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400',
  62. skipped: 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400',
  63. running: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400',
  64. };
  65. const icons: Record<string, React.ReactNode> = {
  66. success: <CheckCircle className="w-3 h-3" />,
  67. failed: <XCircle className="w-3 h-3" />,
  68. skipped: <SkipForward className="w-3 h-3" />,
  69. running: <Loader2 className="w-3 h-3 animate-spin" />,
  70. };
  71. return (
  72. <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'}`}>
  73. {icons[status]}
  74. {status.charAt(0).toUpperCase() + status.slice(1)}
  75. </span>
  76. );
  77. }
  78. const PROVIDER_REPO_URL_I18N_KEY: Record<GitProviderType, string> = {
  79. github: 'backup.repoUrlPlaceholderGitHub',
  80. gitea: 'backup.repoUrlPlaceholderGitea',
  81. forgejo: 'backup.repoUrlPlaceholderForgejo',
  82. gitlab: 'backup.repoUrlPlaceholderGitLab',
  83. };
  84. const PROVIDER_TOKEN_PLACEHOLDER: Record<GitProviderType, string> = {
  85. github: 'ghp_xxxxxxxxxxxx',
  86. gitea: 'your_access_token',
  87. forgejo: 'your_access_token',
  88. gitlab: 'glpat-xxxxxxxxxxxx',
  89. };
  90. // Each provider names its scopes differently, so a single hint could only ever
  91. // be right for one of them (#2775). Naming the scopes up front is also what
  92. // keeps a token from being minted more permissive than a backup needs.
  93. const PROVIDER_TOKEN_HINT_I18N_KEY: Record<GitProviderType, string> = {
  94. github: 'backup.tokenHintGitHub',
  95. gitea: 'backup.tokenHintGitea',
  96. forgejo: 'backup.tokenHintForgejo',
  97. gitlab: 'backup.tokenHintGitLab',
  98. };
  99. interface GitHubBackupAutosaveState {
  100. repository_url: string;
  101. branch: string;
  102. provider: GitProviderType;
  103. allow_insecure_http: boolean;
  104. schedule_enabled: boolean;
  105. schedule_type: ScheduleType;
  106. backup_kprofiles: boolean;
  107. backup_cloud_profiles: boolean;
  108. backup_settings: boolean;
  109. backup_spools: boolean;
  110. backup_archives: boolean;
  111. enabled: boolean;
  112. }
  113. function serializeAutosaveState(state: GitHubBackupAutosaveState): string {
  114. return JSON.stringify(state);
  115. }
  116. function getChangedAutosaveFields(
  117. current: GitHubBackupAutosaveState,
  118. previous: GitHubBackupAutosaveState | null
  119. ): Partial<GitHubBackupAutosaveState> {
  120. if (!previous) {
  121. return current;
  122. }
  123. const changes: Partial<GitHubBackupAutosaveState> = {};
  124. for (const key of Object.keys(current) as Array<keyof GitHubBackupAutosaveState>) {
  125. if (current[key] !== previous[key]) {
  126. changes[key] = current[key] as never;
  127. }
  128. }
  129. return changes;
  130. }
  131. export function GitHubBackupSettings() {
  132. const queryClient = useQueryClient();
  133. const { showToast } = useToast();
  134. const { t } = useTranslation();
  135. const { hasPermission, isAdmin } = useAuth();
  136. // All three restore endpoints are gated on GITHUB_RESTORE server-side, so a
  137. // user without it gets a 403 the moment the modal opens its preview. Hide the
  138. // button rather than offer an action that cannot work. Deliberately scoped to
  139. // the button: the card itself stays visible, since backup configuration is a
  140. // separate permission. hasPermission returns true when auth is off.
  141. const canRestoreFromGit = hasPermission('github:restore');
  142. // Local state for form
  143. const [repoUrl, setRepoUrl] = useState('');
  144. const [accessToken, setAccessToken] = useState('');
  145. const [branch, setBranch] = useState('main');
  146. const [provider, setProvider] = useState<GitProviderType>('github');
  147. const [scheduleEnabled, setScheduleEnabled] = useState(false);
  148. const [scheduleType, setScheduleType] = useState<ScheduleType>('daily');
  149. const [backupKProfiles, setBackupKProfiles] = useState(true);
  150. const [backupCloudProfiles, setBackupCloudProfiles] = useState(true);
  151. const [backupSettings, setBackupSettings] = useState(false);
  152. const [backupSpools, setBackupSpools] = useState(false);
  153. const [backupArchives, setBackupArchives] = useState(false);
  154. const [allowInsecureHttp, setAllowInsecureHttp] = useState(false);
  155. const [enabled, setEnabled] = useState(true);
  156. // Local backup state
  157. const [isExporting, setIsExporting] = useState(false);
  158. const [isRestoring, setIsRestoring] = useState(false);
  159. const [operationStatus, setOperationStatus] = useState<string>('');
  160. const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
  161. const [restoreFile, setRestoreFile] = useState<File | null>(null);
  162. const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
  163. const fileInputRef = useRef<HTMLInputElement>(null);
  164. // Restore from the Git backup repository (#2656)
  165. const [showGitRestore, setShowGitRestore] = useState(false);
  166. // Scheduled local backup state
  167. const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
  168. const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
  169. const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
  170. const [localBackupPath, setLocalBackupPath] = useState('');
  171. const { data: localBackupStatus, refetch: refetchLocalStatus } = useQuery<LocalBackupStatus>({
  172. queryKey: ['local-backup-status'],
  173. queryFn: api.getLocalBackupStatus,
  174. refetchInterval: (query) => query.state.data?.is_running ? 1000 : 10000,
  175. });
  176. const { data: localBackups, refetch: refetchLocalBackups } = useQuery<LocalBackupFile[]>({
  177. queryKey: ['local-backup-files'],
  178. queryFn: api.getLocalBackups,
  179. refetchInterval: 30000,
  180. });
  181. // Probes the output directory with a real write (#2544). A directory the
  182. // service cannot write to — a NAS share outside the systemd unit's
  183. // ReadWritePaths, typically — otherwise only surfaces as a failed backup at
  184. // 03:00, for however many nights it takes someone to notice. Refetched
  185. // explicitly when the path changes or a backup runs, not polled: it writes.
  186. const { data: localBackupPathCheck, refetch: refetchLocalPathCheck } = useQuery<LocalBackupPathCheck>({
  187. queryKey: ['local-backup-path-check'],
  188. queryFn: api.checkLocalBackupPath,
  189. enabled: localBackupStatus?.enabled === true,
  190. refetchOnWindowFocus: false,
  191. });
  192. // Sync local path state from server
  193. useEffect(() => {
  194. if (localBackupStatus?.path !== undefined) {
  195. setLocalBackupPath(localBackupStatus.path);
  196. }
  197. }, [localBackupStatus?.path]);
  198. const triggerLocalBackupMutation = useMutation({
  199. mutationFn: api.triggerLocalBackup,
  200. onSuccess: (data) => {
  201. if (data.success) {
  202. showToast(t('backup.scheduledBackupComplete'));
  203. } else {
  204. showToast(data.message, 'error');
  205. }
  206. refetchLocalStatus();
  207. refetchLocalBackups();
  208. refetchLocalPathCheck();
  209. },
  210. onError: () => showToast(t('backup.scheduledBackupFailed'), 'error'),
  211. });
  212. const deleteLocalBackupMutation = useMutation({
  213. mutationFn: (filename: string) => api.deleteLocalBackup(filename),
  214. onSuccess: (data) => {
  215. refetchLocalBackups();
  216. setDeleteConfirmFile(null);
  217. // A refused delete answers 200 with success false.
  218. if (!data.success) showToast(data.message, 'error');
  219. },
  220. onError: (error: Error) => {
  221. setDeleteConfirmFile(null);
  222. showToast(error.message, 'error');
  223. },
  224. });
  225. const restoreLocalBackupMutation = useMutation({
  226. mutationFn: async (filename: string) => {
  227. setRestoreConfirmFile(null);
  228. setIsRestoring(true);
  229. setRestoreResult(null);
  230. setOperationStatus(t('backup.restoring'));
  231. return api.restoreLocalBackup(filename);
  232. },
  233. onSuccess: (data) => {
  234. setIsRestoring(false);
  235. setOperationStatus('');
  236. if (data.success) {
  237. setRestoreResult({ success: true, message: data.message });
  238. showToast(t('backup.backupRestoredRestart'), 'success');
  239. } else {
  240. setRestoreResult({ success: false, message: data.message });
  241. showToast(data.message, 'error');
  242. }
  243. },
  244. onError: (e) => {
  245. setIsRestoring(false);
  246. setOperationStatus('');
  247. const msg = e instanceof Error ? e.message : t('backup.failedToRestore');
  248. setRestoreResult({ success: false, message: msg });
  249. showToast(msg, 'error');
  250. },
  251. });
  252. // Block navigation while backup/restore is in progress
  253. useEffect(() => {
  254. const isOperationInProgress = isExporting || isRestoring;
  255. if (isOperationInProgress) {
  256. const handleBeforeUnload = (e: BeforeUnloadEvent) => {
  257. e.preventDefault();
  258. e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?';
  259. return e.returnValue;
  260. };
  261. window.addEventListener('beforeunload', handleBeforeUnload);
  262. return () => window.removeEventListener('beforeunload', handleBeforeUnload);
  263. }
  264. }, [isExporting, isRestoring]);
  265. // Test connection state
  266. const [testLoading, setTestLoading] = useState(false);
  267. const [testResult, setTestResult] = useState<{
  268. success: boolean;
  269. message: string;
  270. isPrivate: boolean | null;
  271. } | null>(null);
  272. // Inline save-error banner — backend rejection messages (e.g. the
  273. // "repository is not private" guard) are far too long for a toast.
  274. // Cleared on success, on the next save attempt, and when the user starts
  275. // editing the repo URL / token / provider so the banner doesn't persist
  276. // after the user has already addressed the cause.
  277. const [saveError, setSaveError] = useState<string | null>(null);
  278. // Auto-save debounce
  279. const settingsAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  280. const tokenAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  281. const initializationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  282. const lastSavedAutosaveStateRef = useRef<GitHubBackupAutosaveState | null>(null);
  283. const lastTokenScheduledForSaveRef = useRef('');
  284. const [isInitialized, setIsInitialized] = useState(false);
  285. const autoSaveState = useMemo<GitHubBackupAutosaveState>(() => ({
  286. repository_url: repoUrl,
  287. branch,
  288. provider,
  289. allow_insecure_http: allowInsecureHttp,
  290. schedule_enabled: scheduleEnabled,
  291. schedule_type: scheduleType,
  292. backup_kprofiles: backupKProfiles,
  293. backup_cloud_profiles: backupCloudProfiles,
  294. backup_settings: backupSettings,
  295. backup_spools: backupSpools,
  296. backup_archives: backupArchives,
  297. enabled,
  298. }), [repoUrl, branch, provider, allowInsecureHttp, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled]);
  299. const autoSaveStateFingerprint = useMemo(
  300. () => serializeAutosaveState(autoSaveState),
  301. [autoSaveState]
  302. );
  303. // Queries
  304. const { data: config, isLoading: configLoading } = useQuery<GitHubBackupConfig | null>({
  305. queryKey: ['github-backup-config'],
  306. queryFn: api.getGitHubBackupConfig,
  307. });
  308. const { data: status } = useQuery<GitHubBackupStatus>({
  309. queryKey: ['github-backup-status'],
  310. queryFn: api.getGitHubBackupStatus,
  311. refetchInterval: (query) => query.state.data?.is_running ? 500 : 10000, // Poll fast during backup
  312. });
  313. const { data: logs } = useQuery<GitHubBackupLog[]>({
  314. queryKey: ['github-backup-logs'],
  315. queryFn: () => api.getGitHubBackupLogs(20),
  316. });
  317. const { data: cloudStatus } = useQuery<CloudAuthStatus>({
  318. queryKey: ['cloud-status'],
  319. queryFn: api.getCloudStatus,
  320. });
  321. // How many cloud accounts the backup would actually collect from, across
  322. // both Bambu Cloud and Orca Cloud. Not the same question as `cloudStatus`,
  323. // which is only *this viewer's* Bambu sign-in: with auth enabled every user
  324. // holds their own credentials and the backup collects from all of them, so
  325. // an admin who never signed in to Bambu Cloud personally would otherwise see
  326. // the category disabled while there is plenty to back up (#2717).
  327. const { data: cloudAccounts } = useQuery<CloudAccountCounts>({
  328. queryKey: ['github-backup-cloud-accounts'],
  329. queryFn: api.getGitHubBackupCloudAccounts,
  330. staleTime: 60_000,
  331. });
  332. const connectedCloudAccounts = (cloudAccounts?.bambu ?? 0) + (cloudAccounts?.orca ?? 0);
  333. // Until the count arrives, fall back to the viewer's own Bambu status rather
  334. // than rendering the toggle as unavailable and making it flicker enabled.
  335. const anyCloudConnected = cloudAccounts
  336. ? connectedCloudAccounts > 0
  337. : !!cloudStatus?.is_authenticated;
  338. // Fetch printers and their statuses for K-profile availability
  339. const { data: printers } = useQuery<Printer[]>({
  340. queryKey: ['printers'],
  341. queryFn: api.getPrinters,
  342. });
  343. // Fetch printer statuses from API (not just cache) to get accurate connection status
  344. const printerStatusQueries = useQueries({
  345. queries: (printers ?? []).map(printer => ({
  346. queryKey: ['printerStatus', printer.id],
  347. queryFn: () => api.getPrinterStatus(printer.id),
  348. staleTime: 10000, // Consider stale after 10s
  349. refetchInterval: 30000, // Refresh every 30s
  350. })),
  351. });
  352. const printerStatuses = (printers ?? []).map((printer, index) => ({
  353. printer,
  354. connected: printerStatusQueries[index]?.data?.connected ?? false,
  355. }));
  356. const totalPrinters = printerStatuses.length;
  357. const connectedPrinters = printerStatuses.filter(p => p.connected).length;
  358. const noPrintersConnected = totalPrinters > 0 && connectedPrinters === 0;
  359. const somePrintersDisconnected = connectedPrinters > 0 && connectedPrinters < totalPrinters;
  360. // Initialize form from config
  361. useEffect(() => {
  362. if (initializationTimerRef.current) {
  363. clearTimeout(initializationTimerRef.current);
  364. }
  365. if (config) {
  366. setIsInitialized(false);
  367. lastSavedAutosaveStateRef.current = {
  368. repository_url: config.repository_url,
  369. branch: config.branch,
  370. provider: config.provider ?? 'github',
  371. allow_insecure_http: config.allow_insecure_http ?? false,
  372. schedule_enabled: config.schedule_enabled,
  373. schedule_type: config.schedule_type,
  374. backup_kprofiles: config.backup_kprofiles,
  375. backup_cloud_profiles: config.backup_cloud_profiles,
  376. backup_settings: config.backup_settings,
  377. backup_spools: config.backup_spools,
  378. backup_archives: config.backup_archives,
  379. enabled: config.enabled,
  380. };
  381. setRepoUrl(config.repository_url);
  382. setBranch(config.branch);
  383. setProvider(config.provider ?? 'github');
  384. setScheduleEnabled(config.schedule_enabled);
  385. setScheduleType(config.schedule_type);
  386. setBackupKProfiles(config.backup_kprofiles);
  387. setBackupCloudProfiles(config.backup_cloud_profiles);
  388. setBackupSettings(config.backup_settings);
  389. setBackupSpools(config.backup_spools);
  390. setBackupArchives(config.backup_archives);
  391. setAllowInsecureHttp(config.allow_insecure_http ?? false);
  392. setEnabled(config.enabled);
  393. setAccessToken(''); // Don't show stored token
  394. // Mark as initialized after a tick to avoid auto-save on initial load
  395. initializationTimerRef.current = setTimeout(() => { setIsInitialized(true); }, 100);
  396. } else {
  397. setIsInitialized(false);
  398. lastSavedAutosaveStateRef.current = null;
  399. setAccessToken('');
  400. }
  401. return () => {
  402. if (initializationTimerRef.current) {
  403. clearTimeout(initializationTimerRef.current);
  404. }
  405. };
  406. }, [config]);
  407. // Auto-save function for existing configs
  408. const autoSave = useCallback(async (includeToken: boolean = false) => {
  409. if (!config) return; // Only auto-save if config already exists
  410. try {
  411. if (includeToken && accessToken) {
  412. // Full save with new token
  413. await api.saveGitHubBackupConfig({
  414. ...autoSaveState,
  415. access_token: accessToken,
  416. });
  417. setAccessToken(''); // Clear after save
  418. setSaveError(null);
  419. showToast(t('backup.tokenUpdated'));
  420. lastTokenScheduledForSaveRef.current = '';
  421. } else {
  422. // Update without token
  423. await api.updateGitHubBackupConfig(getChangedAutosaveFields(
  424. autoSaveState,
  425. lastSavedAutosaveStateRef.current
  426. ));
  427. setSaveError(null);
  428. showToast(t('backup.settingsSaved'));
  429. }
  430. lastSavedAutosaveStateRef.current = autoSaveState;
  431. queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
  432. queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
  433. } catch (error) {
  434. setSaveError((error as Error).message);
  435. }
  436. }, [config, accessToken, autoSaveState, queryClient, showToast, t]);
  437. const autoSaveRef = useRef(autoSave);
  438. useEffect(() => {
  439. autoSaveRef.current = autoSave;
  440. }, [autoSave]);
  441. // Auto-save effect for existing configs (debounced)
  442. useEffect(() => {
  443. if (!isInitialized || !config) return;
  444. if (
  445. lastSavedAutosaveStateRef.current
  446. && autoSaveStateFingerprint === serializeAutosaveState(lastSavedAutosaveStateRef.current)
  447. ) return;
  448. if (settingsAutoSaveTimerRef.current) {
  449. clearTimeout(settingsAutoSaveTimerRef.current);
  450. }
  451. settingsAutoSaveTimerRef.current = setTimeout(() => {
  452. autoSave(false);
  453. }, 500);
  454. return () => {
  455. if (settingsAutoSaveTimerRef.current) {
  456. clearTimeout(settingsAutoSaveTimerRef.current);
  457. }
  458. };
  459. }, [isInitialized, config, autoSaveStateFingerprint, autoSave]);
  460. // Auto-save token when it changes (with longer debounce)
  461. useEffect(() => {
  462. if (!isInitialized || !config || !accessToken) return;
  463. if (accessToken === lastTokenScheduledForSaveRef.current) return;
  464. lastTokenScheduledForSaveRef.current = accessToken;
  465. if (tokenAutoSaveTimerRef.current) {
  466. clearTimeout(tokenAutoSaveTimerRef.current);
  467. }
  468. tokenAutoSaveTimerRef.current = setTimeout(() => {
  469. autoSaveRef.current(true);
  470. }, 1000);
  471. return () => {
  472. if (tokenAutoSaveTimerRef.current) {
  473. clearTimeout(tokenAutoSaveTimerRef.current);
  474. }
  475. };
  476. }, [isInitialized, accessToken, config]);
  477. // Mutations
  478. const saveConfigMutation = useMutation({
  479. mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
  480. onMutate: () => {
  481. setSaveError(null);
  482. },
  483. onSuccess: () => {
  484. queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
  485. queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
  486. showToast(t('backup.githubBackupEnabled'));
  487. setAccessToken('');
  488. setIsInitialized(true);
  489. },
  490. onError: (error: Error) => {
  491. setSaveError(error.message);
  492. },
  493. });
  494. const triggerBackupMutation = useMutation<GitHubBackupTriggerResponse, Error>({
  495. mutationFn: api.triggerGitHubBackup,
  496. onSuccess: (result) => {
  497. queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
  498. queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
  499. if (result.success) {
  500. if (result.files_changed > 0) {
  501. showToast(t('backup.backupCompleteFiles', { count: result.files_changed }));
  502. } else {
  503. showToast(t('backup.backupSkippedNoChanges'));
  504. }
  505. } else {
  506. showToast(t('backup.backupFailed2', { message: result.message }), 'error');
  507. }
  508. },
  509. onError: (error: Error) => {
  510. showToast(t('backup.backupFailed2', { message: error.message }), 'error');
  511. },
  512. });
  513. const clearLogsMutation = useMutation<{ deleted: number; message: string }, Error>({
  514. mutationFn: () => api.clearGitHubBackupLogs(0),
  515. onSuccess: (result) => {
  516. queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
  517. showToast(t('backup.clearedLogs', { count: result.deleted }));
  518. },
  519. onError: (error: Error) => {
  520. showToast(t('backup.failedToClearLogs', { message: error.message }), 'error');
  521. },
  522. });
  523. const handleTestConnection = async () => {
  524. setTestLoading(true);
  525. setTestResult(null);
  526. try {
  527. let result;
  528. // If user entered a new token, test with those credentials
  529. if (accessToken) {
  530. if (!repoUrl) {
  531. showToast(t('backup.enterRepoUrl'), 'error');
  532. setTestLoading(false);
  533. return;
  534. }
  535. result = await api.testGitHubConnection(repoUrl, accessToken, provider);
  536. } else if (config?.has_token) {
  537. // Use stored credentials
  538. result = await api.testGitHubStoredConnection();
  539. } else {
  540. showToast(t('backup.enterRepoAndToken'), 'error');
  541. setTestLoading(false);
  542. return;
  543. }
  544. setTestResult({
  545. success: result.success,
  546. message: result.message,
  547. isPrivate: result.is_private,
  548. });
  549. } catch (error) {
  550. setTestResult({ success: false, message: (error as Error).message, isPrivate: null });
  551. } finally {
  552. setTestLoading(false);
  553. }
  554. };
  555. // Initial setup save (only for new configs)
  556. const handleInitialSetup = () => {
  557. if (!repoUrl) {
  558. showToast(t('backup.repoRequired'), 'error');
  559. return;
  560. }
  561. if (!accessToken) {
  562. showToast(t('backup.tokenRequired'), 'error');
  563. return;
  564. }
  565. saveConfigMutation.mutate({
  566. repository_url: repoUrl,
  567. access_token: accessToken,
  568. branch,
  569. provider,
  570. allow_insecure_http: allowInsecureHttp,
  571. schedule_enabled: scheduleEnabled,
  572. schedule_type: scheduleType,
  573. backup_kprofiles: backupKProfiles,
  574. backup_cloud_profiles: backupCloudProfiles,
  575. backup_settings: backupSettings,
  576. backup_spools: backupSpools,
  577. backup_archives: backupArchives,
  578. enabled,
  579. });
  580. };
  581. if (configLoading) {
  582. return (
  583. <div className="flex items-center justify-center py-12">
  584. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  585. </div>
  586. );
  587. }
  588. return (
  589. <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
  590. {/* Left Column - Git Backup */}
  591. <div className="space-y-6">
  592. <Card id="card-backup-github">
  593. <CardHeader>
  594. <div className="flex items-center justify-between">
  595. <div className="flex items-center gap-2">
  596. <Github className="w-5 h-5 text-gray-400" />
  597. <h2 className="text-lg font-semibold text-white">{t('backup.githubBackup')}</h2>
  598. </div>
  599. {config && (
  600. <div className="flex items-center gap-2">
  601. <span className="text-sm text-bambu-gray">{t('backup.enabled')}</span>
  602. <Toggle
  603. checked={enabled}
  604. onChange={setEnabled}
  605. />
  606. </div>
  607. )}
  608. </div>
  609. </CardHeader>
  610. <CardContent className="space-y-4">
  611. <p className="text-sm text-bambu-gray">
  612. {t('backup.githubDescription')}
  613. </p>
  614. {/* Provider Selection */}
  615. <div>
  616. <label htmlFor="git-provider-select" className="block text-sm text-bambu-gray mb-1">{t('backup.provider')}</label>
  617. <select
  618. id="git-provider-select"
  619. value={provider}
  620. onChange={(e) => { setProvider(e.target.value as GitProviderType); setTestResult(null); setSaveError(null); }}
  621. className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  622. >
  623. <option value="github">{t('backup.providerGitHub')}</option>
  624. <option value="gitlab">{t('backup.providerGitLab')}</option>
  625. <option value="gitea">{t('backup.providerGitea')}</option>
  626. <option value="forgejo">{t('backup.providerForgejo')}</option>
  627. </select>
  628. </div>
  629. {/* Repository URL */}
  630. <div>
  631. <label className="block text-sm text-bambu-gray mb-1">
  632. {t('backup.repositoryUrl')}
  633. </label>
  634. <input
  635. type="text"
  636. value={repoUrl}
  637. onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); setSaveError(null); }}
  638. placeholder={t(PROVIDER_REPO_URL_I18N_KEY[provider])}
  639. className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  640. />
  641. <label className="flex items-start gap-2 mt-2 cursor-pointer">
  642. <input
  643. type="checkbox"
  644. checked={allowInsecureHttp}
  645. onChange={(e) => { setAllowInsecureHttp(e.target.checked); setTestResult(null); }}
  646. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  647. />
  648. <div>
  649. <span className="text-sm text-white">{t('backup.allowInsecureHttp')}</span>
  650. <p className="text-xs text-bambu-gray">{t('backup.allowInsecureHttpHint')}</p>
  651. </div>
  652. </label>
  653. </div>
  654. {/* Access Token */}
  655. <div>
  656. <label className="block text-sm text-bambu-gray mb-1">
  657. {t('backup.personalAccessToken')} {config?.has_token && <span className="text-green-700 dark:text-green-400">{t('backup.tokenSaved')}</span>}
  658. </label>
  659. <input
  660. type="password"
  661. value={accessToken}
  662. onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); setSaveError(null); }}
  663. placeholder={config?.has_token ? t('backup.enterNewToken') : PROVIDER_TOKEN_PLACEHOLDER[provider]}
  664. className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  665. />
  666. <p className="text-xs text-bambu-gray mt-1">
  667. {t(PROVIDER_TOKEN_HINT_I18N_KEY[provider])}
  668. </p>
  669. </div>
  670. {/* Branch - inline with schedule */}
  671. <div className="grid grid-cols-2 gap-4">
  672. <div>
  673. <label className="block text-sm text-bambu-gray mb-1">{t('backup.branch')}</label>
  674. <input
  675. type="text"
  676. value={branch}
  677. onChange={(e) => setBranch(e.target.value)}
  678. placeholder="main"
  679. className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  680. />
  681. </div>
  682. <div>
  683. <label className="block text-sm text-bambu-gray mb-1">{t('backup.autoBackup')}</label>
  684. <select
  685. value={scheduleEnabled ? scheduleType : 'disabled'}
  686. onChange={(e) => {
  687. if (e.target.value === 'disabled') {
  688. setScheduleEnabled(false);
  689. } else {
  690. setScheduleEnabled(true);
  691. setScheduleType(e.target.value as ScheduleType);
  692. }
  693. }}
  694. className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  695. >
  696. <option value="disabled">{t('backup.manualOnly')}</option>
  697. <option value="hourly">{t('backup.hourly')}</option>
  698. <option value="daily">{t('backup.daily')}</option>
  699. <option value="weekly">{t('backup.weekly')}</option>
  700. </select>
  701. </div>
  702. </div>
  703. {/* What to backup */}
  704. <div>
  705. <label className="block text-sm text-bambu-gray mb-2">{t('backup.includeInBackup')}</label>
  706. <div className="space-y-2">
  707. <label className={`flex items-start gap-2 ${noPrintersConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
  708. <input
  709. type="checkbox"
  710. checked={backupKProfiles}
  711. onChange={(e) => setBackupKProfiles(e.target.checked)}
  712. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  713. disabled={noPrintersConnected}
  714. />
  715. <div className="flex-1">
  716. <div className="flex items-center gap-2">
  717. <span className={`text-sm ${noPrintersConnected ? 'text-bambu-gray' : 'text-white'}`}>{t('backup.kProfiles')}</span>
  718. {noPrintersConnected && (
  719. <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
  720. <AlertTriangle className="w-3 h-3" />
  721. {t('backup.noPrintersConnected')}
  722. </span>
  723. )}
  724. {somePrintersDisconnected && (
  725. <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
  726. <AlertTriangle className="w-3 h-3" />
  727. {t('backup.printersConnected', { connected: connectedPrinters, total: totalPrinters })}
  728. </span>
  729. )}
  730. </div>
  731. <p className="text-xs text-bambu-gray">{t('backup.kProfilesDescription')}</p>
  732. </div>
  733. </label>
  734. <label className={`flex items-start gap-2 ${!anyCloudConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
  735. <input
  736. type="checkbox"
  737. checked={backupCloudProfiles}
  738. onChange={(e) => setBackupCloudProfiles(e.target.checked)}
  739. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  740. disabled={!anyCloudConnected}
  741. />
  742. <div>
  743. <div className="flex items-center gap-2">
  744. <span className={`text-sm ${anyCloudConnected ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
  745. {!anyCloudConnected && (
  746. <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
  747. <AlertTriangle className="w-3 h-3" />
  748. {t('backup.cloudLoginRequiredShort')}
  749. </span>
  750. )}
  751. </div>
  752. <p className="text-xs text-bambu-gray">{t('backup.cloudProfilesDescription')}</p>
  753. {/* Say how many accounts are in scope. On a multi-user
  754. install the presets being backed up are other people's,
  755. and the count is the only honest way to show that
  756. without naming them. */}
  757. {connectedCloudAccounts > 0 && (
  758. <p className="text-xs text-bambu-gray mt-0.5">
  759. {t('backup.cloudProfilesAccounts', {
  760. bambu: cloudAccounts?.bambu ?? 0,
  761. orca: cloudAccounts?.orca ?? 0,
  762. })}
  763. </p>
  764. )}
  765. </div>
  766. </label>
  767. <label className="flex items-start gap-2 cursor-pointer">
  768. <input
  769. type="checkbox"
  770. checked={backupSettings}
  771. onChange={(e) => setBackupSettings(e.target.checked)}
  772. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  773. />
  774. <div>
  775. <span className="text-white text-sm">{t('backup.appSettings')}</span>
  776. <p className="text-xs text-bambu-gray">{t('backup.appSettingsDescription')}</p>
  777. </div>
  778. </label>
  779. <label className="flex items-start gap-2 cursor-pointer">
  780. <input
  781. type="checkbox"
  782. checked={backupSpools}
  783. onChange={(e) => setBackupSpools(e.target.checked)}
  784. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  785. />
  786. <div>
  787. <span className="text-white text-sm">{t('backup.spoolInventory')}</span>
  788. <p className="text-xs text-bambu-gray">{t('backup.spoolInventoryDescription')}</p>
  789. </div>
  790. </label>
  791. <label className="flex items-start gap-2 cursor-pointer">
  792. <input
  793. type="checkbox"
  794. checked={backupArchives}
  795. onChange={(e) => setBackupArchives(e.target.checked)}
  796. className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  797. />
  798. <div>
  799. <span className="text-white text-sm">{t('backup.printArchives')}</span>
  800. <p className="text-xs text-bambu-gray">{t('backup.printArchivesDescription')}</p>
  801. </div>
  802. </label>
  803. </div>
  804. </div>
  805. {/* Test + Status + Actions */}
  806. <div className="border-t border-bambu-dark-tertiary pt-4 space-y-3">
  807. {/* Status line */}
  808. {status?.configured && (
  809. <div className="flex items-center justify-between text-sm">
  810. <div className="flex items-center gap-2 text-bambu-gray">
  811. {status.last_backup_at ? (
  812. <>
  813. <span>{t('backup.lastBackupAt')} {formatRelativeTime(status.last_backup_at, 'system', t)}</span>
  814. <StatusBadge status={status.last_backup_status} />
  815. </>
  816. ) : (
  817. <span>{t('backup.noBackupsYet')}</span>
  818. )}
  819. </div>
  820. {status.next_scheduled_run && (
  821. <span className="text-bambu-gray">
  822. <Clock className="w-3 h-3 inline mr-1" />
  823. {t('backup.next')} {formatRelativeTime(status.next_scheduled_run, 'system', t)}
  824. </span>
  825. )}
  826. </div>
  827. )}
  828. {/* Save error — inline banner. Keeps long backend rejection
  829. messages (e.g. the "repository is not private" guard)
  830. readable instead of clipped to a toast. */}
  831. {saveError && (
  832. <div className="text-sm text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded p-3 flex items-start gap-2">
  833. <XCircle className="w-4 h-4 mt-0.5 shrink-0" />
  834. <div className="flex-1 leading-relaxed whitespace-pre-wrap break-words">{saveError}</div>
  835. <button
  836. type="button"
  837. onClick={() => setSaveError(null)}
  838. className="text-bambu-gray hover:text-white shrink-0"
  839. aria-label={t('common.dismiss', 'Dismiss')}
  840. >
  841. <XCircle className="w-4 h-4" />
  842. </button>
  843. </div>
  844. )}
  845. {/* Test result */}
  846. {testResult && (
  847. <div className="space-y-1.5">
  848. <div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}>
  849. {testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
  850. {testResult.message}
  851. </div>
  852. {testResult.success && testResult.isPrivate === true && (
  853. <div className="text-xs flex items-center gap-1 text-green-700 dark:text-green-400">
  854. <CheckCircle className="w-3.5 h-3.5" />
  855. {t('backup.repoIsPrivate', 'Repository is private — safe to back up to.')}
  856. </div>
  857. )}
  858. {testResult.success && testResult.isPrivate === false && (
  859. <div className="text-xs text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded p-2 flex items-start gap-1.5">
  860. <XCircle className="w-4 h-4 mt-0.5 shrink-0" />
  861. <span>
  862. {t(
  863. 'backup.repoIsPublicWarning',
  864. 'Repository is PUBLIC. Bambuddy backups include MQTT credentials, Home Assistant tokens, Prometheus tokens, your Bambu Cloud email, and printer access codes via K-profiles. Saving is blocked until you make the repository private in your provider\'s settings.',
  865. )}
  866. </span>
  867. </div>
  868. )}
  869. {testResult.success && testResult.isPrivate === null && (
  870. <div className="text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30 rounded p-2 flex items-start gap-1.5">
  871. <XCircle className="w-4 h-4 mt-0.5 shrink-0" />
  872. <span>
  873. {t(
  874. 'backup.repoVisibilityUnknown',
  875. 'Could not determine repository visibility. Bambuddy refuses to back up to anything not confirmed private; saving will be blocked.',
  876. )}
  877. </span>
  878. </div>
  879. )}
  880. </div>
  881. )}
  882. {/* Action buttons */}
  883. <div className="flex flex-wrap items-center gap-2">
  884. {status?.configured ? (
  885. <>
  886. {(triggerBackupMutation.isPending || status.is_running) ? (
  887. <div className="flex items-center gap-2 text-bambu-green">
  888. <Loader2 className="w-4 h-4 animate-spin" />
  889. <span className="text-sm">{status.progress || t('backup.startingBackup')}</span>
  890. </div>
  891. ) : (
  892. <>
  893. <Button
  894. variant="primary"
  895. size="sm"
  896. onClick={() => triggerBackupMutation.mutate()}
  897. disabled={!config?.enabled}
  898. >
  899. <Play className="w-4 h-4" />
  900. {t('backup.backupNow')}
  901. </Button>
  902. <Button
  903. variant="secondary"
  904. size="sm"
  905. onClick={handleTestConnection}
  906. disabled={testLoading}
  907. >
  908. {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
  909. {t('backup.test')}
  910. </Button>
  911. {/* Restore from the backup repo (#2656) */}
  912. {canRestoreFromGit && (
  913. <Button
  914. variant="secondary"
  915. size="sm"
  916. onClick={() => setShowGitRestore(true)}
  917. disabled={status.restore_running}
  918. >
  919. <RotateCcw className="w-4 h-4" />
  920. {t('backup.restoreFromGit.button')}
  921. </Button>
  922. )}
  923. </>
  924. )}
  925. </>
  926. ) : (
  927. <>
  928. <Button
  929. variant="primary"
  930. size="sm"
  931. onClick={handleInitialSetup}
  932. disabled={saveConfigMutation.isPending || !repoUrl || !accessToken}
  933. >
  934. {saveConfigMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
  935. {t('backup.enableBackup')}
  936. </Button>
  937. <Button
  938. variant="secondary"
  939. size="sm"
  940. onClick={handleTestConnection}
  941. disabled={testLoading || !repoUrl || !accessToken}
  942. >
  943. {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
  944. {t('backup.testConnection')}
  945. </Button>
  946. </>
  947. )}
  948. </div>
  949. </div>
  950. </CardContent>
  951. </Card>
  952. {/* Backup History - only show if configured and has logs */}
  953. {logs && logs.length > 0 && (
  954. <Card id="card-backup-history">
  955. <CardHeader>
  956. <div className="flex items-center justify-between">
  957. <div className="flex items-center gap-2">
  958. <History className="w-5 h-5 text-gray-400" />
  959. <h2 className="text-lg font-semibold text-white">{t('backup.history')}</h2>
  960. </div>
  961. <Button
  962. variant="ghost"
  963. size="sm"
  964. onClick={() => setShowClearLogsConfirm(true)}
  965. disabled={clearLogsMutation.isPending}
  966. >
  967. <Trash2 className="w-4 h-4" />
  968. {t('backup.clear')}
  969. </Button>
  970. </div>
  971. </CardHeader>
  972. <CardContent>
  973. <div className="overflow-x-auto">
  974. <table className="w-full text-sm">
  975. <thead>
  976. <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
  977. <th className="text-left py-2 px-2">{t('backup.date')}</th>
  978. <th className="text-left py-2 px-2">{t('backup.trigger')}</th>
  979. <th className="text-left py-2 px-2">{t('backup.status')}</th>
  980. <th className="text-left py-2 px-2">{t('backup.commit')}</th>
  981. </tr>
  982. </thead>
  983. <tbody>
  984. {logs.slice(0, 10).map((log) => (
  985. <tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
  986. <td className="py-2 px-2 text-white">{formatDateTime(log.started_at)}</td>
  987. {/* A restore writes a log row too, and without this it
  988. was indistinguishable from a backup: a successful
  989. run dated now, while Last backup said otherwise. */}
  990. <td className="py-2 px-2 text-bambu-gray">
  991. {t(`backup.triggers.${log.trigger}`, { defaultValue: log.trigger })}
  992. </td>
  993. <td className="py-2 px-2"><StatusBadge status={log.status} /></td>
  994. <td className="py-2 px-2">
  995. {log.commit_sha ? (
  996. <a
  997. href={`${config?.repository_url}/commit/${log.commit_sha}`}
  998. target="_blank"
  999. rel="noopener noreferrer"
  1000. className="text-bambu-green hover:underline inline-flex items-center gap-1"
  1001. >
  1002. {log.commit_sha.substring(0, 7)}
  1003. <ExternalLink className="w-3 h-3" />
  1004. </a>
  1005. ) : (
  1006. <span className="text-bambu-gray">-</span>
  1007. )}
  1008. </td>
  1009. </tr>
  1010. ))}
  1011. </tbody>
  1012. </table>
  1013. </div>
  1014. </CardContent>
  1015. </Card>
  1016. )}
  1017. </div>
  1018. {/* Right Column - Local Backup */}
  1019. <div className="space-y-6">
  1020. <Card id="card-backup-local">
  1021. <CardHeader>
  1022. <div className="flex items-center gap-2">
  1023. <Database className="w-5 h-5 text-gray-400" />
  1024. <h2 className="text-lg font-semibold text-white">{t('backup.localBackup')}</h2>
  1025. </div>
  1026. </CardHeader>
  1027. <CardContent className="space-y-4">
  1028. <p className="text-sm text-bambu-gray">
  1029. {t('backup.localBackupDescription')}
  1030. </p>
  1031. {/* Export */}
  1032. <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
  1033. <div>
  1034. <p className="text-white">{t('backup.downloadBackupLabel')}</p>
  1035. <p className="text-sm text-bambu-gray">
  1036. {t('backup.completeBackupZip')}
  1037. </p>
  1038. {/* A full backup holds every password hash and 2FA secret. */}
  1039. {!isAdmin && <p className="text-xs text-bambu-gray mt-1">{t('settings.adminOnlySetting')}</p>}
  1040. </div>
  1041. <Button
  1042. variant="secondary"
  1043. size="sm"
  1044. disabled={isExporting || isRestoring || !isAdmin}
  1045. onClick={async () => {
  1046. setIsExporting(true);
  1047. setOperationStatus(t('backup.preparingBackup'));
  1048. try {
  1049. setOperationStatus(t('backup.creatingArchive'));
  1050. const { blob, filename } = await api.exportBackup();
  1051. setOperationStatus(t('backup.downloadingFile'));
  1052. const url = URL.createObjectURL(blob);
  1053. const a = document.createElement('a');
  1054. a.href = url;
  1055. a.download = filename;
  1056. a.click();
  1057. URL.revokeObjectURL(url);
  1058. showToast(t('backup.backupDownloaded'));
  1059. } catch (e) {
  1060. showToast(t('backup.failedToCreateBackup', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
  1061. } finally {
  1062. setIsExporting(false);
  1063. setOperationStatus('');
  1064. }
  1065. }}
  1066. >
  1067. <Download className="w-4 h-4" />
  1068. {t('backup.download')}
  1069. </Button>
  1070. </div>
  1071. {/* Import */}
  1072. <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
  1073. <div>
  1074. <p className="text-white">{t('backup.restoreBackup')}</p>
  1075. <p className="text-sm text-bambu-gray">
  1076. {t('backup.restoreDescription')}
  1077. </p>
  1078. <p className="text-xs text-bambu-gray-light mt-1">
  1079. {t('backup.restoreNote')}
  1080. </p>
  1081. </div>
  1082. <input
  1083. ref={fileInputRef}
  1084. type="file"
  1085. accept=".zip"
  1086. className="hidden"
  1087. onChange={(e) => {
  1088. const file = e.target.files?.[0];
  1089. if (file) {
  1090. setRestoreFile(file);
  1091. setShowRestoreConfirm(true);
  1092. }
  1093. e.target.value = '';
  1094. }}
  1095. />
  1096. <Button
  1097. variant="secondary"
  1098. size="sm"
  1099. disabled={isRestoring || isExporting || !isAdmin}
  1100. title={!isAdmin ? t('settings.adminOnlySetting') : undefined}
  1101. onClick={() => fileInputRef.current?.click()}
  1102. >
  1103. <Upload className="w-4 h-4" />
  1104. {t('backup.restore')}
  1105. </Button>
  1106. </div>
  1107. {/* Restore result message */}
  1108. {restoreResult && (
  1109. <div className={`p-3 rounded-lg ${restoreResult.success ? 'bg-green-50 dark:bg-green-500/10 border border-green-300 dark:border-green-500/30' : 'bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30'}`}>
  1110. <div className="flex items-start gap-2 text-sm">
  1111. {restoreResult.success ? (
  1112. <CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
  1113. ) : (
  1114. <XCircle className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
  1115. )}
  1116. <div className={restoreResult.success ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'}>
  1117. {restoreResult.message}
  1118. {restoreResult.success && (
  1119. <div className="mt-2">
  1120. <Button
  1121. size="sm"
  1122. onClick={() => window.location.reload()}
  1123. >
  1124. <RotateCcw className="w-3 h-3" />
  1125. {t('backup.reloadNow')}
  1126. </Button>
  1127. </div>
  1128. )}
  1129. </div>
  1130. </div>
  1131. </div>
  1132. )}
  1133. {/* Warning */}
  1134. <div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
  1135. <div className="flex items-start gap-2 text-sm">
  1136. <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
  1137. <div className="text-yellow-800 dark:text-yellow-200">
  1138. <span className="font-medium">{t('backup.restoreReplacesAll')}</span>{' '}
  1139. <span className="text-yellow-800/80 dark:text-yellow-200/70">{t('backup.restoreReplacesAllDetail')}</span>
  1140. </div>
  1141. </div>
  1142. </div>
  1143. </CardContent>
  1144. </Card>
  1145. {/* Scheduled Local Backups */}
  1146. <Card id="card-backup-scheduled">
  1147. <CardHeader>
  1148. <div className="flex items-center justify-between">
  1149. <div className="flex items-center gap-2">
  1150. <FolderArchive className="w-5 h-5 text-gray-400" />
  1151. <h2 className="text-lg font-semibold text-white">{t('backup.scheduledBackup')}</h2>
  1152. </div>
  1153. <Toggle
  1154. checked={localBackupStatus?.enabled ?? false}
  1155. onChange={async (checked) => {
  1156. try {
  1157. await api.updateSettings({ local_backup_enabled: checked });
  1158. queryClient.invalidateQueries({ queryKey: ['settings'] });
  1159. showToast(t('backup.settingsSaved'));
  1160. } catch (e) {
  1161. showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
  1162. }
  1163. refetchLocalStatus();
  1164. }}
  1165. />
  1166. </div>
  1167. </CardHeader>
  1168. <CardContent className="space-y-4">
  1169. <p className="text-sm text-bambu-gray">
  1170. {t('backup.scheduledBackupDescription')}
  1171. </p>
  1172. {localBackupStatus?.enabled && (
  1173. <>
  1174. {/* Schedule + Time + Retention */}
  1175. <div className="grid grid-cols-3 gap-4">
  1176. <div>
  1177. <label className="block text-sm text-bambu-gray mb-1">{t('backup.frequency')}</label>
  1178. <select
  1179. value={localBackupStatus?.schedule ?? 'daily'}
  1180. className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1181. onChange={async (e) => {
  1182. try {
  1183. await api.updateSettings({ local_backup_schedule: e.target.value });
  1184. showToast(t('backup.settingsSaved'));
  1185. } catch (e) {
  1186. showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
  1187. }
  1188. refetchLocalStatus();
  1189. }}
  1190. >
  1191. <option value="hourly">{t('backup.hourly')}</option>
  1192. <option value="daily">{t('backup.daily')}</option>
  1193. <option value="weekly">{t('backup.weekly')}</option>
  1194. </select>
  1195. </div>
  1196. {(localBackupStatus?.schedule ?? 'daily') !== 'hourly' && (
  1197. <div>
  1198. <label className="block text-sm text-bambu-gray mb-1">{t('backup.backupTime')}</label>
  1199. <input
  1200. type="time"
  1201. value={localBackupStatus?.time ?? '03:00'}
  1202. className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1203. onChange={async (e) => {
  1204. try {
  1205. await api.updateSettings({ local_backup_time: e.target.value });
  1206. showToast(t('backup.settingsSaved'));
  1207. } catch (err) {
  1208. showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');
  1209. }
  1210. refetchLocalStatus();
  1211. }}
  1212. />
  1213. <p className="text-xs text-bambu-gray-light mt-1">
  1214. {t('backup.localTimeHint', { tz: localBackupStatus?.timezone || 'UTC' })}
  1215. </p>
  1216. </div>
  1217. )}
  1218. <div>
  1219. <label className="block text-sm text-bambu-gray mb-1">{t('backup.retention')}</label>
  1220. <NumberInput
  1221. min={1}
  1222. max={100}
  1223. value={localBackupStatus?.retention ?? 5}
  1224. className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1225. onChange={async (val) => {
  1226. try {
  1227. await api.updateSettings({ local_backup_retention: val });
  1228. showToast(t('backup.settingsSaved'));
  1229. } catch (e) {
  1230. showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
  1231. }
  1232. refetchLocalStatus();
  1233. }}
  1234. fallback={5}
  1235. />
  1236. <p className="text-xs text-bambu-gray-light mt-1">{t('backup.retentionDescription')}</p>
  1237. </div>
  1238. </div>
  1239. {/* Output Path */}
  1240. <div>
  1241. <label className="block text-sm text-bambu-gray mb-1">{t('backup.outputPath')}</label>
  1242. <input
  1243. type="text"
  1244. value={localBackupPath}
  1245. onChange={(e) => setLocalBackupPath(e.target.value)}
  1246. disabled={!isAdmin}
  1247. title={!isAdmin ? t('settings.adminOnlySetting') : undefined}
  1248. className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
  1249. onBlur={async () => {
  1250. if (!isAdmin) return;
  1251. try {
  1252. await api.updateSettings({ local_backup_path: localBackupPath });
  1253. showToast(t('backup.settingsSaved'));
  1254. } catch (err) {
  1255. showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');
  1256. }
  1257. refetchLocalStatus();
  1258. refetchLocalBackups();
  1259. refetchLocalPathCheck();
  1260. }}
  1261. onKeyDown={(e) => {
  1262. if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
  1263. }}
  1264. />
  1265. <p className="text-xs text-bambu-gray-light mt-1">
  1266. {localBackupPath
  1267. ? t('backup.outputPathDescription')
  1268. : <>{t('backup.defaultPathLabel')} <code className="text-bambu-gray">{localBackupStatus?.default_path || '...'}</code></>
  1269. }
  1270. </p>
  1271. {/* The directory cannot be written to — say why, and how to fix it (#2544) */}
  1272. {localBackupPathCheck && !localBackupPathCheck.writable && (
  1273. <div className="mt-2 p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
  1274. <div className="flex items-start gap-2 text-sm">
  1275. <AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
  1276. <div className="min-w-0 text-red-800 dark:text-red-200">
  1277. <p className="font-medium">{t('backup.pathCheck.title')}</p>
  1278. <p className="mt-1 text-red-800/80 dark:text-red-200/80">
  1279. {t(`backup.pathCheck.${localBackupPathCheck.code}`, {
  1280. path: localBackupPathCheck.path,
  1281. defaultValue: localBackupPathCheck.message,
  1282. })}
  1283. </p>
  1284. {localBackupPathCheck.remedy && (
  1285. <>
  1286. <p className="mt-2 font-medium">{t('backup.pathCheck.howToFix')}</p>
  1287. <pre className="mt-1 p-2 rounded bg-black/10 dark:bg-black/40 text-xs whitespace-pre-wrap break-words">
  1288. {localBackupPathCheck.remedy}
  1289. </pre>
  1290. </>
  1291. )}
  1292. {localBackupPathCheck.detail && (
  1293. <p className="mt-2 text-xs text-red-800/60 dark:text-red-200/60 break-words">
  1294. {localBackupPathCheck.detail}
  1295. </p>
  1296. )}
  1297. </div>
  1298. </div>
  1299. </div>
  1300. )}
  1301. {/* Writable, but only inside the container — the backups die with it */}
  1302. {localBackupPathCheck?.writable && localBackupPathCheck.warning === 'container_ephemeral' && (
  1303. <div className="mt-2 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
  1304. <div className="flex items-start gap-2 text-sm">
  1305. <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
  1306. <div className="min-w-0 text-yellow-800 dark:text-yellow-200">
  1307. <p className="font-medium">{t('backup.pathCheck.ephemeralTitle')}</p>
  1308. <p className="mt-1 text-yellow-800/80 dark:text-yellow-200/80">
  1309. {t('backup.pathCheck.container_ephemeral', { path: localBackupPathCheck.path })}
  1310. </p>
  1311. {localBackupPathCheck.remedy && (
  1312. <pre className="mt-1 p-2 rounded bg-black/10 dark:bg-black/40 text-xs whitespace-pre-wrap break-words">
  1313. {localBackupPathCheck.remedy}
  1314. </pre>
  1315. )}
  1316. </div>
  1317. </div>
  1318. </div>
  1319. )}
  1320. </div>
  1321. {/* Status + Run Now */}
  1322. <div className="flex items-center justify-between py-3 border-t border-bambu-dark-tertiary">
  1323. <div className="text-sm">
  1324. {localBackupStatus?.last_backup_at && (
  1325. <div className="flex items-center gap-2 text-bambu-gray">
  1326. <span>{t('backup.lastBackup')}:</span>
  1327. <StatusBadge status={localBackupStatus.last_status} />
  1328. <span>{formatRelativeTime(localBackupStatus.last_backup_at)}</span>
  1329. </div>
  1330. )}
  1331. {localBackupStatus?.next_run && (
  1332. <div className="text-bambu-gray mt-1">
  1333. <span>{t('backup.nextBackup')}: </span>
  1334. <span>{formatDateTime(localBackupStatus.next_run)}</span>
  1335. </div>
  1336. )}
  1337. </div>
  1338. <Button
  1339. variant="secondary"
  1340. size="sm"
  1341. disabled={localBackupStatus?.is_running || triggerLocalBackupMutation.isPending}
  1342. onClick={() => triggerLocalBackupMutation.mutate()}
  1343. >
  1344. {localBackupStatus?.is_running || triggerLocalBackupMutation.isPending ? (
  1345. <Loader2 className="w-4 h-4 animate-spin" />
  1346. ) : (
  1347. <Play className="w-4 h-4" />
  1348. )}
  1349. {localBackupStatus?.is_running ? t('backup.backupRunning') : t('backup.runNow')}
  1350. </Button>
  1351. </div>
  1352. {/* Backup Files List */}
  1353. {localBackups && localBackups.length > 0 && (
  1354. <div className="border-t border-bambu-dark-tertiary pt-3">
  1355. <h3 className="text-sm font-medium text-white mb-2">{t('backup.backupFiles')}</h3>
  1356. <div className="space-y-1">
  1357. {localBackups.map((file) => (
  1358. <div key={file.filename} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-bambu-dark-tertiary/50 text-sm">
  1359. <div className="flex-1 min-w-0">
  1360. <span className="text-white truncate block">{file.filename}</span>
  1361. <span className="text-bambu-gray text-xs">
  1362. {(file.size / 1024 / 1024).toFixed(1)} MB &middot; {formatDateTime(file.created_at)}
  1363. </span>
  1364. </div>
  1365. <div className="flex items-center gap-1 flex-shrink-0">
  1366. {isAdmin && (
  1367. <>
  1368. <button
  1369. className="text-bambu-gray hover:text-bambu-green p-1"
  1370. title={t('backup.download')}
  1371. onClick={async () => {
  1372. try {
  1373. const { blob, filename: fname } = await api.downloadLocalBackup(file.filename);
  1374. const url = URL.createObjectURL(blob);
  1375. const a = document.createElement('a');
  1376. a.href = url;
  1377. a.download = fname;
  1378. a.click();
  1379. URL.revokeObjectURL(url);
  1380. } catch {
  1381. showToast(t('backup.scheduledBackupFailed'), 'error');
  1382. }
  1383. }}
  1384. >
  1385. <Download className="w-3.5 h-3.5" />
  1386. </button>
  1387. <button
  1388. className="text-bambu-gray hover:text-yellow-600 dark:hover:text-yellow-400 p-1"
  1389. title={t('backup.restore')}
  1390. onClick={() => setRestoreConfirmFile(file.filename)}
  1391. >
  1392. <RotateCcw className="w-3.5 h-3.5" />
  1393. </button>
  1394. </>
  1395. )}
  1396. <button
  1397. className="text-bambu-gray hover:text-red-600 dark:hover:text-red-400 p-1"
  1398. onClick={() => setDeleteConfirmFile(file.filename)}
  1399. title={t('backup.deleteBackup')}
  1400. >
  1401. <Trash2 className="w-3.5 h-3.5" />
  1402. </button>
  1403. </div>
  1404. </div>
  1405. ))}
  1406. </div>
  1407. </div>
  1408. )}
  1409. {localBackups && localBackups.length === 0 && (
  1410. <p className="text-sm text-bambu-gray text-center py-3 border-t border-bambu-dark-tertiary">
  1411. {t('backup.noScheduledBackups')}
  1412. </p>
  1413. )}
  1414. </>
  1415. )}
  1416. </CardContent>
  1417. </Card>
  1418. </div>
  1419. {/* Restore from the Git backup repository (#2656) */}
  1420. {showGitRestore && <GitHubRestoreModal onClose={() => setShowGitRestore(false)} />}
  1421. {showClearLogsConfirm && (
  1422. <ConfirmModal
  1423. title={t('backup.clearHistoryConfirmTitle')}
  1424. message={t('backup.clearHistoryConfirmMessage')}
  1425. confirmText={t('backup.clear')}
  1426. variant="danger"
  1427. onConfirm={() => {
  1428. setShowClearLogsConfirm(false);
  1429. clearLogsMutation.mutate();
  1430. }}
  1431. onCancel={() => setShowClearLogsConfirm(false)}
  1432. />
  1433. )}
  1434. {/* Delete Backup Confirmation Modal */}
  1435. {deleteConfirmFile && (
  1436. <ConfirmModal
  1437. title={t('backup.deleteBackup')}
  1438. message={t('backup.deleteBackupConfirm')}
  1439. confirmText={t('backup.deleteBackup')}
  1440. variant="danger"
  1441. onConfirm={() => deleteLocalBackupMutation.mutate(deleteConfirmFile)}
  1442. onCancel={() => setDeleteConfirmFile(null)}
  1443. />
  1444. )}
  1445. {/* Restore from Scheduled Backup Confirmation Modal */}
  1446. {restoreConfirmFile && (
  1447. <ConfirmModal
  1448. title={t('backup.restoreConfirmTitle')}
  1449. message={t('backup.restoreConfirmMessage', { filename: restoreConfirmFile })}
  1450. confirmText={t('backup.restoreConfirmButton')}
  1451. variant="danger"
  1452. onConfirm={() => restoreLocalBackupMutation.mutate(restoreConfirmFile)}
  1453. onCancel={() => setRestoreConfirmFile(null)}
  1454. />
  1455. )}
  1456. {/* Restore Confirmation Modal */}
  1457. {showRestoreConfirm && restoreFile && (
  1458. <ConfirmModal
  1459. title={t('backup.restoreConfirmTitle')}
  1460. message={t('backup.restoreConfirmMessage', { filename: restoreFile.name })}
  1461. confirmText={t('backup.restoreConfirmButton')}
  1462. variant="danger"
  1463. onConfirm={async () => {
  1464. setShowRestoreConfirm(false);
  1465. setIsRestoring(true);
  1466. setRestoreResult(null);
  1467. try {
  1468. setOperationStatus(t('backup.uploadingFile'));
  1469. const result = await api.importBackup(restoreFile);
  1470. setRestoreResult(result);
  1471. if (result.success) {
  1472. showToast(t('backup.backupRestoredRestart'), 'success');
  1473. } else {
  1474. showToast(result.message || t('backup.failedToRestore'), 'error');
  1475. }
  1476. } catch (e) {
  1477. const message = e instanceof Error ? e.message : t('backup.failedToRestore');
  1478. setRestoreResult({ success: false, message });
  1479. showToast(message, 'error');
  1480. } finally {
  1481. setIsRestoring(false);
  1482. setOperationStatus('');
  1483. setRestoreFile(null);
  1484. }
  1485. }}
  1486. onCancel={() => {
  1487. setShowRestoreConfirm(false);
  1488. setRestoreFile(null);
  1489. }}
  1490. />
  1491. )}
  1492. {/* Blocking overlay during backup/restore operations */}
  1493. {(isExporting || isRestoring) && (
  1494. <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[100]">
  1495. <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-8 max-w-md w-full mx-4 text-center">
  1496. <div className="flex justify-center mb-4">
  1497. <div className="relative">
  1498. <div className="w-16 h-16 border-4 border-bambu-dark-tertiary rounded-full"></div>
  1499. <div className="w-16 h-16 border-4 border-bambu-green border-t-transparent rounded-full absolute inset-0 animate-spin"></div>
  1500. </div>
  1501. </div>
  1502. <h3 className="text-xl font-semibold text-white mb-2">
  1503. {isExporting ? t('backup.creatingBackup') : t('backup.restoringBackup')}
  1504. </h3>
  1505. <p className="text-bambu-gray mb-4">
  1506. {operationStatus || (isExporting ? t('backup.preparing') : t('backup.processing'))}
  1507. </p>
  1508. <div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
  1509. <div className="flex items-start gap-2 text-sm">
  1510. <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
  1511. <p className="text-yellow-800 dark:text-yellow-200 text-left">
  1512. {t('backup.doNotClosePage')}
  1513. </p>
  1514. </div>
  1515. </div>
  1516. </div>
  1517. </div>
  1518. )}
  1519. </div>
  1520. );
  1521. }