import { useCallback, useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Archive, CheckCircle2, Info, Loader2, Palette, RotateCcw, Settings as SettingsIcon, Thermometer, X, } from 'lucide-react'; import { Card, CardContent } from './Card'; import { Button } from './Button'; import { Toggle } from './Toggle'; import { ConfirmModal } from './ConfirmModal'; import { api, type RestoreCategory, type GitHubRestoreParams, type GitHubRestoreResponse, } from '../api/client'; import type { TFunction } from 'i18next'; interface GitHubRestoreModalProps { onClose: () => void; } /** * Render a server-supplied translation code, falling back to its English text. * * The restore endpoints describe every note and preview caveat as a `code` plus * typed `params`, and carry the English rendering along as `message`. That is * the same contract `backup.pathCheck` already uses one card down in * GitHubBackupSettings — including the `defaultValue` arm, which is what keeps a * newer backend's unfamiliar code readable instead of printing the raw key. */ function translateCoded( t: TFunction, group: 'notes' | 'details', code: string | null | undefined, params: GitHubRestoreParams | undefined, fallback: string | null ): string | null { if (!code) return fallback; return t(`backup.restoreFromGit.${group}.${code}`, { ...(params ?? {}), defaultValue: fallback ?? code, }); } interface CategoryMeta { id: RestoreCategory; labelKey: string; icon: React.ReactNode; } // Order mirrors the order the backend applies them in. Labels reuse the keys // the backup checkbox group already ships in all locales. const CATEGORIES: CategoryMeta[] = [ { id: 'archives', labelKey: 'backup.printArchives', icon: }, { id: 'spools', labelKey: 'backup.spoolInventory', icon: }, { id: 'settings', labelKey: 'backup.appSettings', icon: }, { id: 'kprofiles', labelKey: 'backup.kProfiles', icon: }, ]; const CATEGORY_LABEL_KEYS: Record = Object.fromEntries( CATEGORIES.map((c) => [c.id, c.labelKey]) ); const LATEST = 'HEAD'; export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const [selectedRef, setSelectedRef] = useState(LATEST); const [selected, setSelected] = useState>({}); const [overwriteExisting, setOverwriteExisting] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const [result, setResult] = useState(null); const commitsQuery = useQuery({ queryKey: ['github-backup-commits'], queryFn: () => api.getGitHubBackupCommits(20), }); const previewQuery = useQuery({ queryKey: ['github-restore-preview', selectedRef], queryFn: () => api.getGitHubRestorePreview(selectedRef), }); // Restore the exact commit the preview described, not the ref that was asked // for. They differ for the default "Latest backup" selection, which posts the // symbolic 'HEAD' and lets the backend re-resolve it — so a backup landing // between preview and restore would silently restore a different commit than // the one whose contents the user just approved. const resolvedRef = previewQuery.data?.success ? previewQuery.data.ref : selectedRef; const availability = useMemo(() => { const map: Record = {}; previewQuery.data?.categories?.forEach((c) => { map[c.category] = { available: c.available, itemCount: c.item_count, detail: translateCoded(t, 'details', c.detail_code, c.detail_params, c.detail), }; }); return map; }, [previewQuery.data, t]); // What a Restore click would actually send. `selected` on its own is not that: // it survives a commit switch by design (the pruning effect below only runs // once the new preview lands), so between picking a commit and its preview // resolving, `selected` still describes the *previous* commit while the // checkbox list is replaced by a spinner. Counting it raw put "2 selected" // and an enabled Restore button under that spinner, and clicking restored the // new commit with the old commit's categories — none of which the user had // seen an item count for. Gating on availability, exactly as the checkboxes // do, empties the list until the preview says otherwise, which also disables // the button. const selectedCategories = useMemo( () => CATEGORIES.filter((c) => selected[c.id] && availability[c.id]?.available).map((c) => c.id), [selected, availability] ); const selectedCount = selectedCategories.length; // Overwrite-off tells the user that existing entries stay as they are, and for // three of the four categories it keeps that promise. K-profiles cannot: // _restore_kprofiles takes no overwrite flag, because writing a slot is always // an overwrite on the printer — resolving the live cali_idx and publishing // extrusion_cali_set replaces whatever calibration that slot holds. The // backend does say so, but as a note in the result panel, i.e. after the MQTT // send has already happened and cannot be taken back. So the one screen that // explains overwrite-off has to carry the exception too, before the click. const warnKprofilesOverwrite = !overwriteExisting && selectedCategories.includes('kprofiles'); const restoreMutation = useMutation({ mutationFn: () => api.restoreFromGitHub({ ref: resolvedRef, categories: selectedCategories, overwrite_existing: overwriteExisting, }), onSuccess: (data) => { setShowConfirm(false); // The endpoint answers 200 for a refused or failed restore too, with // `success: false` and an empty `results` — and two of those are ordinary // conditions, not errors: another restore already running, and a backup // being mid-flight. Rendering the result panel for them showed a green // tick, no tally at all and a "reload so the restored data appears" hint // above a message saying nothing had been restored. Only a real success // gets the panel; a failure keeps the form and shows the red block below. if (data.success) { setResult(data); // A restore rewrites rows these caches hold. ['settings'] is one of // them: until #2716 was fixed on dev, invalidating it made // SettingsPage's debounced auto-save write the pre-restore form state // straight back over the restore, so this modal skipped it and pinned // the cache instead. That page now reconciles a moved server snapshot // field by field, so the restore no longer needs an exception. queryClient.invalidateQueries({ queryKey: ['spools'] }); queryClient.invalidateQueries({ queryKey: ['archives'] }); queryClient.invalidateQueries({ queryKey: ['settings'] }); } // A failure that got as far as resolving the commit still writes a log row // (status "failed"), so refresh the history and status either way. queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] }); queryClient.invalidateQueries({ queryKey: ['github-backup-status'] }); }, onError: () => setShowConfirm(false), }); const isRestoring = restoreMutation.isPending; // A settings restore rewrites rows the whole app reads, and not all of them // through a query this modal can invalidate. The interface language is applied // by i18n.changeLanguage, called only from the SettingsPage dropdown and the // appliance-locale bootstrap; the auth state comes from AuthProvider's // mount-time getAuthStatus, not from ['settings'] at all. So every exit path // after a settings restore reloads rather than just closing. const settingsRestored = Boolean(result && 'settings' in result.results); const closeModal = useCallback(() => { if (settingsRestored) { window.location.reload(); return; } onClose(); }, [settingsRestored, onClose]); // Close on Escape, except while a restore is in flight. useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && !isRestoring && !showConfirm) closeModal(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [closeModal, isRestoring, showConfirm]); // Interrupting a restore mid-flight can leave a partly-applied category. useEffect(() => { if (!isRestoring) return; const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ''; }; window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler); }, [isRestoring]); // Selecting a category that isn't in the newly-picked commit would send a // request the backend rejects, so drop those whenever the preview changes. useEffect(() => { if (!previewQuery.data) return; setSelected((prev) => { const next: Record = {}; CATEGORIES.forEach((c) => { next[c.id] = Boolean(prev[c.id]) && Boolean(availability[c.id]?.available); }); return next; }); }, [previewQuery.data, availability]); const commits = commitsQuery.data?.commits ?? []; const formatCommitLabel = (sha: string, message: string, date: string) => { const firstLine = (message || '').split('\n')[0]; const when = date ? new Date(date).toLocaleString() : ''; return `${sha.slice(0, 7)} — ${when}${firstLine ? ` — ${firstLine}` : ''}`; }; // Two ways these can fail, and both have to reach the user. A provider-side // failure (bad token, repo unreachable) answers 200 with `success: false` and // a message. A rejected *request* — a 401/403 once the session expires with // the modal open, a 500, the network dropping — throws in `request()`, so // `data` is undefined: reading the message off `data` alone left the picker // holding only "Latest" and every category greyed out by an empty availability // map, with nothing on screen saying why. const queryError = (query: { isError: boolean; error: unknown }) => query.isError ? (query.error as Error)?.message || t('backup.restoreFromGit.loadFailed') : null; const previewError = queryError(previewQuery) ?? (previewQuery.data && !previewQuery.data.success ? previewQuery.data.message : null); const commitsError = queryError(commitsQuery) ?? (commitsQuery.data && !commitsQuery.data.success ? commitsQuery.data.message : null); return ( <>
e.stopPropagation()}> {/* Header */}

