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:
maziggy
2026-01-27 15:01:06 +01:00
parent ea737c4719
commit 4ea7f7c4aa
20 changed files with 11536 additions and 8848 deletions
@@ -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>
);
}