mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
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.
1508 lines
66 KiB
TypeScript
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 · {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>
|
|
);
|
|
}
|