GitHubBackupSettings.tsx 66 KB

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