Files
bambuddy/frontend/src/components/GitHubRestoreModal.tsx
T
jmoore-skild df6656a0a9 fix(backup): drop the settings-pin workaround, upstream fixed the cause (#2656)
This modal carried two workarounds for #2716: `onSuccess` deliberately did not
invalidate `['settings']`, and a query-cache subscription pinned the entry to
the pre-restore copy for as long as the result panel was up. Both existed
because SettingsPage's debounced auto-save diffed its `localSettings` form
state against the live cache, so any refetch of a restored settings row --
this modal's, a window refocus, a reconnect, or any of the ~30 other observers
of the key -- read as an edit and PATCHed the pre-restore values back over the
restore about 500 ms later.

`43cb216a` on dev fixed that. The page now keeps a server baseline and
reconciles a moved snapshot field by field: an untouched field adopts the
server's value instead of overwriting it. The restore no longer needs an
exception, and maziggy explicitly invited dropping it.

A commit on top rather than a rebase-drop of `21bb5afc`: later commits touch
this file, and the workaround was right when it was written. This says so.

The reload on close stays -- it was never one of the two workarounds. Its
stated reason was, though, and it was the #2716 bug, so it is restated for
what it actually buys: invalidating `['settings']` only resyncs what reads
that query, and the interface language, currency and auth toggles are read on
boot.

Tests: "never invalidates the settings query" inverts; the pin test and its
control go with the pin. The reload pair stays. 28 -> 26 tests in this file.

Bundle rebuilt: index-CCCWDEkl.js -> index-CHCEEMgx.js, carrying this, J1's
locale leaf and the reworded ack caveat. CSS hash unchanged.
2026-08-04 08:57:34 -04:00

523 lines
24 KiB
TypeScript

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: <Archive className="w-4 h-4" /> },
{ id: 'spools', labelKey: 'backup.spoolInventory', icon: <Palette className="w-4 h-4" /> },
{ id: 'settings', labelKey: 'backup.appSettings', icon: <SettingsIcon className="w-4 h-4" /> },
{ id: 'kprofiles', labelKey: 'backup.kProfiles', icon: <Thermometer className="w-4 h-4" /> },
];
const CATEGORY_LABEL_KEYS: Record<string, string> = 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<string>(LATEST);
const [selected, setSelected] = useState<Record<string, boolean>>({});
const [overwriteExisting, setOverwriteExisting] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [result, setResult] = useState<GitHubRestoreResponse | null>(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<string, { available: boolean; itemCount: number; detail: string | null }> = {};
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<string, boolean> = {};
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 (
<>
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={isRestoring ? undefined : closeModal}
>
<Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent className="p-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-3">
<div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
<RotateCcw className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">{t('backup.restoreFromGit.title')}</h3>
<p className="text-sm text-bambu-gray">{t('backup.restoreFromGit.subtitle')}</p>
</div>
</div>
<button
onClick={closeModal}
disabled={isRestoring}
aria-label={t('common.close')}
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{result ? (
/* Result summary */
<div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
<div className="flex items-start gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<span className="text-white">{result.message}</span>
</div>
{Object.entries(result.results).map(([name, tally]) => (
<div key={name} className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">
{CATEGORY_LABEL_KEYS[name] ? t(CATEGORY_LABEL_KEYS[name]) : name}
</span>
<span className="text-xs text-bambu-gray">
{t('backup.restoreFromGit.tally', {
restored: tally.restored,
skipped: tally.skipped,
failed: tally.failed,
})}
</span>
</div>
{tally.notes.length > 0 && (
<ul className="mt-2 space-y-1">
{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.
<li
key={`${note.code}:${JSON.stringify(note.params)}`}
className="text-xs text-bambu-gray flex items-start gap-1.5"
>
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{translateCoded(t, 'notes', note.code, note.params, note.message)}</span>
</li>
))}
</ul>
)}
</div>
))}
<div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
<p className="text-xs text-yellow-700 dark:text-yellow-200">
{t('backup.restoreFromGit.reloadHint')}
</p>
</div>
</div>
) : (
<div className={`p-4 space-y-4 max-h-[400px] overflow-y-auto ${isRestoring ? 'opacity-50 pointer-events-none' : ''}`}>
{/* A restore that was refused or failed comes back here rather
than to the result panel, so keep these above the fold. */}
{restoreMutation.isError && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
<p className="text-sm text-red-700 dark:text-red-400">
{(restoreMutation.error as Error)?.message || t('backup.restoreFromGit.failed')}
</p>
</div>
)}
{restoreMutation.data && !restoreMutation.data.success && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
<p className="text-sm text-red-700 dark:text-red-400">{restoreMutation.data.message}</p>
</div>
)}
{/* Commit picker */}
<div>
<label htmlFor="restore-commit" className="block text-sm font-medium text-white mb-1">
{t('backup.restoreFromGit.commitLabel')}
</label>
<select
id="restore-commit"
value={selectedRef}
onChange={(e) => {
setSelectedRef(e.target.value);
// Drop the previous attempt's failure banner: it refers to
// the commit that was just switched away from. (A *result*
// cannot be showing here — the summary replaces this form.)
restoreMutation.reset();
}}
disabled={isRestoring || commitsQuery.isLoading}
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"
>
<option value={LATEST}>{t('backup.restoreFromGit.latestCommit')}</option>
{commits.map((c) => (
<option key={c.sha} value={c.sha}>
{formatCommitLabel(c.sha, c.message, c.date)}
</option>
))}
</select>
{commitsError && <p className="mt-1 text-xs text-red-500 dark:text-red-400">{commitsError}</p>}
</div>
{/* Category selection */}
<div>
<p className="text-sm font-medium text-white mb-2">{t('backup.restoreFromGit.categoriesLabel')}</p>
{previewQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-bambu-gray p-3">
<Loader2 className="w-4 h-4 animate-spin" />
{t('backup.restoreFromGit.inspecting')}
</div>
) : previewError ? (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
<p className="text-sm text-red-700 dark:text-red-400">{previewError}</p>
</div>
) : (
<div className="space-y-2">
{CATEGORIES.map((category) => {
const info = availability[category.id];
const isAvailable = Boolean(info?.available);
const isChecked = Boolean(selected[category.id]) && isAvailable;
return (
<label
key={category.id}
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
isAvailable ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
} ${
isChecked
? 'bg-bambu-green/10 border border-bambu-green/30'
: 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'
}`}
>
<input
type="checkbox"
checked={isChecked}
disabled={!isAvailable || isRestoring}
onChange={() =>
setSelected((prev) => ({ ...prev, [category.id]: !prev[category.id] }))
}
className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
/>
<div className={isChecked ? 'text-bambu-green' : 'text-bambu-gray'}>{category.icon}</div>
<div className="flex-1">
<div className="text-white text-sm font-medium">
{t(category.labelKey)}
{isAvailable && info?.itemCount ? (
<span className="ml-2 text-xs text-bambu-gray">
{t('backup.restoreFromGit.itemCount', { count: info.itemCount })}
</span>
) : null}
</div>
{info?.detail && <div className="text-xs text-bambu-gray">{info.detail}</div>}
{category.id === 'kprofiles' && isChecked && warnKprofilesOverwrite && (
<div className="text-xs text-yellow-700 dark:text-yellow-200">
{t('backup.restoreFromGit.kprofilesOverwriteCaveat')}
</div>
)}
</div>
</label>
);
})}
</div>
)}
</div>
{/* Overwrite toggle */}
<div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-white">{t('backup.restoreFromGit.overwriteLabel')}</p>
<p className="text-xs text-bambu-gray">
{overwriteExisting
? t('backup.restoreFromGit.overwriteOn')
: t('backup.restoreFromGit.overwriteOff')}
</p>
</div>
<Toggle checked={overwriteExisting} onChange={setOverwriteExisting} disabled={isRestoring} />
</div>
</div>
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
{result ? (
<>
<span />
<div className="flex gap-3">
<Button variant="secondary" onClick={closeModal}>
{t('common.close')}
</Button>
<Button
onClick={() => window.location.reload()}
className="bg-bambu-green hover:bg-bambu-green-dark"
>
{t('backup.reloadNow')}
</Button>
</div>
</>
) : (
<>
<span className="text-sm text-bambu-gray">
{t('backup.restoreFromGit.selectedCount', { count: selectedCount })}
</span>
<div className="flex gap-3">
<Button variant="secondary" onClick={closeModal} disabled={isRestoring}>
{t('common.cancel')}
</Button>
<Button
onClick={() => setShowConfirm(true)}
disabled={selectedCount === 0 || isRestoring}
className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
>
{isRestoring ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('backup.restoreFromGit.restoring')}
</>
) : (
<>
<RotateCcw className="w-4 h-4 mr-2" />
{t('backup.restore')}
</>
)}
</Button>
</div>
</>
)}
</div>
</CardContent>
</Card>
</div>
{showConfirm && (
<ConfirmModal
variant="danger"
overlayZIndex="z-[110]"
title={t('backup.restoreFromGit.confirmTitle')}
message={
overwriteExisting
? t('backup.restoreFromGit.confirmMessageOverwrite')
: warnKprofilesOverwrite
? `${t('backup.restoreFromGit.confirmMessage')} ${t('backup.restoreFromGit.kprofilesOverwriteCaveat')}`
: t('backup.restoreFromGit.confirmMessage')
}
confirmText={t('backup.restore')}
isLoading={isRestoring}
loadingText={t('backup.restoreFromGit.restoring')}
onConfirm={() => restoreMutation.mutate()}
onCancel={() => setShowConfirm(false)}
/>
)}
</>
);
}