GitHubBackupSettings.tsx 61 KB

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