| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439 |
- 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,
- 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-500/20 text-green-400',
- failed: 'bg-red-500/20 text-red-400',
- skipped: 'bg-yellow-500/20 text-yellow-400',
- running: 'bg-blue-500/20 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,
- });
- // 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();
- },
- 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-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-500/20 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-500/20 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-500/20 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-400 bg-red-500/10 border 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-400' : '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-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-400 bg-red-500/10 border 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-400 bg-yellow-500/10 border 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-500/10 border border-green-500/30' : 'bg-red-500/10 border border-red-500/30'}`}>
- <div className="flex items-start gap-2 text-sm">
- {restoreResult.success ? (
- <CheckCircle className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
- ) : (
- <XCircle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
- )}
- <div className={restoreResult.success ? 'text-green-200' : '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-500/10 border border-yellow-500/30">
- <div className="flex items-start gap-2 text-sm">
- <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
- <div className="text-yellow-200">
- <span className="font-medium">{t('backup.restoreReplacesAll')}</span>{' '}
- <span className="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.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();
- }}
- 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>
- </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-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-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-500/10 border border-yellow-500/30">
- <div className="flex items-start gap-2 text-sm">
- <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
- <p className="text-yellow-200 text-left">
- {t('backup.doNotClosePage')}
- </p>
- </div>
- </div>
- </div>
- </div>
- )}
- </div>
- );
- }
|