{t('backup.restoreFromGit.title')}

{t('backup.restoreFromGit.subtitle')}

{result ? ( /* Result summary */
{result.message}
{Object.entries(result.results).map(([name, tally]) => (
{CATEGORY_LABEL_KEYS[name] ? t(CATEGORY_LABEL_KEYS[name]) : name} {t('backup.restoreFromGit.tally', { restored: tally.restored, skipped: tally.skipped, failed: tally.failed, })}
{tally.notes.length > 0 && (
    {tally.notes.map((note) => ( // The server dedupes on (code, params), not on code // alone — two printers can both be offline — so the // key has to carry the params too.
  • {translateCoded(t, 'notes', note.code, note.params, note.message)}
  • ))}
)}
))}

{t('backup.restoreFromGit.reloadHint')}

) : (
{/* A restore that was refused or failed comes back here rather than to the result panel, so keep these above the fold. */} {restoreMutation.isError && (

{(restoreMutation.error as Error)?.message || t('backup.restoreFromGit.failed')}

)} {restoreMutation.data && !restoreMutation.data.success && (

{restoreMutation.data.message}

)} {/* Commit picker */}
{commitsError &&

{commitsError}

}
{/* Category selection */}

{t('backup.restoreFromGit.categoriesLabel')}

{previewQuery.isLoading ? (
{t('backup.restoreFromGit.inspecting')}
) : previewError ? (

{previewError}

) : (
{CATEGORIES.map((category) => { const info = availability[category.id]; const isAvailable = Boolean(info?.available); const isChecked = Boolean(selected[category.id]) && isAvailable; return ( ); })}
)}
{/* Overwrite toggle */}

{t('backup.restoreFromGit.overwriteLabel')}

{overwriteExisting ? t('backup.restoreFromGit.overwriteOn') : t('backup.restoreFromGit.overwriteOff')}

)} {/* Footer */}
{result ? ( <>
) : ( <> {t('backup.restoreFromGit.selectedCount', { count: selectedCount })}
)}
{showConfirm && ( restoreMutation.mutate()} onCancel={() => setShowConfirm(false)} /> )} ); }