GitHubBackupSettings.tsx 69 KB

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