GitHubRestoreModal.tsx 25 KB

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