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,283 @@
/**
* Tests for the GitHub Backup API client functions.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import type {
GitHubBackupConfig,
GitHubBackupStatus,
GitHubBackupLog,
} from '../../api/client';
// Mock API base URL
const API_BASE = 'http://localhost:5000/api/v1';
// Create MSW server
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('GitHub Backup API Types', () => {
it('GitHubBackupConfig has correct shape', () => {
const config: GitHubBackupConfig = {
id: 1,
repository_url: 'https://github.com/test/repo',
has_token: true,
branch: 'main',
schedule_enabled: true,
schedule_type: 'daily',
backup_kprofiles: true,
backup_cloud_profiles: true,
backup_settings: false,
enabled: true,
last_backup_at: '2026-01-27T10:00:00Z',
last_backup_status: 'success',
last_backup_message: null,
last_backup_commit_sha: 'abc123',
next_scheduled_run: '2026-01-28T00:00:00Z',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-27T10:00:00Z',
};
expect(config.id).toBe(1);
expect(config.has_token).toBe(true);
expect(config.schedule_type).toBe('daily');
});
it('GitHubBackupStatus has correct shape', () => {
const status: GitHubBackupStatus = {
configured: true,
enabled: true,
is_running: false,
progress: null,
last_backup_at: '2026-01-27T10:00:00Z',
last_backup_status: 'success',
next_scheduled_run: '2026-01-28T00:00:00Z',
};
expect(status.configured).toBe(true);
expect(status.is_running).toBe(false);
});
it('GitHubBackupStatus can have progress', () => {
const status: GitHubBackupStatus = {
configured: true,
enabled: true,
is_running: true,
progress: 'Pushing to GitHub...',
last_backup_at: null,
last_backup_status: null,
next_scheduled_run: null,
};
expect(status.is_running).toBe(true);
expect(status.progress).toBe('Pushing to GitHub...');
});
it('GitHubBackupLog has correct shape', () => {
const log: GitHubBackupLog = {
id: 1,
config_id: 1,
started_at: '2026-01-27T10:00:00Z',
completed_at: '2026-01-27T10:01:00Z',
status: 'success',
trigger: 'manual',
commit_sha: 'abc123',
files_changed: 5,
error_message: null,
};
expect(log.status).toBe('success');
expect(log.trigger).toBe('manual');
expect(log.files_changed).toBe(5);
});
it('GitHubBackupLog can have error', () => {
const log: GitHubBackupLog = {
id: 2,
config_id: 1,
started_at: '2026-01-27T10:00:00Z',
completed_at: '2026-01-27T10:00:30Z',
status: 'failed',
trigger: 'scheduled',
commit_sha: null,
files_changed: 0,
error_message: 'Authentication failed',
};
expect(log.status).toBe('failed');
expect(log.error_message).toBe('Authentication failed');
expect(log.commit_sha).toBeNull();
});
});
describe('GitHub Backup API Endpoints', () => {
it('GET /github-backup/config returns null when not configured', async () => {
server.use(
http.get(`${API_BASE}/github-backup/config`, () => {
return HttpResponse.json(null);
})
);
const response = await fetch(`${API_BASE}/github-backup/config`);
const data = await response.json();
expect(data).toBeNull();
});
it('GET /github-backup/config returns config when exists', async () => {
const mockConfig: GitHubBackupConfig = {
id: 1,
repository_url: 'https://github.com/test/repo',
has_token: true,
branch: 'main',
schedule_enabled: false,
schedule_type: 'daily',
backup_kprofiles: true,
backup_cloud_profiles: true,
backup_settings: false,
enabled: true,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
server.use(
http.get(`${API_BASE}/github-backup/config`, () => {
return HttpResponse.json(mockConfig);
})
);
const response = await fetch(`${API_BASE}/github-backup/config`);
const data = await response.json();
expect(data.repository_url).toBe('https://github.com/test/repo');
expect(data.has_token).toBe(true);
});
it('GET /github-backup/status returns not configured status', async () => {
const mockStatus: GitHubBackupStatus = {
configured: false,
enabled: false,
is_running: false,
progress: null,
last_backup_at: null,
last_backup_status: null,
next_scheduled_run: null,
};
server.use(
http.get(`${API_BASE}/github-backup/status`, () => {
return HttpResponse.json(mockStatus);
})
);
const response = await fetch(`${API_BASE}/github-backup/status`);
const data = await response.json();
expect(data.configured).toBe(false);
expect(data.enabled).toBe(false);
});
it('GET /github-backup/logs returns empty list when no logs', async () => {
server.use(
http.get(`${API_BASE}/github-backup/logs`, () => {
return HttpResponse.json([]);
})
);
const response = await fetch(`${API_BASE}/github-backup/logs`);
const data = await response.json();
expect(data).toEqual([]);
});
it('GET /github-backup/logs returns log entries', async () => {
const mockLogs: GitHubBackupLog[] = [
{
id: 1,
config_id: 1,
started_at: '2026-01-27T10:00:00Z',
completed_at: '2026-01-27T10:01:00Z',
status: 'success',
trigger: 'manual',
commit_sha: 'abc123',
files_changed: 5,
error_message: null,
},
];
server.use(
http.get(`${API_BASE}/github-backup/logs`, () => {
return HttpResponse.json(mockLogs);
})
);
const response = await fetch(`${API_BASE}/github-backup/logs`);
const data = await response.json();
expect(data.length).toBe(1);
expect(data[0].status).toBe('success');
});
it('POST /github-backup/run returns 404 when not configured', async () => {
server.use(
http.post(`${API_BASE}/github-backup/run`, () => {
return HttpResponse.json(
{ detail: 'No configuration found' },
{ status: 404 }
);
})
);
const response = await fetch(`${API_BASE}/github-backup/run`, {
method: 'POST',
});
expect(response.status).toBe(404);
});
it('POST /github-backup/test returns success on valid credentials', async () => {
server.use(
http.post(`${API_BASE}/github-backup/test`, () => {
return HttpResponse.json({
success: true,
message: 'Connection successful',
repo_name: 'test/repo',
default_branch: 'main',
});
})
);
const response = await fetch(
`${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=ghp_test`,
{ method: 'POST' }
);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.repo_name).toBe('test/repo');
});
it('POST /github-backup/test returns failure on invalid credentials', async () => {
server.use(
http.post(`${API_BASE}/github-backup/test`, () => {
return HttpResponse.json({
success: false,
message: 'Authentication failed',
repo_name: null,
default_branch: null,
});
})
);
const response = await fetch(
`${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=invalid`,
{ method: 'POST' }
);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toBe('Authentication failed');
});
});
+112
View File
@@ -1296,6 +1296,78 @@ export interface NotificationProviderUpdate {
printer_id?: number | null;
}
// GitHub Backup types
export type ScheduleType = 'hourly' | 'daily' | 'weekly';
export interface GitHubBackupConfig {
id: number;
repository_url: string;
has_token: boolean;
branch: string;
schedule_enabled: boolean;
schedule_type: ScheduleType;
backup_kprofiles: boolean;
backup_cloud_profiles: boolean;
backup_settings: boolean;
enabled: boolean;
last_backup_at: string | null;
last_backup_status: string | null;
last_backup_message: string | null;
last_backup_commit_sha: string | null;
next_scheduled_run: string | null;
created_at: string;
updated_at: string;
}
export interface GitHubBackupConfigCreate {
repository_url: string;
access_token: string;
branch?: string;
schedule_enabled?: boolean;
schedule_type?: ScheduleType;
backup_kprofiles?: boolean;
backup_cloud_profiles?: boolean;
backup_settings?: boolean;
enabled?: boolean;
}
export interface GitHubBackupLog {
id: number;
config_id: number;
started_at: string;
completed_at: string | null;
status: string;
trigger: string;
commit_sha: string | null;
files_changed: number;
error_message: string | null;
}
export interface GitHubBackupStatus {
configured: boolean;
enabled: boolean;
is_running: boolean;
progress: string | null;
last_backup_at: string | null;
last_backup_status: string | null;
next_scheduled_run: string | null;
}
export interface GitHubTestConnectionResponse {
success: boolean;
message: string;
repo_name: string | null;
permissions: Record<string, boolean> | null;
}
export interface GitHubBackupTriggerResponse {
success: boolean;
message: string;
log_id: number | null;
commit_sha: string | null;
files_changed: number;
}
export interface NotificationTestRequest {
provider_type: ProviderType;
config: Record<string, unknown>;
@@ -3038,6 +3110,46 @@ export const api = {
used_meters: number;
}>;
}>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),
// GitHub Backup
getGitHubBackupConfig: () =>
request<GitHubBackupConfig | null>('/github-backup/config'),
saveGitHubBackupConfig: (config: GitHubBackupConfigCreate) =>
request<GitHubBackupConfig>('/github-backup/config', {
method: 'POST',
body: JSON.stringify(config),
}),
updateGitHubBackupConfig: (config: Partial<GitHubBackupConfigCreate>) =>
request<GitHubBackupConfig>('/github-backup/config', {
method: 'PATCH',
body: JSON.stringify(config),
}),
deleteGitHubBackupConfig: () =>
request<{ message: string }>('/github-backup/config', { method: 'DELETE' }),
testGitHubConnection: (repoUrl: string, token: string) =>
request<GitHubTestConnectionResponse>(
`/github-backup/test?repo_url=${encodeURIComponent(repoUrl)}&token=${encodeURIComponent(token)}`,
{ method: 'POST' }
),
testGitHubStoredConnection: () =>
request<GitHubTestConnectionResponse>('/github-backup/test-stored', { method: 'POST' }),
triggerGitHubBackup: () =>
request<GitHubBackupTriggerResponse>('/github-backup/run', { method: 'POST' }),
getGitHubBackupStatus: () =>
request<GitHubBackupStatus>('/github-backup/status'),
getGitHubBackupLogs: (limit: number = 50) =>
request<GitHubBackupLog[]>(`/github-backup/logs?limit=${limit}`),
clearGitHubBackupLogs: (keepLast: number = 10) =>
request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
};
// AMS History types
@@ -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>
);
}
+55 -116
View File
@@ -1,11 +1,11 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { formatDateOnly } from '../utils/date';
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
@@ -15,11 +15,10 @@ import { AddNotificationModal } from '../components/AddNotificationModal';
import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
import { NotificationLogViewer } from '../components/NotificationLogViewer';
import { ConfirmModal } from '../components/ConfirmModal';
import { BackupModal } from '../components/BackupModal';
import { RestoreModal } from '../components/RestoreModal';
import { SpoolmanSettings } from '../components/SpoolmanSettings';
import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
import { APIBrowser } from '../components/APIBrowser';
import { virtualPrinterApi } from '../api/client';
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
@@ -29,7 +28,7 @@ import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, t
import { useState, useEffect, useRef, useCallback } from 'react';
import { Palette } from 'lucide-react';
const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users'] as const;
const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
type TabType = typeof validTabs[number];
export function SettingsPage() {
@@ -37,7 +36,7 @@ export function SettingsPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { t, i18n } = useTranslation();
const { showToast, showPersistentToast, dismissToast } = useToast();
const { showToast } = useToast();
const { authEnabled, user, refreshAuth } = useAuth();
const {
mode,
@@ -85,8 +84,6 @@ export function SettingsPage() {
const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
const [showBackupModal, setShowBackupModal] = useState(false);
const [showRestoreModal, setShowRestoreModal] = useState(false);
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false);
@@ -254,6 +251,18 @@ export function SettingsPage() {
refetchInterval: activeTab === 'network' ? 5000 : false, // Poll every 5s when on Network tab
});
// GitHub backup status for Backup tab indicator
const { data: githubBackupStatus } = useQuery<GitHubBackupStatus>({
queryKey: ['github-backup-status'],
queryFn: api.getGitHubBackupStatus,
});
// Cloud auth status for Backup tab indicator
const { data: cloudAuthStatus } = useQuery<CloudAuthStatus>({
queryKey: ['cloud-status'],
queryFn: api.getCloudStatus,
});
const applyUpdateMutation = useMutation({
mutationFn: api.applyUpdate,
onSuccess: (data) => {
@@ -694,6 +703,18 @@ export function SettingsPage() {
<span className={`w-2 h-2 rounded-full ${authEnabled ? 'bg-green-400' : 'bg-gray-500'}`} />
)}
</button>
<button
onClick={() => handleTabChange('backup')}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
activeTab === 'backup'
? 'text-bambu-green border-bambu-green'
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
}`}
>
<Database className="w-4 h-4" />
Backup
<span className={`w-2 h-2 rounded-full ${cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
</button>
</div>
{/* General Tab */}
@@ -1390,58 +1411,22 @@ export function SettingsPage() {
<h2 className="text-lg font-semibold text-white">Data Management</h2>
</CardHeader>
<CardContent className="space-y-4">
{/* Backup/Restore */}
<div className="flex items-center justify-between">
<div>
<p className="text-white">Backup Data</p>
<p className="text-white">Clear Notification Logs</p>
<p className="text-sm text-bambu-gray">
Export settings, providers, printers, and more
Delete notification logs older than 30 days
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowBackupModal(true)}
onClick={() => setShowClearLogsConfirm(true)}
>
<Download className="w-4 h-4" />
Export
<Trash2 className="w-4 h-4" />
Clear
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">Restore Backup</p>
<p className="text-sm text-bambu-gray">
Import settings from a backup file with duplicate handling options
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowRestoreModal(true)}
>
<Upload className="w-4 h-4" />
Restore
</Button>
</div>
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white">Clear Notification Logs</p>
<p className="text-sm text-bambu-gray">
Delete notification logs older than 30 days
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowClearLogsConfirm(true)}
>
<Trash2 className="w-4 h-4" />
Clear
</Button>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">Reset UI Preferences</p>
@@ -1458,6 +1443,22 @@ export function SettingsPage() {
Reset
</Button>
</div>
<div className="flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary">
<div>
<p className="text-white">Backup & Restore</p>
<p className="text-sm text-bambu-gray">
Export/import settings and configure GitHub backup
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleTabChange('backup')}
>
<Database className="w-4 h-4" />
Go to Backup
</Button>
</div>
</CardContent>
</Card>
</div>
@@ -2877,73 +2878,6 @@ export function SettingsPage() {
/>
)}
{/* Backup Modal */}
{showBackupModal && (
<BackupModal
onClose={() => setShowBackupModal(false)}
onExport={async (categories) => {
setShowBackupModal(false);
const toastId = 'backup-progress';
const includesArchives = categories.archives;
// Show persistent loading toast for archive backups (can be large)
if (includesArchives) {
showPersistentToast(toastId, t('backup.preparing', { defaultValue: 'Preparing backup...' }), 'loading');
}
try {
const { blob, filename } = await api.exportBackup(categories);
// Dismiss loading toast before download starts
if (includesArchives) {
dismissToast(toastId);
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast(t('backup.downloaded', { defaultValue: 'Backup downloaded' }), 'success');
} catch {
// Dismiss loading toast on error
if (includesArchives) {
dismissToast(toastId);
}
showToast(t('backup.failed', { defaultValue: 'Failed to create backup' }), 'error');
}
}}
/>
)}
{/* Restore Modal */}
{showRestoreModal && (
<RestoreModal
onClose={() => setShowRestoreModal(false)}
onRestore={async (file, overwrite) => {
return await api.importBackup(file, overwrite);
}}
onSuccess={() => {
// Reset local settings to force re-sync from restored data
setLocalSettings(null);
isInitialLoadRef.current = true;
// Use resetQueries to clear cached data completely
// This ensures fresh data is fetched, not stale cache
queryClient.resetQueries({ queryKey: ['settings'] });
// Invalidate other queries that may have changed
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
queryClient.invalidateQueries({ queryKey: ['external-links'] });
queryClient.invalidateQueries({ queryKey: ['printers'] });
queryClient.invalidateQueries({ queryKey: ['filaments'] });
queryClient.invalidateQueries({ queryKey: ['maintenance-types'] });
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
}}
/>
)}
{/* Release Notes Modal */}
{showReleaseNotes && updateCheck?.release_notes && (
<div
@@ -3197,6 +3131,11 @@ export function SettingsPage() {
</div>
)}
{/* Backup Tab */}
{activeTab === 'backup' && (
<GitHubBackupSettings />
)}
{/* Disable Authentication Confirmation Modal */}
{showDisableAuthConfirm && (
<ConfirmModal