mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Add GitHub profile backup feature
New feature to automatically backup K-profiles, cloud profiles, and app settings to a GitHub repository with scheduled or on-demand execution. Features: - Configure GitHub repo URL and Personal Access Token - Schedule backups hourly, daily, or weekly (background scheduler) - Manual backup trigger with real-time progress tracking - Skip unchanged commits (only creates commit when data changes) - Backup history log with status and commit links - Requires Bambu Cloud login for full profile access - New Settings → Backup & Restore tab consolidating all backup options - GitHub backup config included in local backup/restore (except PAT) Backend: - New models: GitHubBackupConfig, GitHubBackupLog - New service: GitHubBackupService with scheduler and GitHub API client - New routes: /github-backup/* for config, status, logs, and triggers - Updated settings.py to include github_backup in backup/restore Frontend: - New GitHubBackupSettings.tsx component with auto-save - Updated SettingsPage with Backup tab and status indicator - Added API types and methods to client.ts Tests: - Backend integration tests for all GitHub backup API endpoints - Frontend API type and endpoint tests
This commit is contained in:
@@ -0,0 +1,746 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Github,
|
||||
Play,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Upload,
|
||||
Database,
|
||||
History,
|
||||
SkipForward,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type {
|
||||
GitHubBackupConfig,
|
||||
GitHubBackupConfigCreate,
|
||||
GitHubBackupLog,
|
||||
GitHubBackupStatus,
|
||||
GitHubBackupTriggerResponse,
|
||||
ScheduleType,
|
||||
CloudAuthStatus,
|
||||
} from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { Toggle } from './Toggle';
|
||||
import { BackupModal } from './BackupModal';
|
||||
import { RestoreModal } from './RestoreModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
const diffMins = Math.round(diffMs / 60000);
|
||||
|
||||
if (diffMins < 0) {
|
||||
const absMins = Math.abs(diffMins);
|
||||
if (absMins < 60) return `${absMins}m ago`;
|
||||
const hours = Math.floor(absMins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
} else {
|
||||
if (diffMins < 60) return `in ${diffMins}m`;
|
||||
const hours = Math.floor(diffMins / 60);
|
||||
if (hours < 24) return `in ${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `in ${days}d`;
|
||||
}
|
||||
}
|
||||
|
||||
export function GitHubBackupSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Local state for form
|
||||
const [repoUrl, setRepoUrl] = useState('');
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const [branch, setBranch] = useState('main');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
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 [enabled, setEnabled] = useState(true);
|
||||
|
||||
// Local backup modals
|
||||
const [showBackupModal, setShowBackupModal] = useState(false);
|
||||
const [showRestoreModal, setShowRestoreModal] = useState(false);
|
||||
|
||||
// Test connection state
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Auto-save debounce
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// Initialize form from config
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setRepoUrl(config.repository_url);
|
||||
setBranch(config.branch);
|
||||
setScheduleEnabled(config.schedule_enabled);
|
||||
setScheduleType(config.schedule_type);
|
||||
setBackupKProfiles(config.backup_kprofiles);
|
||||
setBackupCloudProfiles(config.backup_cloud_profiles);
|
||||
setBackupSettings(config.backup_settings);
|
||||
setEnabled(config.enabled);
|
||||
setAccessToken(''); // Don't show stored token
|
||||
// Mark as initialized after a tick to avoid auto-save on initial load
|
||||
setTimeout(() => { isInitializedRef.current = true; }, 100);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Auto-save function for existing configs
|
||||
const autoSave = useCallback(async (includeToken: boolean = false) => {
|
||||
if (!config?.has_token) return; // Only auto-save if config already exists
|
||||
|
||||
try {
|
||||
if (includeToken && accessToken) {
|
||||
// Full save with new token
|
||||
await api.saveGitHubBackupConfig({
|
||||
repository_url: repoUrl,
|
||||
access_token: accessToken,
|
||||
branch,
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_type: scheduleType,
|
||||
backup_kprofiles: backupKProfiles,
|
||||
backup_cloud_profiles: backupCloudProfiles,
|
||||
backup_settings: backupSettings,
|
||||
enabled,
|
||||
});
|
||||
setAccessToken(''); // Clear after save
|
||||
showToast('Token updated');
|
||||
} else {
|
||||
// Update without token
|
||||
await api.updateGitHubBackupConfig({
|
||||
repository_url: repoUrl,
|
||||
branch,
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_type: scheduleType,
|
||||
backup_kprofiles: backupKProfiles,
|
||||
backup_cloud_profiles: backupCloudProfiles,
|
||||
backup_settings: backupSettings,
|
||||
enabled,
|
||||
});
|
||||
showToast('Settings saved');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
|
||||
} catch (error) {
|
||||
showToast(`Failed to save: ${(error as Error).message}`, 'error');
|
||||
}
|
||||
}, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, enabled, queryClient, showToast]);
|
||||
|
||||
// Auto-save effect for existing configs (debounced)
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current || !config?.has_token) return;
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
autoSave(false);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [repoUrl, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, enabled, autoSave, config?.has_token]);
|
||||
|
||||
// Auto-save token when it changes (with longer debounce)
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current || !config?.has_token || !accessToken) return;
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
autoSave(true);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [accessToken, autoSave, config?.has_token]);
|
||||
|
||||
// Mutations
|
||||
const saveConfigMutation = useMutation({
|
||||
mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
|
||||
showToast('GitHub backup enabled');
|
||||
setAccessToken('');
|
||||
isInitializedRef.current = true;
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(`Failed to save: ${error.message}`, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
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(`Backup complete - ${result.files_changed} files updated`);
|
||||
} else {
|
||||
showToast('Backup skipped - no changes');
|
||||
}
|
||||
} else {
|
||||
showToast(`Backup failed: ${result.message}`, 'error');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(`Backup failed: ${error.message}`, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const clearLogsMutation = useMutation<{ deleted: number; message: string }, Error>({
|
||||
mutationFn: () => api.clearGitHubBackupLogs(10),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
|
||||
showToast(`Cleared ${result.deleted} logs`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(`Failed to clear logs: ${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('Enter repository URL', 'error');
|
||||
setTestLoading(false);
|
||||
return;
|
||||
}
|
||||
result = await api.testGitHubConnection(repoUrl, accessToken);
|
||||
} else if (config?.has_token) {
|
||||
// Use stored credentials
|
||||
result = await api.testGitHubStoredConnection();
|
||||
} else {
|
||||
showToast('Enter repository URL and access token', 'error');
|
||||
setTestLoading(false);
|
||||
return;
|
||||
}
|
||||
setTestResult({ success: result.success, message: result.message });
|
||||
} catch (error) {
|
||||
setTestResult({ success: false, message: (error as Error).message });
|
||||
} finally {
|
||||
setTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial setup save (only for new configs)
|
||||
const handleInitialSetup = () => {
|
||||
if (!repoUrl) {
|
||||
showToast('Repository URL is required', 'error');
|
||||
return;
|
||||
}
|
||||
if (!accessToken) {
|
||||
showToast('Access token is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
saveConfigMutation.mutate({
|
||||
repository_url: repoUrl,
|
||||
access_token: accessToken,
|
||||
branch,
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_type: scheduleType,
|
||||
backup_kprofiles: backupKProfiles,
|
||||
backup_cloud_profiles: backupCloudProfiles,
|
||||
backup_settings: backupSettings,
|
||||
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 - GitHub Backup */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<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">GitHub Backup</h2>
|
||||
</div>
|
||||
{config && cloudStatus?.is_authenticated && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-bambu-gray">Enabled</span>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Bambu Cloud required message */}
|
||||
{!cloudStatus?.is_authenticated ? (
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-yellow-400">
|
||||
Bambu Cloud login required. Sign in under Profiles → Cloud Profiles to enable GitHub backup.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Automatically sync your profiles to a private GitHub repository for backup and version history.
|
||||
</p>
|
||||
|
||||
{/* Repository URL */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Repository URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={repoUrl}
|
||||
onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); }}
|
||||
placeholder="https://github.com/username/bambuddy-backup"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Access Token */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Personal Access Token {config?.has_token && <span className="text-green-400">(saved)</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={accessToken}
|
||||
onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}
|
||||
placeholder={config?.has_token ? 'Enter new token to update' : 'ghp_xxxxxxxxxxxx'}
|
||||
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
|
||||
>
|
||||
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Fine-grained token with Contents read/write permission
|
||||
</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">Branch</label>
|
||||
<input
|
||||
type="text"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
className="w-full 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">Auto-backup</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 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">Manual only</option>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What to backup */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-2">Include in backup</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-2 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"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-white text-sm">K-Profiles</span>
|
||||
<p className="text-xs text-bambu-gray">Pressure advance calibration from connected printers</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 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>
|
||||
<span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>Cloud Profiles</span>
|
||||
<p className="text-xs text-bambu-gray">Filament, printer, and process presets from Bambu Cloud</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">App Settings</span>
|
||||
<p className="text-xs text-bambu-gray">Bambuddy configuration (excludes sensitive data)</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>Last backup: {formatRelativeTime(status.last_backup_at)}</span>
|
||||
<StatusBadge status={status.last_backup_status} />
|
||||
</>
|
||||
) : (
|
||||
<span>No backups yet</span>
|
||||
)}
|
||||
</div>
|
||||
{status.next_scheduled_run && (
|
||||
<span className="text-bambu-gray">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
Next: {formatRelativeTime(status.next_scheduled_run)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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 || 'Starting backup...'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => triggerBackupMutation.mutate()}
|
||||
disabled={!config?.enabled}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Backup Now
|
||||
</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" />}
|
||||
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" />}
|
||||
Enable Backup
|
||||
</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" />}
|
||||
Test Connection
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Backup History - only show if configured and has logs */}
|
||||
{logs && logs.length > 0 && (
|
||||
<Card>
|
||||
<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">History</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => clearLogsMutation.mutate()}
|
||||
disabled={clearLogsMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
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">Date</th>
|
||||
<th className="text-left py-2 px-2">Status</th>
|
||||
<th className="text-left py-2 px-2">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>
|
||||
<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">Local Backup</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Export or import your Bambuddy data as a local file for manual backup or migration.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
|
||||
<div>
|
||||
<p className="text-white">Export Data</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Download all settings, printers, and profiles
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowBackupModal(true)}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="text-white">Import Backup</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Restore from a previous export file
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowRestoreModal(true)}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showBackupModal && (
|
||||
<BackupModal
|
||||
onClose={() => setShowBackupModal(false)}
|
||||
onExport={async (categories) => {
|
||||
setShowBackupModal(false);
|
||||
try {
|
||||
const { blob, filename } = await api.exportBackup(categories);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Backup downloaded successfully');
|
||||
} catch {
|
||||
showToast('Failed to create backup', 'error');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRestoreModal && (
|
||||
<RestoreModal
|
||||
onClose={() => setShowRestoreModal(false)}
|
||||
onRestore={async (file, overwrite) => {
|
||||
return await api.importBackup(file, overwrite);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowRestoreModal(false);
|
||||
showToast('Backup restored successfully');
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user