GitHubRestoreModal.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. Archive,
  6. CheckCircle2,
  7. Info,
  8. Loader2,
  9. Palette,
  10. RotateCcw,
  11. Settings as SettingsIcon,
  12. Thermometer,
  13. X,
  14. } from 'lucide-react';
  15. import { Card, CardContent } from './Card';
  16. import { Button } from './Button';
  17. import { Toggle } from './Toggle';
  18. import { ConfirmModal } from './ConfirmModal';
  19. import {
  20. api,
  21. type RestoreCategory,
  22. type GitHubRestoreParams,
  23. type GitHubRestoreResponse,
  24. } from '../api/client';
  25. import type { TFunction } from 'i18next';
  26. interface GitHubRestoreModalProps {
  27. onClose: () => void;
  28. }
  29. /**
  30. * Render a server-supplied translation code, falling back to its English text.
  31. *
  32. * The restore endpoints describe every note and preview caveat as a `code` plus
  33. * typed `params`, and carry the English rendering along as `message`. That is
  34. * the same contract `backup.pathCheck` already uses one card down in
  35. * GitHubBackupSettings — including the `defaultValue` arm, which is what keeps a
  36. * newer backend's unfamiliar code readable instead of printing the raw key.
  37. */
  38. function translateCoded(
  39. t: TFunction,
  40. group: 'notes' | 'details',
  41. code: string | null | undefined,
  42. params: GitHubRestoreParams | undefined,
  43. fallback: string | null
  44. ): string | null {
  45. if (!code) return fallback;
  46. return t(`backup.restoreFromGit.${group}.${code}`, {
  47. ...(params ?? {}),
  48. defaultValue: fallback ?? code,
  49. });
  50. }
  51. interface CategoryMeta {
  52. id: RestoreCategory;
  53. labelKey: string;
  54. icon: React.ReactNode;
  55. }
  56. // Order mirrors the order the backend applies them in. Labels reuse the keys
  57. // the backup checkbox group already ships in all locales.
  58. const CATEGORIES: CategoryMeta[] = [
  59. { id: 'archives', labelKey: 'backup.printArchives', icon: <Archive className="w-4 h-4" /> },
  60. { id: 'spools', labelKey: 'backup.spoolInventory', icon: <Palette className="w-4 h-4" /> },
  61. { id: 'settings', labelKey: 'backup.appSettings', icon: <SettingsIcon className="w-4 h-4" /> },
  62. { id: 'kprofiles', labelKey: 'backup.kProfiles', icon: <Thermometer className="w-4 h-4" /> },
  63. ];
  64. const CATEGORY_LABEL_KEYS: Record<string, string> = Object.fromEntries(
  65. CATEGORIES.map((c) => [c.id, c.labelKey])
  66. );
  67. const LATEST = 'HEAD';
  68. export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
  69. const { t } = useTranslation();
  70. const queryClient = useQueryClient();
  71. const [selectedRef, setSelectedRef] = useState<string>(LATEST);
  72. const [selected, setSelected] = useState<Record<string, boolean>>({});
  73. const [overwriteExisting, setOverwriteExisting] = useState(false);
  74. const [showConfirm, setShowConfirm] = useState(false);
  75. const [result, setResult] = useState<GitHubRestoreResponse | null>(null);
  76. const commitsQuery = useQuery({
  77. queryKey: ['github-backup-commits'],
  78. queryFn: () => api.getGitHubBackupCommits(20),
  79. });
  80. const previewQuery = useQuery({
  81. queryKey: ['github-restore-preview', selectedRef],
  82. queryFn: () => api.getGitHubRestorePreview(selectedRef),
  83. });
  84. // Restore the exact commit the preview described, not the ref that was asked
  85. // for. They differ for the default "Latest backup" selection, which posts the
  86. // symbolic 'HEAD' and lets the backend re-resolve it — so a backup landing
  87. // between preview and restore would silently restore a different commit than
  88. // the one whose contents the user just approved.
  89. const resolvedRef = previewQuery.data?.success ? previewQuery.data.ref : selectedRef;
  90. const availability = useMemo(() => {
  91. const map: Record<string, { available: boolean; itemCount: number; detail: string | null }> = {};
  92. previewQuery.data?.categories?.forEach((c) => {
  93. map[c.category] = {
  94. available: c.available,
  95. itemCount: c.item_count,
  96. detail: translateCoded(t, 'details', c.detail_code, c.detail_params, c.detail),
  97. };
  98. });
  99. return map;
  100. }, [previewQuery.data, t]);
  101. // What a Restore click would actually send. `selected` on its own is not that:
  102. // it survives a commit switch by design (the pruning effect below only runs
  103. // once the new preview lands), so between picking a commit and its preview
  104. // resolving, `selected` still describes the *previous* commit while the
  105. // checkbox list is replaced by a spinner. Counting it raw put "2 selected"
  106. // and an enabled Restore button under that spinner, and clicking restored the
  107. // new commit with the old commit's categories — none of which the user had
  108. // seen an item count for. Gating on availability, exactly as the checkboxes
  109. // do, empties the list until the preview says otherwise, which also disables
  110. // the button.
  111. const selectedCategories = useMemo(
  112. () => CATEGORIES.filter((c) => selected[c.id] && availability[c.id]?.available).map((c) => c.id),
  113. [selected, availability]
  114. );
  115. const selectedCount = selectedCategories.length;
  116. // Overwrite-off tells the user that existing entries stay as they are, and for
  117. // three of the four categories it keeps that promise. K-profiles cannot:
  118. // _restore_kprofiles takes no overwrite flag, because writing a slot is always
  119. // an overwrite on the printer — resolving the live cali_idx and publishing
  120. // extrusion_cali_set replaces whatever calibration that slot holds. The
  121. // backend does say so, but as a note in the result panel, i.e. after the MQTT
  122. // send has already happened and cannot be taken back. So the one screen that
  123. // explains overwrite-off has to carry the exception too, before the click.
  124. const warnKprofilesOverwrite = !overwriteExisting && selectedCategories.includes('kprofiles');
  125. const restoreMutation = useMutation({
  126. mutationFn: () =>
  127. api.restoreFromGitHub({
  128. ref: resolvedRef,
  129. categories: selectedCategories,
  130. overwrite_existing: overwriteExisting,
  131. }),
  132. onSuccess: (data) => {
  133. setShowConfirm(false);
  134. // The endpoint answers 200 for a refused or failed restore too, with
  135. // `success: false` and an empty `results` — and two of those are ordinary
  136. // conditions, not errors: another restore already running, and a backup
  137. // being mid-flight. Rendering the result panel for them showed a green
  138. // tick, no tally at all and a "reload so the restored data appears" hint
  139. // above a message saying nothing had been restored. Only a real success
  140. // gets the panel; a failure keeps the form and shows the red block below.
  141. if (data.success) {
  142. setResult(data);
  143. // A restore rewrites rows these caches hold. ['settings'] is one of
  144. // them: until #2716 was fixed on dev, invalidating it made
  145. // SettingsPage's debounced auto-save write the pre-restore form state
  146. // straight back over the restore, so this modal skipped it and pinned
  147. // the cache instead. That page now reconciles a moved server snapshot
  148. // field by field, so the restore no longer needs an exception.
  149. queryClient.invalidateQueries({ queryKey: ['spools'] });
  150. queryClient.invalidateQueries({ queryKey: ['archives'] });
  151. queryClient.invalidateQueries({ queryKey: ['settings'] });
  152. }
  153. // A failure that got as far as resolving the commit still writes a log row
  154. // (status "failed"), so refresh the history and status either way.
  155. queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
  156. queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
  157. },
  158. onError: () => setShowConfirm(false),
  159. });
  160. const isRestoring = restoreMutation.isPending;
  161. // A settings restore rewrites rows the whole app reads, and not all of them
  162. // through a query this modal can invalidate. The interface language is applied
  163. // by i18n.changeLanguage, called only from the SettingsPage dropdown and the
  164. // appliance-locale bootstrap; the auth state comes from AuthProvider's
  165. // mount-time getAuthStatus, not from ['settings'] at all. So every exit path
  166. // after a settings restore reloads rather than just closing.
  167. const settingsRestored = Boolean(result && 'settings' in result.results);
  168. const closeModal = useCallback(() => {
  169. if (settingsRestored) {
  170. window.location.reload();
  171. return;
  172. }
  173. onClose();
  174. }, [settingsRestored, onClose]);
  175. // Close on Escape, except while a restore is in flight.
  176. useEffect(() => {
  177. const handleKeyDown = (e: KeyboardEvent) => {
  178. if (e.key === 'Escape' && !isRestoring && !showConfirm) closeModal();
  179. };
  180. window.addEventListener('keydown', handleKeyDown);
  181. return () => window.removeEventListener('keydown', handleKeyDown);
  182. }, [closeModal, isRestoring, showConfirm]);
  183. // Interrupting a restore mid-flight can leave a partly-applied category.
  184. useEffect(() => {
  185. if (!isRestoring) return;
  186. const handler = (e: BeforeUnloadEvent) => {
  187. e.preventDefault();
  188. e.returnValue = '';
  189. };
  190. window.addEventListener('beforeunload', handler);
  191. return () => window.removeEventListener('beforeunload', handler);
  192. }, [isRestoring]);
  193. // Selecting a category that isn't in the newly-picked commit would send a
  194. // request the backend rejects, so drop those whenever the preview changes.
  195. useEffect(() => {
  196. if (!previewQuery.data) return;
  197. setSelected((prev) => {
  198. const next: Record<string, boolean> = {};
  199. CATEGORIES.forEach((c) => {
  200. next[c.id] = Boolean(prev[c.id]) && Boolean(availability[c.id]?.available);
  201. });
  202. return next;
  203. });
  204. }, [previewQuery.data, availability]);
  205. const commits = commitsQuery.data?.commits ?? [];
  206. const formatCommitLabel = (sha: string, message: string, date: string) => {
  207. const firstLine = (message || '').split('\n')[0];
  208. const when = date ? new Date(date).toLocaleString() : '';
  209. return `${sha.slice(0, 7)} — ${when}${firstLine ? ` — ${firstLine}` : ''}`;
  210. };
  211. // Two ways these can fail, and both have to reach the user. A provider-side
  212. // failure (bad token, repo unreachable) answers 200 with `success: false` and
  213. // a message. A rejected *request* — a 401/403 once the session expires with
  214. // the modal open, a 500, the network dropping — throws in `request()`, so
  215. // `data` is undefined: reading the message off `data` alone left the picker
  216. // holding only "Latest" and every category greyed out by an empty availability
  217. // map, with nothing on screen saying why.
  218. const queryError = (query: { isError: boolean; error: unknown }) =>
  219. query.isError ? (query.error as Error)?.message || t('backup.restoreFromGit.loadFailed') : null;
  220. const previewError =
  221. queryError(previewQuery) ??
  222. (previewQuery.data && !previewQuery.data.success ? previewQuery.data.message : null);
  223. const commitsError =
  224. queryError(commitsQuery) ??
  225. (commitsQuery.data && !commitsQuery.data.success ? commitsQuery.data.message : null);
  226. return (
  227. <>
  228. <div
  229. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  230. onClick={isRestoring ? undefined : closeModal}
  231. >
  232. <Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
  233. <CardContent className="p-0">
  234. {/* Header */}
  235. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  236. <div className="flex items-center gap-3">
  237. <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
  238. <RotateCcw className="w-5 h-5" />
  239. </div>
  240. <div>
  241. <h3 className="text-lg font-semibold text-white">{t('backup.restoreFromGit.title')}</h3>
  242. <p className="text-sm text-bambu-gray">{t('backup.restoreFromGit.subtitle')}</p>
  243. </div>
  244. </div>
  245. <button
  246. onClick={closeModal}
  247. disabled={isRestoring}
  248. aria-label={t('common.close')}
  249. className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors disabled:opacity-50"
  250. >
  251. <X className="w-5 h-5" />
  252. </button>
  253. </div>
  254. {result ? (
  255. /* Result summary */
  256. <div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
  257. <div className="flex items-start gap-2 text-sm">
  258. <CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  259. <span className="text-white">{result.message}</span>
  260. </div>
  261. {Object.entries(result.results).map(([name, tally]) => (
  262. <div key={name} className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
  263. <div className="flex items-center justify-between">
  264. <span className="text-sm font-medium text-white">
  265. {CATEGORY_LABEL_KEYS[name] ? t(CATEGORY_LABEL_KEYS[name]) : name}
  266. </span>
  267. <span className="text-xs text-bambu-gray">
  268. {t('backup.restoreFromGit.tally', {
  269. restored: tally.restored,
  270. skipped: tally.skipped,
  271. failed: tally.failed,
  272. })}
  273. </span>
  274. </div>
  275. {tally.notes.length > 0 && (
  276. <ul className="mt-2 space-y-1">
  277. {tally.notes.map((note) => (
  278. // The server dedupes on (code, params), not on code
  279. // alone — two printers can both be offline — so the
  280. // key has to carry the params too.
  281. <li
  282. key={`${note.code}:${JSON.stringify(note.params)}`}
  283. className="text-xs text-bambu-gray flex items-start gap-1.5"
  284. >
  285. <Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
  286. <span>{translateCoded(t, 'notes', note.code, note.params, note.message)}</span>
  287. </li>
  288. ))}
  289. </ul>
  290. )}
  291. </div>
  292. ))}
  293. <div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
  294. <p className="text-xs text-yellow-700 dark:text-yellow-200">
  295. {t('backup.restoreFromGit.reloadHint')}
  296. </p>
  297. </div>
  298. </div>
  299. ) : (
  300. <div className={`p-4 space-y-4 max-h-[400px] overflow-y-auto ${isRestoring ? 'opacity-50 pointer-events-none' : ''}`}>
  301. {/* A restore that was refused or failed comes back here rather
  302. than to the result panel, so keep these above the fold. */}
  303. {restoreMutation.isError && (
  304. <div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
  305. <p className="text-sm text-red-700 dark:text-red-400">
  306. {(restoreMutation.error as Error)?.message || t('backup.restoreFromGit.failed')}
  307. </p>
  308. </div>
  309. )}
  310. {restoreMutation.data && !restoreMutation.data.success && (
  311. <div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
  312. <p className="text-sm text-red-700 dark:text-red-400">{restoreMutation.data.message}</p>
  313. </div>
  314. )}
  315. {/* Commit picker */}
  316. <div>
  317. <label htmlFor="restore-commit" className="block text-sm font-medium text-white mb-1">
  318. {t('backup.restoreFromGit.commitLabel')}
  319. </label>
  320. <select
  321. id="restore-commit"
  322. value={selectedRef}
  323. onChange={(e) => {
  324. setSelectedRef(e.target.value);
  325. // Drop the previous attempt's failure banner: it refers to
  326. // the commit that was just switched away from. (A *result*
  327. // cannot be showing here — the summary replaces this form.)
  328. restoreMutation.reset();
  329. }}
  330. disabled={isRestoring || commitsQuery.isLoading}
  331. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  332. >
  333. <option value={LATEST}>{t('backup.restoreFromGit.latestCommit')}</option>
  334. {commits.map((c) => (
  335. <option key={c.sha} value={c.sha}>
  336. {formatCommitLabel(c.sha, c.message, c.date)}
  337. </option>
  338. ))}
  339. </select>
  340. {commitsError && <p className="mt-1 text-xs text-red-500 dark:text-red-400">{commitsError}</p>}
  341. </div>
  342. {/* Category selection */}
  343. <div>
  344. <p className="text-sm font-medium text-white mb-2">{t('backup.restoreFromGit.categoriesLabel')}</p>
  345. {previewQuery.isLoading ? (
  346. <div className="flex items-center gap-2 text-sm text-bambu-gray p-3">
  347. <Loader2 className="w-4 h-4 animate-spin" />
  348. {t('backup.restoreFromGit.inspecting')}
  349. </div>
  350. ) : previewError ? (
  351. <div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
  352. <p className="text-sm text-red-700 dark:text-red-400">{previewError}</p>
  353. </div>
  354. ) : (
  355. <div className="space-y-2">
  356. {CATEGORIES.map((category) => {
  357. const info = availability[category.id];
  358. const isAvailable = Boolean(info?.available);
  359. const isChecked = Boolean(selected[category.id]) && isAvailable;
  360. return (
  361. <label
  362. key={category.id}
  363. className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
  364. isAvailable ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
  365. } ${
  366. isChecked
  367. ? 'bg-bambu-green/10 border border-bambu-green/30'
  368. : 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'
  369. }`}
  370. >
  371. <input
  372. type="checkbox"
  373. checked={isChecked}
  374. disabled={!isAvailable || isRestoring}
  375. onChange={() =>
  376. setSelected((prev) => ({ ...prev, [category.id]: !prev[category.id] }))
  377. }
  378. className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
  379. />
  380. <div className={isChecked ? 'text-bambu-green' : 'text-bambu-gray'}>{category.icon}</div>
  381. <div className="flex-1">
  382. <div className="text-white text-sm font-medium">
  383. {t(category.labelKey)}
  384. {isAvailable && info?.itemCount ? (
  385. <span className="ml-2 text-xs text-bambu-gray">
  386. {t('backup.restoreFromGit.itemCount', { count: info.itemCount })}
  387. </span>
  388. ) : null}
  389. </div>
  390. {info?.detail && <div className="text-xs text-bambu-gray">{info.detail}</div>}
  391. {category.id === 'kprofiles' && isChecked && warnKprofilesOverwrite && (
  392. <div className="text-xs text-yellow-700 dark:text-yellow-200">
  393. {t('backup.restoreFromGit.kprofilesOverwriteCaveat')}
  394. </div>
  395. )}
  396. </div>
  397. </label>
  398. );
  399. })}
  400. </div>
  401. )}
  402. </div>
  403. {/* Overwrite toggle */}
  404. <div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
  405. <div className="flex items-center justify-between gap-3">
  406. <div>
  407. <p className="text-sm font-medium text-white">{t('backup.restoreFromGit.overwriteLabel')}</p>
  408. <p className="text-xs text-bambu-gray">
  409. {overwriteExisting
  410. ? t('backup.restoreFromGit.overwriteOn')
  411. : t('backup.restoreFromGit.overwriteOff')}
  412. </p>
  413. </div>
  414. <Toggle checked={overwriteExisting} onChange={setOverwriteExisting} disabled={isRestoring} />
  415. </div>
  416. </div>
  417. </div>
  418. )}
  419. {/* Footer */}
  420. <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
  421. {result ? (
  422. <>
  423. <span />
  424. <div className="flex gap-3">
  425. <Button variant="secondary" onClick={closeModal}>
  426. {t('common.close')}
  427. </Button>
  428. <Button
  429. onClick={() => window.location.reload()}
  430. className="bg-bambu-green hover:bg-bambu-green-dark"
  431. >
  432. {t('backup.reloadNow')}
  433. </Button>
  434. </div>
  435. </>
  436. ) : (
  437. <>
  438. <span className="text-sm text-bambu-gray">
  439. {t('backup.restoreFromGit.selectedCount', { count: selectedCount })}
  440. </span>
  441. <div className="flex gap-3">
  442. <Button variant="secondary" onClick={closeModal} disabled={isRestoring}>
  443. {t('common.cancel')}
  444. </Button>
  445. <Button
  446. onClick={() => setShowConfirm(true)}
  447. disabled={selectedCount === 0 || isRestoring}
  448. className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
  449. >
  450. {isRestoring ? (
  451. <>
  452. <Loader2 className="w-4 h-4 mr-2 animate-spin" />
  453. {t('backup.restoreFromGit.restoring')}
  454. </>
  455. ) : (
  456. <>
  457. <RotateCcw className="w-4 h-4 mr-2" />
  458. {t('backup.restore')}
  459. </>
  460. )}
  461. </Button>
  462. </div>
  463. </>
  464. )}
  465. </div>
  466. </CardContent>
  467. </Card>
  468. </div>
  469. {showConfirm && (
  470. <ConfirmModal
  471. variant="danger"
  472. overlayZIndex="z-[110]"
  473. title={t('backup.restoreFromGit.confirmTitle')}
  474. message={
  475. overwriteExisting
  476. ? t('backup.restoreFromGit.confirmMessageOverwrite')
  477. : warnKprofilesOverwrite
  478. ? `${t('backup.restoreFromGit.confirmMessage')} ${t('backup.restoreFromGit.kprofilesOverwriteCaveat')}`
  479. : t('backup.restoreFromGit.confirmMessage')
  480. }
  481. confirmText={t('backup.restore')}
  482. isLoading={isRestoring}
  483. loadingText={t('backup.restoreFromGit.restoring')}
  484. onConfirm={() => restoreMutation.mutate()}
  485. onCancel={() => setShowConfirm(false)}
  486. />
  487. )}
  488. </>
  489. );
  490. }