Files
bambuddy/frontend/src/components/GitHubBackupSettings.tsx
T
maziggy 5bbfeefa65 fix(backup): diagnose an unwritable backup path instead of quoting errno 30 (#2544)
Nightly backups to a mounted NAS share ran from May and then stopped, failing
with [Errno 30] Read-only file system. The reporter checked folder permissions
-- correctly: the mount is gid=backup,dir_mode=0775, the service user is in that
group, and his own shell writes to the share fine.

Errno 30 is EROFS. A permission problem is errno 13. EROFS means the filesystem
refused the write, and it refused because we told it to: our systemd unit ships
ProtectSystem=strict, which mounts everything read-only inside the service's
mount namespace and carves back out only ReadWritePaths=<install> <data> <logs>.
A NAS share is not one of those three. Reads are unaffected -- which is why the
UI happily listed his existing backups from the share while being unable to
write a new one -- and his shell is outside the namespace entirely, so every
check he could think to run said the directory was fine.

Both installers write the unit file wholesale, so a ReadWritePaths line added by
hand disappeared on the next install, taking the backups with it. They now back
the old unit up (.bak-<timestamp>) and carry the operator's extra writable paths
forward, reporting which ones they kept. The unit template documents the
carve-out.

The output directory is probed with a real write when it is saved and when the
backup card loads, so an unwritable path is caught there rather than at 03:00
for a week. On failure the card names the cause and hands over the fix with the
operator's path already in it (systemctl edit bambuddy -> ReadWritePaths=...),
and a failed run reports the same diagnosis rather than the raw OSError. EROFS
outside systemd, permission-denied, out-of-space, not-a-directory and missing are
told apart, in all 11 locales.

Docker: a backup path that is not bind-mounted is writable -- the write lands in
the container's ephemeral layer and is lost on the next compose up. The probe
compares the directory's device against the container root and warns, with the
compose snippet that mounts it properly.
2026-07-12 08:44:53 +02:00

1508 lines
66 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Github,
Play,
Clock,
CheckCircle,
XCircle,
Loader2,
ExternalLink,
RefreshCw,
Download,
Upload,
Database,
History,
SkipForward,
AlertTriangle,
Trash2,
RotateCcw,
FolderArchive,
} from 'lucide-react';
import { api } from '../api/client';
import type {
GitHubBackupConfig,
GitHubBackupConfigCreate,
GitHubBackupLog,
GitHubBackupStatus,
GitHubBackupTriggerResponse,
GitProviderType,
LocalBackupFile,
LocalBackupPathCheck,
LocalBackupStatus,
ScheduleType,
CloudAuthStatus,
Printer,
} from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
import { ConfirmModal } from './ConfirmModal';
import { useToast } from '../contexts/ToastContext';
import { formatRelativeTime, parseUTCDate } from '../utils/date';
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return '-';
const date = parseUTCDate(dateStr);
if (!date) return '-';
return date.toLocaleString();
}
interface StatusBadgeProps {
status: string | null;
}
function StatusBadge({ status }: StatusBadgeProps) {
if (!status) return null;
const styles: Record<string, string> = {
success: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400',
failed: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400',
skipped: 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400',
running: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400',
};
const icons: Record<string, React.ReactNode> = {
success: <CheckCircle className="w-3 h-3" />,
failed: <XCircle className="w-3 h-3" />,
skipped: <SkipForward className="w-3 h-3" />,
running: <Loader2 className="w-3 h-3 animate-spin" />,
};
return (
<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'}`}>
{icons[status]}
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
const PROVIDER_REPO_URL_I18N_KEY: Record<GitProviderType, string> = {
github: 'backup.repoUrlPlaceholderGitHub',
gitea: 'backup.repoUrlPlaceholderGitea',
forgejo: 'backup.repoUrlPlaceholderForgejo',
gitlab: 'backup.repoUrlPlaceholderGitLab',
};
const PROVIDER_TOKEN_PLACEHOLDER: Record<GitProviderType, string> = {
github: 'ghp_xxxxxxxxxxxx',
gitea: 'your_access_token',
forgejo: 'your_access_token',
gitlab: 'glpat-xxxxxxxxxxxx',
};
interface GitHubBackupAutosaveState {
repository_url: string;
branch: string;
provider: GitProviderType;
allow_insecure_http: boolean;
schedule_enabled: boolean;
schedule_type: ScheduleType;
backup_kprofiles: boolean;
backup_cloud_profiles: boolean;
backup_settings: boolean;
backup_spools: boolean;
backup_archives: boolean;
enabled: boolean;
}
function serializeAutosaveState(state: GitHubBackupAutosaveState): string {
return JSON.stringify(state);
}
function getChangedAutosaveFields(
current: GitHubBackupAutosaveState,
previous: GitHubBackupAutosaveState | null
): Partial<GitHubBackupAutosaveState> {
if (!previous) {
return current;
}
const changes: Partial<GitHubBackupAutosaveState> = {};
for (const key of Object.keys(current) as Array<keyof GitHubBackupAutosaveState>) {
if (current[key] !== previous[key]) {
changes[key] = current[key] as never;
}
}
return changes;
}
export function GitHubBackupSettings() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const { t } = useTranslation();
// Local state for form
const [repoUrl, setRepoUrl] = useState('');
const [accessToken, setAccessToken] = useState('');
const [branch, setBranch] = useState('main');
const [provider, setProvider] = useState<GitProviderType>('github');
const [scheduleEnabled, setScheduleEnabled] = useState(false);
const [scheduleType, setScheduleType] = useState<ScheduleType>('daily');
const [backupKProfiles, setBackupKProfiles] = useState(true);
const [backupCloudProfiles, setBackupCloudProfiles] = useState(true);
const [backupSettings, setBackupSettings] = useState(false);
const [backupSpools, setBackupSpools] = useState(false);
const [backupArchives, setBackupArchives] = useState(false);
const [allowInsecureHttp, setAllowInsecureHttp] = useState(false);
const [enabled, setEnabled] = useState(true);
// Local backup state
const [isExporting, setIsExporting] = useState(false);
const [isRestoring, setIsRestoring] = useState(false);
const [operationStatus, setOperationStatus] = useState<string>('');
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
const [restoreFile, setRestoreFile] = useState<File | null>(null);
const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Scheduled local backup state
const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
const [localBackupPath, setLocalBackupPath] = useState('');
const { data: localBackupStatus, refetch: refetchLocalStatus } = useQuery<LocalBackupStatus>({
queryKey: ['local-backup-status'],
queryFn: api.getLocalBackupStatus,
refetchInterval: (query) => query.state.data?.is_running ? 1000 : 10000,
});
const { data: localBackups, refetch: refetchLocalBackups } = useQuery<LocalBackupFile[]>({
queryKey: ['local-backup-files'],
queryFn: api.getLocalBackups,
refetchInterval: 30000,
});
// Probes the output directory with a real write (#2544). A directory the
// service cannot write to — a NAS share outside the systemd unit's
// ReadWritePaths, typically — otherwise only surfaces as a failed backup at
// 03:00, for however many nights it takes someone to notice. Refetched
// explicitly when the path changes or a backup runs, not polled: it writes.
const { data: localBackupPathCheck, refetch: refetchLocalPathCheck } = useQuery<LocalBackupPathCheck>({
queryKey: ['local-backup-path-check'],
queryFn: api.checkLocalBackupPath,
enabled: localBackupStatus?.enabled === true,
refetchOnWindowFocus: false,
});
// Sync local path state from server
useEffect(() => {
if (localBackupStatus?.path !== undefined) {
setLocalBackupPath(localBackupStatus.path);
}
}, [localBackupStatus?.path]);
const triggerLocalBackupMutation = useMutation({
mutationFn: api.triggerLocalBackup,
onSuccess: (data) => {
if (data.success) {
showToast(t('backup.scheduledBackupComplete'));
} else {
showToast(data.message, 'error');
}
refetchLocalStatus();
refetchLocalBackups();
refetchLocalPathCheck();
},
onError: () => showToast(t('backup.scheduledBackupFailed'), 'error'),
});
const deleteLocalBackupMutation = useMutation({
mutationFn: (filename: string) => api.deleteLocalBackup(filename),
onSuccess: () => {
refetchLocalBackups();
setDeleteConfirmFile(null);
},
});
const restoreLocalBackupMutation = useMutation({
mutationFn: async (filename: string) => {
setRestoreConfirmFile(null);
setIsRestoring(true);
setRestoreResult(null);
setOperationStatus(t('backup.restoring'));
return api.restoreLocalBackup(filename);
},
onSuccess: (data) => {
setIsRestoring(false);
setOperationStatus('');
if (data.success) {
setRestoreResult({ success: true, message: data.message });
showToast(t('backup.backupRestoredRestart'), 'success');
} else {
setRestoreResult({ success: false, message: data.message });
showToast(data.message, 'error');
}
},
onError: (e) => {
setIsRestoring(false);
setOperationStatus('');
const msg = e instanceof Error ? e.message : t('backup.failedToRestore');
setRestoreResult({ success: false, message: msg });
showToast(msg, 'error');
},
});
// Block navigation while backup/restore is in progress
useEffect(() => {
const isOperationInProgress = isExporting || isRestoring;
if (isOperationInProgress) {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?';
return e.returnValue;
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}
}, [isExporting, isRestoring]);
// Test connection state
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
isPrivate: boolean | null;
} | null>(null);
// Inline save-error banner — backend rejection messages (e.g. the
// "repository is not private" guard) are far too long for a toast.
// Cleared on success, on the next save attempt, and when the user starts
// editing the repo URL / token / provider so the banner doesn't persist
// after the user has already addressed the cause.
const [saveError, setSaveError] = useState<string | null>(null);
// Auto-save debounce
const settingsAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const tokenAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initializationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedAutosaveStateRef = useRef<GitHubBackupAutosaveState | null>(null);
const lastTokenScheduledForSaveRef = useRef('');
const [isInitialized, setIsInitialized] = useState(false);
const autoSaveState = useMemo<GitHubBackupAutosaveState>(() => ({
repository_url: repoUrl,
branch,
provider,
allow_insecure_http: allowInsecureHttp,
schedule_enabled: scheduleEnabled,
schedule_type: scheduleType,
backup_kprofiles: backupKProfiles,
backup_cloud_profiles: backupCloudProfiles,
backup_settings: backupSettings,
backup_spools: backupSpools,
backup_archives: backupArchives,
enabled,
}), [repoUrl, branch, provider, allowInsecureHttp, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled]);
const autoSaveStateFingerprint = useMemo(
() => serializeAutosaveState(autoSaveState),
[autoSaveState]
);
// Queries
const { data: config, isLoading: configLoading } = useQuery<GitHubBackupConfig | null>({
queryKey: ['github-backup-config'],
queryFn: api.getGitHubBackupConfig,
});
const { data: status } = useQuery<GitHubBackupStatus>({
queryKey: ['github-backup-status'],
queryFn: api.getGitHubBackupStatus,
refetchInterval: (query) => query.state.data?.is_running ? 500 : 10000, // Poll fast during backup
});
const { data: logs } = useQuery<GitHubBackupLog[]>({
queryKey: ['github-backup-logs'],
queryFn: () => api.getGitHubBackupLogs(20),
});
const { data: cloudStatus } = useQuery<CloudAuthStatus>({
queryKey: ['cloud-status'],
queryFn: api.getCloudStatus,
});
// Fetch printers and their statuses for K-profile availability
const { data: printers } = useQuery<Printer[]>({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Fetch printer statuses from API (not just cache) to get accurate connection status
const printerStatusQueries = useQueries({
queries: (printers ?? []).map(printer => ({
queryKey: ['printerStatus', printer.id],
queryFn: () => api.getPrinterStatus(printer.id),
staleTime: 10000, // Consider stale after 10s
refetchInterval: 30000, // Refresh every 30s
})),
});
const printerStatuses = (printers ?? []).map((printer, index) => ({
printer,
connected: printerStatusQueries[index]?.data?.connected ?? false,
}));
const totalPrinters = printerStatuses.length;
const connectedPrinters = printerStatuses.filter(p => p.connected).length;
const noPrintersConnected = totalPrinters > 0 && connectedPrinters === 0;
const somePrintersDisconnected = connectedPrinters > 0 && connectedPrinters < totalPrinters;
// Initialize form from config
useEffect(() => {
if (initializationTimerRef.current) {
clearTimeout(initializationTimerRef.current);
}
if (config) {
setIsInitialized(false);
lastSavedAutosaveStateRef.current = {
repository_url: config.repository_url,
branch: config.branch,
provider: config.provider ?? 'github',
allow_insecure_http: config.allow_insecure_http ?? false,
schedule_enabled: config.schedule_enabled,
schedule_type: config.schedule_type,
backup_kprofiles: config.backup_kprofiles,
backup_cloud_profiles: config.backup_cloud_profiles,
backup_settings: config.backup_settings,
backup_spools: config.backup_spools,
backup_archives: config.backup_archives,
enabled: config.enabled,
};
setRepoUrl(config.repository_url);
setBranch(config.branch);
setProvider(config.provider ?? 'github');
setScheduleEnabled(config.schedule_enabled);
setScheduleType(config.schedule_type);
setBackupKProfiles(config.backup_kprofiles);
setBackupCloudProfiles(config.backup_cloud_profiles);
setBackupSettings(config.backup_settings);
setBackupSpools(config.backup_spools);
setBackupArchives(config.backup_archives);
setAllowInsecureHttp(config.allow_insecure_http ?? false);
setEnabled(config.enabled);
setAccessToken(''); // Don't show stored token
// Mark as initialized after a tick to avoid auto-save on initial load
initializationTimerRef.current = setTimeout(() => { setIsInitialized(true); }, 100);
} else {
setIsInitialized(false);
lastSavedAutosaveStateRef.current = null;
setAccessToken('');
}
return () => {
if (initializationTimerRef.current) {
clearTimeout(initializationTimerRef.current);
}
};
}, [config]);
// Auto-save function for existing configs
const autoSave = useCallback(async (includeToken: boolean = false) => {
if (!config) return; // Only auto-save if config already exists
try {
if (includeToken && accessToken) {
// Full save with new token
await api.saveGitHubBackupConfig({
...autoSaveState,
access_token: accessToken,
});
setAccessToken(''); // Clear after save
setSaveError(null);
showToast(t('backup.tokenUpdated'));
lastTokenScheduledForSaveRef.current = '';
} else {
// Update without token
await api.updateGitHubBackupConfig(getChangedAutosaveFields(
autoSaveState,
lastSavedAutosaveStateRef.current
));
setSaveError(null);
showToast(t('backup.settingsSaved'));
}
lastSavedAutosaveStateRef.current = autoSaveState;
queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
} catch (error) {
setSaveError((error as Error).message);
}
}, [config, accessToken, autoSaveState, queryClient, showToast, t]);
const autoSaveRef = useRef(autoSave);
useEffect(() => {
autoSaveRef.current = autoSave;
}, [autoSave]);
// Auto-save effect for existing configs (debounced)
useEffect(() => {
if (!isInitialized || !config) return;
if (
lastSavedAutosaveStateRef.current
&& autoSaveStateFingerprint === serializeAutosaveState(lastSavedAutosaveStateRef.current)
) return;
if (settingsAutoSaveTimerRef.current) {
clearTimeout(settingsAutoSaveTimerRef.current);
}
settingsAutoSaveTimerRef.current = setTimeout(() => {
autoSave(false);
}, 500);
return () => {
if (settingsAutoSaveTimerRef.current) {
clearTimeout(settingsAutoSaveTimerRef.current);
}
};
}, [isInitialized, config, autoSaveStateFingerprint, autoSave]);
// Auto-save token when it changes (with longer debounce)
useEffect(() => {
if (!isInitialized || !config || !accessToken) return;
if (accessToken === lastTokenScheduledForSaveRef.current) return;
lastTokenScheduledForSaveRef.current = accessToken;
if (tokenAutoSaveTimerRef.current) {
clearTimeout(tokenAutoSaveTimerRef.current);
}
tokenAutoSaveTimerRef.current = setTimeout(() => {
autoSaveRef.current(true);
}, 1000);
return () => {
if (tokenAutoSaveTimerRef.current) {
clearTimeout(tokenAutoSaveTimerRef.current);
}
};
}, [isInitialized, accessToken, config]);
// Mutations
const saveConfigMutation = useMutation({
mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
onMutate: () => {
setSaveError(null);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
showToast(t('backup.githubBackupEnabled'));
setAccessToken('');
setIsInitialized(true);
},
onError: (error: Error) => {
setSaveError(error.message);
},
});
const triggerBackupMutation = useMutation<GitHubBackupTriggerResponse, Error>({
mutationFn: api.triggerGitHubBackup,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
if (result.success) {
if (result.files_changed > 0) {
showToast(t('backup.backupCompleteFiles', { count: result.files_changed }));
} else {
showToast(t('backup.backupSkippedNoChanges'));
}
} else {
showToast(t('backup.backupFailed2', { message: result.message }), 'error');
}
},
onError: (error: Error) => {
showToast(t('backup.backupFailed2', { message: error.message }), 'error');
},
});
const clearLogsMutation = useMutation<{ deleted: number; message: string }, Error>({
mutationFn: () => api.clearGitHubBackupLogs(0),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
showToast(t('backup.clearedLogs', { count: result.deleted }));
},
onError: (error: Error) => {
showToast(t('backup.failedToClearLogs', { message: error.message }), 'error');
},
});
const handleTestConnection = async () => {
setTestLoading(true);
setTestResult(null);
try {
let result;
// If user entered a new token, test with those credentials
if (accessToken) {
if (!repoUrl) {
showToast(t('backup.enterRepoUrl'), 'error');
setTestLoading(false);
return;
}
result = await api.testGitHubConnection(repoUrl, accessToken, provider);
} else if (config?.has_token) {
// Use stored credentials
result = await api.testGitHubStoredConnection();
} else {
showToast(t('backup.enterRepoAndToken'), 'error');
setTestLoading(false);
return;
}
setTestResult({
success: result.success,
message: result.message,
isPrivate: result.is_private,
});
} catch (error) {
setTestResult({ success: false, message: (error as Error).message, isPrivate: null });
} finally {
setTestLoading(false);
}
};
// Initial setup save (only for new configs)
const handleInitialSetup = () => {
if (!repoUrl) {
showToast(t('backup.repoRequired'), 'error');
return;
}
if (!accessToken) {
showToast(t('backup.tokenRequired'), 'error');
return;
}
saveConfigMutation.mutate({
repository_url: repoUrl,
access_token: accessToken,
branch,
provider,
allow_insecure_http: allowInsecureHttp,
schedule_enabled: scheduleEnabled,
schedule_type: scheduleType,
backup_kprofiles: backupKProfiles,
backup_cloud_profiles: backupCloudProfiles,
backup_settings: backupSettings,
backup_spools: backupSpools,
backup_archives: backupArchives,
enabled,
});
};
if (configLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Git Backup */}
<div className="space-y-6">
<Card id="card-backup-github">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Github className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">{t('backup.githubBackup')}</h2>
</div>
{config && (
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray">{t('backup.enabled')}</span>
<Toggle
checked={enabled}
onChange={setEnabled}
/>
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
{t('backup.githubDescription')}
</p>
{/* Provider Selection */}
<div>
<label htmlFor="git-provider-select" className="block text-sm text-bambu-gray mb-1">{t('backup.provider')}</label>
<select
id="git-provider-select"
value={provider}
onChange={(e) => { setProvider(e.target.value as GitProviderType); setTestResult(null); setSaveError(null); }}
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"
>
<option value="github">{t('backup.providerGitHub')}</option>
<option value="gitlab">{t('backup.providerGitLab')}</option>
<option value="gitea">{t('backup.providerGitea')}</option>
<option value="forgejo">{t('backup.providerForgejo')}</option>
</select>
</div>
{/* Repository URL */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('backup.repositoryUrl')}
</label>
<input
type="text"
value={repoUrl}
onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); setSaveError(null); }}
placeholder={t(PROVIDER_REPO_URL_I18N_KEY[provider])}
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"
/>
<label className="flex items-start gap-2 mt-2 cursor-pointer">
<input
type="checkbox"
checked={allowInsecureHttp}
onChange={(e) => { setAllowInsecureHttp(e.target.checked); setTestResult(null); }}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-sm text-white">{t('backup.allowInsecureHttp')}</span>
<p className="text-xs text-bambu-gray">{t('backup.allowInsecureHttpHint')}</p>
</div>
</label>
</div>
{/* Access Token */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('backup.personalAccessToken')} {config?.has_token && <span className="text-green-700 dark:text-green-400">{t('backup.tokenSaved')}</span>}
</label>
<input
type="password"
value={accessToken}
onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); setSaveError(null); }}
placeholder={config?.has_token ? t('backup.enterNewToken') : PROVIDER_TOKEN_PLACEHOLDER[provider]}
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"
/>
<p className="text-xs text-bambu-gray mt-1">
{t('backup.tokenHint')}
</p>
</div>
{/* Branch - inline with schedule */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.branch')}</label>
<input
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
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"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.autoBackup')}</label>
<select
value={scheduleEnabled ? scheduleType : 'disabled'}
onChange={(e) => {
if (e.target.value === 'disabled') {
setScheduleEnabled(false);
} else {
setScheduleEnabled(true);
setScheduleType(e.target.value as ScheduleType);
}
}}
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"
>
<option value="disabled">{t('backup.manualOnly')}</option>
<option value="hourly">{t('backup.hourly')}</option>
<option value="daily">{t('backup.daily')}</option>
<option value="weekly">{t('backup.weekly')}</option>
</select>
</div>
</div>
{/* What to backup */}
<div>
<label className="block text-sm text-bambu-gray mb-2">{t('backup.includeInBackup')}</label>
<div className="space-y-2">
<label className={`flex items-start gap-2 ${noPrintersConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<input
type="checkbox"
checked={backupKProfiles}
onChange={(e) => setBackupKProfiles(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
disabled={noPrintersConnected}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className={`text-sm ${noPrintersConnected ? 'text-bambu-gray' : 'text-white'}`}>{t('backup.kProfiles')}</span>
{noPrintersConnected && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<AlertTriangle className="w-3 h-3" />
{t('backup.noPrintersConnected')}
</span>
)}
{somePrintersDisconnected && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<AlertTriangle className="w-3 h-3" />
{t('backup.printersConnected', { connected: connectedPrinters, total: totalPrinters })}
</span>
)}
</div>
<p className="text-xs text-bambu-gray">{t('backup.kProfilesDescription')}</p>
</div>
</label>
<label className={`flex items-start gap-2 ${!cloudStatus?.is_authenticated ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<input
type="checkbox"
checked={backupCloudProfiles}
onChange={(e) => setBackupCloudProfiles(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
disabled={!cloudStatus?.is_authenticated}
/>
<div>
<div className="flex items-center gap-2">
<span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
{!cloudStatus?.is_authenticated && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<AlertTriangle className="w-3 h-3" />
{t('backup.cloudLoginRequiredShort')}
</span>
)}
</div>
<p className="text-xs text-bambu-gray">{t('backup.cloudProfilesDescription')}</p>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={backupSettings}
onChange={(e) => setBackupSettings(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-white text-sm">{t('backup.appSettings')}</span>
<p className="text-xs text-bambu-gray">{t('backup.appSettingsDescription')}</p>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={backupSpools}
onChange={(e) => setBackupSpools(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-white text-sm">{t('backup.spoolInventory')}</span>
<p className="text-xs text-bambu-gray">{t('backup.spoolInventoryDescription')}</p>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={backupArchives}
onChange={(e) => setBackupArchives(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-white text-sm">{t('backup.printArchives')}</span>
<p className="text-xs text-bambu-gray">{t('backup.printArchivesDescription')}</p>
</div>
</label>
</div>
</div>
{/* Test + Status + Actions */}
<div className="border-t border-bambu-dark-tertiary pt-4 space-y-3">
{/* Status line */}
{status?.configured && (
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-bambu-gray">
{status.last_backup_at ? (
<>
<span>{t('backup.lastBackupAt')} {formatRelativeTime(status.last_backup_at, 'system', t)}</span>
<StatusBadge status={status.last_backup_status} />
</>
) : (
<span>{t('backup.noBackupsYet')}</span>
)}
</div>
{status.next_scheduled_run && (
<span className="text-bambu-gray">
<Clock className="w-3 h-3 inline mr-1" />
{t('backup.next')} {formatRelativeTime(status.next_scheduled_run, 'system', t)}
</span>
)}
</div>
)}
{/* Save error — inline banner. Keeps long backend rejection
messages (e.g. the "repository is not private" guard)
readable instead of clipped to a toast. */}
{saveError && (
<div className="text-sm text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded p-3 flex items-start gap-2">
<XCircle className="w-4 h-4 mt-0.5 shrink-0" />
<div className="flex-1 leading-relaxed whitespace-pre-wrap break-words">{saveError}</div>
<button
type="button"
onClick={() => setSaveError(null)}
className="text-bambu-gray hover:text-white shrink-0"
aria-label={t('common.dismiss', 'Dismiss')}
>
<XCircle className="w-4 h-4" />
</button>
</div>
)}
{/* Test result */}
{testResult && (
<div className="space-y-1.5">
<div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}>
{testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{testResult.message}
</div>
{testResult.success && testResult.isPrivate === true && (
<div className="text-xs flex items-center gap-1 text-green-700 dark:text-green-400">
<CheckCircle className="w-3.5 h-3.5" />
{t('backup.repoIsPrivate', 'Repository is private — safe to back up to.')}
</div>
)}
{testResult.success && testResult.isPrivate === false && (
<div className="text-xs text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded p-2 flex items-start gap-1.5">
<XCircle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
{t(
'backup.repoIsPublicWarning',
'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.',
)}
</span>
</div>
)}
{testResult.success && testResult.isPrivate === null && (
<div className="text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30 rounded p-2 flex items-start gap-1.5">
<XCircle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
{t(
'backup.repoVisibilityUnknown',
'Could not determine repository visibility. Bambuddy refuses to back up to anything not confirmed private; saving will be blocked.',
)}
</span>
</div>
)}
</div>
)}
{/* Action buttons */}
<div className="flex flex-wrap items-center gap-2">
{status?.configured ? (
<>
{(triggerBackupMutation.isPending || status.is_running) ? (
<div className="flex items-center gap-2 text-bambu-green">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">{status.progress || t('backup.startingBackup')}</span>
</div>
) : (
<>
<Button
variant="primary"
size="sm"
onClick={() => triggerBackupMutation.mutate()}
disabled={!config?.enabled}
>
<Play className="w-4 h-4" />
{t('backup.backupNow')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleTestConnection}
disabled={testLoading}
>
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{t('backup.test')}
</Button>
</>
)}
</>
) : (
<>
<Button
variant="primary"
size="sm"
onClick={handleInitialSetup}
disabled={saveConfigMutation.isPending || !repoUrl || !accessToken}
>
{saveConfigMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
{t('backup.enableBackup')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleTestConnection}
disabled={testLoading || !repoUrl || !accessToken}
>
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{t('backup.testConnection')}
</Button>
</>
)}
</div>
</div>
</CardContent>
</Card>
{/* Backup History - only show if configured and has logs */}
{logs && logs.length > 0 && (
<Card id="card-backup-history">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<History className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">{t('backup.history')}</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => clearLogsMutation.mutate()}
disabled={clearLogsMutation.isPending}
>
<Trash2 className="w-4 h-4" />
{t('backup.clear')}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
<th className="text-left py-2 px-2">{t('backup.date')}</th>
<th className="text-left py-2 px-2">{t('backup.status')}</th>
<th className="text-left py-2 px-2">{t('backup.commit')}</th>
</tr>
</thead>
<tbody>
{logs.slice(0, 10).map((log) => (
<tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
<td className="py-2 px-2 text-white">{formatDateTime(log.started_at)}</td>
<td className="py-2 px-2"><StatusBadge status={log.status} /></td>
<td className="py-2 px-2">
{log.commit_sha ? (
<a
href={`${config?.repository_url}/commit/${log.commit_sha}`}
target="_blank"
rel="noopener noreferrer"
className="text-bambu-green hover:underline inline-flex items-center gap-1"
>
{log.commit_sha.substring(0, 7)}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-bambu-gray">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
{/* Right Column - Local Backup */}
<div className="space-y-6">
<Card id="card-backup-local">
<CardHeader>
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">{t('backup.localBackup')}</h2>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
{t('backup.localBackupDescription')}
</p>
{/* Export */}
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
<div>
<p className="text-white">{t('backup.downloadBackupLabel')}</p>
<p className="text-sm text-bambu-gray">
{t('backup.completeBackupZip')}
</p>
</div>
<Button
variant="secondary"
size="sm"
disabled={isExporting || isRestoring}
onClick={async () => {
setIsExporting(true);
setOperationStatus(t('backup.preparingBackup'));
try {
setOperationStatus(t('backup.creatingArchive'));
const { blob, filename } = await api.exportBackup();
setOperationStatus(t('backup.downloadingFile'));
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast(t('backup.backupDownloaded'));
} catch (e) {
showToast(t('backup.failedToCreateBackup', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
} finally {
setIsExporting(false);
setOperationStatus('');
}
}}
>
<Download className="w-4 h-4" />
{t('backup.download')}
</Button>
</div>
{/* Import */}
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
<div>
<p className="text-white">{t('backup.restoreBackup')}</p>
<p className="text-sm text-bambu-gray">
{t('backup.restoreDescription')}
</p>
<p className="text-xs text-bambu-gray-light mt-1">
{t('backup.restoreNote')}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setRestoreFile(file);
setShowRestoreConfirm(true);
}
e.target.value = '';
}}
/>
<Button
variant="secondary"
size="sm"
disabled={isRestoring || isExporting}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4" />
{t('backup.restore')}
</Button>
</div>
{/* Restore result message */}
{restoreResult && (
<div className={`p-3 rounded-lg ${restoreResult.success ? 'bg-green-50 dark:bg-green-500/10 border border-green-300 dark:border-green-500/30' : 'bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30'}`}>
<div className="flex items-start gap-2 text-sm">
{restoreResult.success ? (
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
) : (
<XCircle className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
)}
<div className={restoreResult.success ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'}>
{restoreResult.message}
{restoreResult.success && (
<div className="mt-2">
<Button
size="sm"
onClick={() => window.location.reload()}
>
<RotateCcw className="w-3 h-3" />
{t('backup.reloadNow')}
</Button>
</div>
)}
</div>
</div>
</div>
)}
{/* Warning */}
<div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="text-yellow-800 dark:text-yellow-200">
<span className="font-medium">{t('backup.restoreReplacesAll')}</span>{' '}
<span className="text-yellow-800/80 dark:text-yellow-200/70">{t('backup.restoreReplacesAllDetail')}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Scheduled Local Backups */}
<Card id="card-backup-scheduled">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FolderArchive className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">{t('backup.scheduledBackup')}</h2>
</div>
<Toggle
checked={localBackupStatus?.enabled ?? false}
onChange={async (checked) => {
try {
await api.updateSettings({ local_backup_enabled: checked });
queryClient.invalidateQueries({ queryKey: ['settings'] });
showToast(t('backup.settingsSaved'));
} catch (e) {
showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
}
refetchLocalStatus();
}}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
{t('backup.scheduledBackupDescription')}
</p>
{localBackupStatus?.enabled && (
<>
{/* Schedule + Time + Retention */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.frequency')}</label>
<select
value={localBackupStatus?.schedule ?? 'daily'}
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"
onChange={async (e) => {
try {
await api.updateSettings({ local_backup_schedule: e.target.value });
showToast(t('backup.settingsSaved'));
} catch (e) {
showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
}
refetchLocalStatus();
}}
>
<option value="hourly">{t('backup.hourly')}</option>
<option value="daily">{t('backup.daily')}</option>
<option value="weekly">{t('backup.weekly')}</option>
</select>
</div>
{(localBackupStatus?.schedule ?? 'daily') !== 'hourly' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.backupTime')}</label>
<input
type="time"
value={localBackupStatus?.time ?? '03:00'}
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]"
onChange={async (e) => {
try {
await api.updateSettings({ local_backup_time: e.target.value });
showToast(t('backup.settingsSaved'));
} catch (err) {
showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');
}
refetchLocalStatus();
}}
/>
<p className="text-xs text-bambu-gray-light mt-1">
{t('backup.localTimeHint', { tz: localBackupStatus?.timezone || 'UTC' })}
</p>
</div>
)}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.retention')}</label>
<input
type="number"
min={1}
max={100}
value={localBackupStatus?.retention ?? 5}
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"
onChange={async (e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 5));
try {
await api.updateSettings({ local_backup_retention: val });
showToast(t('backup.settingsSaved'));
} catch (e) {
showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
}
refetchLocalStatus();
}}
/>
<p className="text-xs text-bambu-gray-light mt-1">{t('backup.retentionDescription')}</p>
</div>
</div>
{/* Output Path */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.outputPath')}</label>
<input
type="text"
value={localBackupPath}
onChange={(e) => setLocalBackupPath(e.target.value)}
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"
onBlur={async () => {
try {
await api.updateSettings({ local_backup_path: localBackupPath });
showToast(t('backup.settingsSaved'));
} catch (err) {
showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');
}
refetchLocalStatus();
refetchLocalBackups();
refetchLocalPathCheck();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
}}
/>
<p className="text-xs text-bambu-gray-light mt-1">
{localBackupPath
? t('backup.outputPathDescription')
: <>{t('backup.defaultPathLabel')} <code className="text-bambu-gray">{localBackupStatus?.default_path || '...'}</code></>
}
</p>
{/* The directory cannot be written to — say why, and how to fix it (#2544) */}
{localBackupPathCheck && !localBackupPathCheck.writable && (
<div className="mt-2 p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 text-red-800 dark:text-red-200">
<p className="font-medium">{t('backup.pathCheck.title')}</p>
<p className="mt-1 text-red-800/80 dark:text-red-200/80">
{t(`backup.pathCheck.${localBackupPathCheck.code}`, {
path: localBackupPathCheck.path,
defaultValue: localBackupPathCheck.message,
})}
</p>
{localBackupPathCheck.remedy && (
<>
<p className="mt-2 font-medium">{t('backup.pathCheck.howToFix')}</p>
<pre className="mt-1 p-2 rounded bg-black/10 dark:bg-black/40 text-xs whitespace-pre-wrap break-words">
{localBackupPathCheck.remedy}
</pre>
</>
)}
{localBackupPathCheck.detail && (
<p className="mt-2 text-xs text-red-800/60 dark:text-red-200/60 break-words">
{localBackupPathCheck.detail}
</p>
)}
</div>
</div>
</div>
)}
{/* Writable, but only inside the container — the backups die with it */}
{localBackupPathCheck?.writable && localBackupPathCheck.warning === 'container_ephemeral' && (
<div className="mt-2 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 text-yellow-800 dark:text-yellow-200">
<p className="font-medium">{t('backup.pathCheck.ephemeralTitle')}</p>
<p className="mt-1 text-yellow-800/80 dark:text-yellow-200/80">
{t('backup.pathCheck.container_ephemeral', { path: localBackupPathCheck.path })}
</p>
{localBackupPathCheck.remedy && (
<pre className="mt-1 p-2 rounded bg-black/10 dark:bg-black/40 text-xs whitespace-pre-wrap break-words">
{localBackupPathCheck.remedy}
</pre>
)}
</div>
</div>
</div>
)}
</div>
{/* Status + Run Now */}
<div className="flex items-center justify-between py-3 border-t border-bambu-dark-tertiary">
<div className="text-sm">
{localBackupStatus?.last_backup_at && (
<div className="flex items-center gap-2 text-bambu-gray">
<span>{t('backup.lastBackup')}:</span>
<StatusBadge status={localBackupStatus.last_status} />
<span>{formatRelativeTime(localBackupStatus.last_backup_at)}</span>
</div>
)}
{localBackupStatus?.next_run && (
<div className="text-bambu-gray mt-1">
<span>{t('backup.nextBackup')}: </span>
<span>{formatDateTime(localBackupStatus.next_run)}</span>
</div>
)}
</div>
<Button
variant="secondary"
size="sm"
disabled={localBackupStatus?.is_running || triggerLocalBackupMutation.isPending}
onClick={() => triggerLocalBackupMutation.mutate()}
>
{localBackupStatus?.is_running || triggerLocalBackupMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
{localBackupStatus?.is_running ? t('backup.backupRunning') : t('backup.runNow')}
</Button>
</div>
{/* Backup Files List */}
{localBackups && localBackups.length > 0 && (
<div className="border-t border-bambu-dark-tertiary pt-3">
<h3 className="text-sm font-medium text-white mb-2">{t('backup.backupFiles')}</h3>
<div className="space-y-1">
{localBackups.map((file) => (
<div key={file.filename} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-bambu-dark-tertiary/50 text-sm">
<div className="flex-1 min-w-0">
<span className="text-white truncate block">{file.filename}</span>
<span className="text-bambu-gray text-xs">
{(file.size / 1024 / 1024).toFixed(1)} MB &middot; {formatDateTime(file.created_at)}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
className="text-bambu-gray hover:text-bambu-green p-1"
title={t('backup.download')}
onClick={async () => {
try {
const { blob, filename: fname } = await api.downloadLocalBackup(file.filename);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fname;
a.click();
URL.revokeObjectURL(url);
} catch {
showToast(t('backup.scheduledBackupFailed'), 'error');
}
}}
>
<Download className="w-3.5 h-3.5" />
</button>
<button
className="text-bambu-gray hover:text-yellow-600 dark:hover:text-yellow-400 p-1"
title={t('backup.restore')}
onClick={() => setRestoreConfirmFile(file.filename)}
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
<button
className="text-bambu-gray hover:text-red-600 dark:hover:text-red-400 p-1"
onClick={() => setDeleteConfirmFile(file.filename)}
title={t('backup.deleteBackup')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
)}
{localBackups && localBackups.length === 0 && (
<p className="text-sm text-bambu-gray text-center py-3 border-t border-bambu-dark-tertiary">
{t('backup.noScheduledBackups')}
</p>
)}
</>
)}
</CardContent>
</Card>
</div>
{/* Delete Backup Confirmation Modal */}
{deleteConfirmFile && (
<ConfirmModal
title={t('backup.deleteBackup')}
message={t('backup.deleteBackupConfirm')}
confirmText={t('backup.deleteBackup')}
variant="danger"
onConfirm={() => deleteLocalBackupMutation.mutate(deleteConfirmFile)}
onCancel={() => setDeleteConfirmFile(null)}
/>
)}
{/* Restore from Scheduled Backup Confirmation Modal */}
{restoreConfirmFile && (
<ConfirmModal
title={t('backup.restoreConfirmTitle')}
message={t('backup.restoreConfirmMessage', { filename: restoreConfirmFile })}
confirmText={t('backup.restoreConfirmButton')}
variant="danger"
onConfirm={() => restoreLocalBackupMutation.mutate(restoreConfirmFile)}
onCancel={() => setRestoreConfirmFile(null)}
/>
)}
{/* Restore Confirmation Modal */}
{showRestoreConfirm && restoreFile && (
<ConfirmModal
title={t('backup.restoreConfirmTitle')}
message={t('backup.restoreConfirmMessage', { filename: restoreFile.name })}
confirmText={t('backup.restoreConfirmButton')}
variant="danger"
onConfirm={async () => {
setShowRestoreConfirm(false);
setIsRestoring(true);
setRestoreResult(null);
try {
setOperationStatus(t('backup.uploadingFile'));
const result = await api.importBackup(restoreFile);
setRestoreResult(result);
if (result.success) {
showToast(t('backup.backupRestoredRestart'), 'success');
} else {
showToast(result.message, 'error');
}
} catch (e) {
const message = e instanceof Error ? e.message : t('backup.failedToRestore');
setRestoreResult({ success: false, message });
showToast(message, 'error');
} finally {
setIsRestoring(false);
setOperationStatus('');
setRestoreFile(null);
}
}}
onCancel={() => {
setShowRestoreConfirm(false);
setRestoreFile(null);
}}
/>
)}
{/* Blocking overlay during backup/restore operations */}
{(isExporting || isRestoring) && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[100]">
<div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-8 max-w-md w-full mx-4 text-center">
<div className="flex justify-center mb-4">
<div className="relative">
<div className="w-16 h-16 border-4 border-bambu-dark-tertiary rounded-full"></div>
<div className="w-16 h-16 border-4 border-bambu-green border-t-transparent rounded-full absolute inset-0 animate-spin"></div>
</div>
</div>
<h3 className="text-xl font-semibold text-white mb-2">
{isExporting ? t('backup.creatingBackup') : t('backup.restoringBackup')}
</h3>
<p className="text-bambu-gray mb-4">
{operationStatus || (isExporting ? t('backup.preparing') : t('backup.processing'))}
</p>
<div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
<p className="text-yellow-800 dark:text-yellow-200 text-left">
{t('backup.doNotClosePage')}
</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}