GitHubBackupSettings.tsx 70 KB

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