mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 20:22:15 +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:
@@ -5,6 +5,17 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.1.6] - Not released
|
||||
|
||||
### New Features
|
||||
- **GitHub Profile Backup** - Automatically backup your Cloud profiles, K-profiles and settings to a GitHub repository:
|
||||
- Configure GitHub repository URL and Personal Access Token
|
||||
- Schedule backups hourly, daily, or weekly
|
||||
- Manual on-demand backup trigger
|
||||
- Backs up K-profiles (per-printer), cloud profiles, and app settings
|
||||
- Skip unchanged commits (only creates commit when data changes)
|
||||
- Real-time progress tracking during backup
|
||||
- Backup history log with status and commit links
|
||||
- Requires Bambu Cloud login for full profile access
|
||||
- New Settings → Backup & Restore tab (local backup/restore moved here)
|
||||
- Included in local backup/restore (except PAT for security)
|
||||
- **Plate Not Empty Notification** - Dedicated notification category for build plate detection:
|
||||
- New toggle in notification provider settings (enabled by default)
|
||||
- Sends immediately (bypasses quiet hours and digest mode)
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
- MQTT publishing for Home Assistant, Node-RED, etc.
|
||||
- Bambu Cloud profile management
|
||||
- K-profiles (pressure advance)
|
||||
- **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
|
||||
- External sidebar links
|
||||
- Webhooks & API keys
|
||||
- Interactive API browser with live testing
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""API routes for GitHub profile backup."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.schemas.github_backup import (
|
||||
GitHubBackupConfigCreate,
|
||||
GitHubBackupConfigResponse,
|
||||
GitHubBackupConfigUpdate,
|
||||
GitHubBackupLogResponse,
|
||||
GitHubBackupStatus,
|
||||
GitHubBackupTriggerResponse,
|
||||
GitHubTestConnectionResponse,
|
||||
)
|
||||
from backend.app.services.github_backup import github_backup_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/github-backup", tags=["github-backup"])
|
||||
|
||||
|
||||
def _config_to_response(config: GitHubBackupConfig) -> dict:
|
||||
"""Convert config model to response dict."""
|
||||
return {
|
||||
"id": config.id,
|
||||
"repository_url": config.repository_url,
|
||||
"has_token": bool(config.access_token),
|
||||
"branch": config.branch,
|
||||
"schedule_enabled": config.schedule_enabled,
|
||||
"schedule_type": config.schedule_type,
|
||||
"backup_kprofiles": config.backup_kprofiles,
|
||||
"backup_cloud_profiles": config.backup_cloud_profiles,
|
||||
"backup_settings": config.backup_settings,
|
||||
"enabled": config.enabled,
|
||||
"last_backup_at": config.last_backup_at,
|
||||
"last_backup_status": config.last_backup_status,
|
||||
"last_backup_message": config.last_backup_message,
|
||||
"last_backup_commit_sha": config.last_backup_commit_sha,
|
||||
"next_scheduled_run": config.next_scheduled_run,
|
||||
"created_at": config.created_at,
|
||||
"updated_at": config.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config", response_model=GitHubBackupConfigResponse | None)
|
||||
async def get_config(db: AsyncSession = Depends(get_db)):
|
||||
"""Get the current GitHub backup configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return None
|
||||
|
||||
return _config_to_response(config)
|
||||
|
||||
|
||||
@router.post("/config", response_model=GitHubBackupConfigResponse)
|
||||
async def save_config(
|
||||
config_data: GitHubBackupConfigCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create or update GitHub backup configuration.
|
||||
|
||||
Only one configuration is supported. If one exists, it will be updated.
|
||||
"""
|
||||
# Check for existing config
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
# Update existing
|
||||
config.repository_url = config_data.repository_url
|
||||
config.access_token = config_data.access_token
|
||||
config.branch = config_data.branch
|
||||
config.schedule_enabled = config_data.schedule_enabled
|
||||
config.schedule_type = config_data.schedule_type.value
|
||||
config.backup_kprofiles = config_data.backup_kprofiles
|
||||
config.backup_cloud_profiles = config_data.backup_cloud_profiles
|
||||
config.backup_settings = config_data.backup_settings
|
||||
config.enabled = config_data.enabled
|
||||
|
||||
# Calculate next scheduled run if enabled
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
|
||||
else:
|
||||
config.next_scheduled_run = None
|
||||
|
||||
logger.info(f"Updated GitHub backup config: {config.repository_url}")
|
||||
else:
|
||||
# Create new
|
||||
config = GitHubBackupConfig(
|
||||
repository_url=config_data.repository_url,
|
||||
access_token=config_data.access_token,
|
||||
branch=config_data.branch,
|
||||
schedule_enabled=config_data.schedule_enabled,
|
||||
schedule_type=config_data.schedule_type.value,
|
||||
backup_kprofiles=config_data.backup_kprofiles,
|
||||
backup_cloud_profiles=config_data.backup_cloud_profiles,
|
||||
backup_settings=config_data.backup_settings,
|
||||
enabled=config_data.enabled,
|
||||
)
|
||||
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
|
||||
|
||||
db.add(config)
|
||||
logger.info(f"Created GitHub backup config: {config.repository_url}")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
|
||||
return _config_to_response(config)
|
||||
|
||||
|
||||
@router.patch("/config", response_model=GitHubBackupConfigResponse)
|
||||
async def update_config(
|
||||
update_data: GitHubBackupConfigUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Partially update GitHub backup configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="No configuration found")
|
||||
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
for key, value in update_dict.items():
|
||||
if key == "schedule_type" and value is not None:
|
||||
setattr(config, key, value.value)
|
||||
else:
|
||||
setattr(config, key, value)
|
||||
|
||||
# Recalculate next scheduled run if schedule settings changed
|
||||
if "schedule_enabled" in update_dict or "schedule_type" in update_dict:
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
|
||||
else:
|
||||
config.next_scheduled_run = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
|
||||
logger.info(f"Updated GitHub backup config: {config.repository_url}")
|
||||
|
||||
return _config_to_response(config)
|
||||
|
||||
|
||||
@router.delete("/config")
|
||||
async def delete_config(db: AsyncSession = Depends(get_db)):
|
||||
"""Delete the GitHub backup configuration and all logs."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="No configuration found")
|
||||
|
||||
await db.delete(config)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Deleted GitHub backup config")
|
||||
|
||||
return {"message": "Configuration deleted"}
|
||||
|
||||
|
||||
@router.post("/test", response_model=GitHubTestConnectionResponse)
|
||||
async def test_connection(
|
||||
repo_url: str = Query(..., description="GitHub repository URL"),
|
||||
token: str = Query(..., description="Personal Access Token"),
|
||||
):
|
||||
"""Test GitHub connection with provided credentials."""
|
||||
result = await github_backup_service.test_connection(repo_url, token)
|
||||
return GitHubTestConnectionResponse(**result)
|
||||
|
||||
|
||||
@router.post("/test-stored", response_model=GitHubTestConnectionResponse)
|
||||
async def test_stored_connection(db: AsyncSession = Depends(get_db)):
|
||||
"""Test GitHub connection using stored configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="No configuration found")
|
||||
|
||||
if not config.access_token:
|
||||
raise HTTPException(status_code=400, detail="No access token configured")
|
||||
|
||||
test_result = await github_backup_service.test_connection(config.repository_url, config.access_token)
|
||||
return GitHubTestConnectionResponse(**test_result)
|
||||
|
||||
|
||||
@router.post("/run", response_model=GitHubBackupTriggerResponse)
|
||||
async def trigger_backup(db: AsyncSession = Depends(get_db)):
|
||||
"""Manually trigger a backup."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
|
||||
|
||||
if not config.enabled:
|
||||
raise HTTPException(status_code=400, detail="Backup is disabled")
|
||||
|
||||
backup_result = await github_backup_service.run_backup(config.id, trigger="manual")
|
||||
|
||||
return GitHubBackupTriggerResponse(**backup_result)
|
||||
|
||||
|
||||
@router.get("/status", response_model=GitHubBackupStatus)
|
||||
async def get_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Get current backup status."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return GitHubBackupStatus(
|
||||
configured=False,
|
||||
enabled=False,
|
||||
is_running=False,
|
||||
progress=None,
|
||||
last_backup_at=None,
|
||||
last_backup_status=None,
|
||||
next_scheduled_run=None,
|
||||
)
|
||||
|
||||
return GitHubBackupStatus(
|
||||
configured=True,
|
||||
enabled=config.enabled,
|
||||
is_running=github_backup_service.is_running,
|
||||
progress=github_backup_service.progress,
|
||||
last_backup_at=config.last_backup_at,
|
||||
last_backup_status=config.last_backup_status,
|
||||
next_scheduled_run=config.next_scheduled_run,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=list[GitHubBackupLogResponse])
|
||||
async def get_logs(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get backup logs."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return []
|
||||
|
||||
logs_result = await db.execute(
|
||||
select(GitHubBackupLog)
|
||||
.where(GitHubBackupLog.config_id == config.id)
|
||||
.order_by(desc(GitHubBackupLog.started_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
logs = logs_result.scalars().all()
|
||||
|
||||
return [
|
||||
GitHubBackupLogResponse(
|
||||
id=log.id,
|
||||
config_id=log.config_id,
|
||||
started_at=log.started_at,
|
||||
completed_at=log.completed_at,
|
||||
status=log.status,
|
||||
trigger=log.trigger,
|
||||
commit_sha=log.commit_sha,
|
||||
files_changed=log.files_changed,
|
||||
error_message=log.error_message,
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/logs")
|
||||
async def clear_logs(
|
||||
keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Clear backup logs, optionally keeping the most recent entries."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return {"deleted": 0, "message": "No configuration found"}
|
||||
|
||||
if keep_last > 0:
|
||||
# Get IDs to keep
|
||||
keep_result = await db.execute(
|
||||
select(GitHubBackupLog.id)
|
||||
.where(GitHubBackupLog.config_id == config.id)
|
||||
.order_by(desc(GitHubBackupLog.started_at))
|
||||
.limit(keep_last)
|
||||
)
|
||||
keep_ids = [row[0] for row in keep_result.fetchall()]
|
||||
|
||||
if keep_ids:
|
||||
delete_result = await db.execute(
|
||||
delete(GitHubBackupLog).where(
|
||||
GitHubBackupLog.config_id == config.id, GitHubBackupLog.id.not_in(keep_ids)
|
||||
)
|
||||
)
|
||||
else:
|
||||
delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
|
||||
else:
|
||||
delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
|
||||
|
||||
await db.commit()
|
||||
|
||||
deleted_count = delete_result.rowcount
|
||||
logger.info(f"Deleted {deleted_count} GitHub backup logs (kept {keep_last})")
|
||||
|
||||
return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs"}
|
||||
@@ -15,6 +15,7 @@ from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.external_link import ExternalLink
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.github_backup import GitHubBackupConfig
|
||||
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
|
||||
from backend.app.models.notification import NotificationProvider
|
||||
from backend.app.models.notification_template import NotificationTemplate
|
||||
@@ -256,6 +257,7 @@ async def export_backup(
|
||||
include_users: bool = Query(
|
||||
False, description="Include users (passwords not exported - users will need new passwords)"
|
||||
),
|
||||
include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"),
|
||||
):
|
||||
"""Export selected data as JSON backup."""
|
||||
backup: dict = {
|
||||
@@ -854,6 +856,24 @@ async def export_backup(
|
||||
)
|
||||
backup["included"].append("users")
|
||||
|
||||
# GitHub backup configuration
|
||||
if include_github_backup:
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
if config:
|
||||
backup["github_backup"] = {
|
||||
"repository_url": config.repository_url,
|
||||
# access_token intentionally not exported for security
|
||||
"branch": config.branch,
|
||||
"schedule_enabled": config.schedule_enabled,
|
||||
"schedule_type": config.schedule_type,
|
||||
"backup_kprofiles": config.backup_kprofiles,
|
||||
"backup_cloud_profiles": config.backup_cloud_profiles,
|
||||
"backup_settings": config.backup_settings,
|
||||
"enabled": config.enabled,
|
||||
}
|
||||
backup["included"].append("github_backup")
|
||||
|
||||
# If there are files to include (icons or archives), create ZIP file
|
||||
if backup_files:
|
||||
zip_buffer = io.BytesIO()
|
||||
@@ -953,6 +973,7 @@ async def import_backup(
|
||||
"projects": 0,
|
||||
"pending_uploads": 0,
|
||||
"users": 0,
|
||||
"github_backup": 0,
|
||||
}
|
||||
skipped = {
|
||||
"settings": 0,
|
||||
@@ -967,6 +988,7 @@ async def import_backup(
|
||||
"projects": 0,
|
||||
"pending_uploads": 0,
|
||||
"users": 0,
|
||||
"github_backup": 0,
|
||||
}
|
||||
skipped_details = {
|
||||
"notification_providers": [],
|
||||
@@ -1966,6 +1988,42 @@ async def import_backup(
|
||||
restored["users"] += 1
|
||||
new_users.append(f"{user_data['username']} (temp password: {temp_password})")
|
||||
|
||||
# Restore GitHub backup configuration (note: access_token not included for security)
|
||||
if "github_backup" in backup:
|
||||
github_data = backup["github_backup"]
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
if overwrite:
|
||||
existing.repository_url = github_data.get("repository_url", existing.repository_url)
|
||||
existing.branch = github_data.get("branch", existing.branch)
|
||||
existing.schedule_enabled = github_data.get("schedule_enabled", existing.schedule_enabled)
|
||||
existing.schedule_type = github_data.get("schedule_type", existing.schedule_type)
|
||||
existing.backup_kprofiles = github_data.get("backup_kprofiles", existing.backup_kprofiles)
|
||||
existing.backup_cloud_profiles = github_data.get(
|
||||
"backup_cloud_profiles", existing.backup_cloud_profiles
|
||||
)
|
||||
existing.backup_settings = github_data.get("backup_settings", existing.backup_settings)
|
||||
existing.enabled = github_data.get("enabled", existing.enabled)
|
||||
# Note: access_token must be re-entered after restore
|
||||
restored["github_backup"] += 1
|
||||
else:
|
||||
skipped["github_backup"] += 1
|
||||
else:
|
||||
config = GitHubBackupConfig(
|
||||
repository_url=github_data.get("repository_url", ""),
|
||||
access_token="", # Must be entered after restore
|
||||
branch=github_data.get("branch", "main"),
|
||||
schedule_enabled=github_data.get("schedule_enabled", False),
|
||||
schedule_type=github_data.get("schedule_type", "daily"),
|
||||
backup_kprofiles=github_data.get("backup_kprofiles", True),
|
||||
backup_cloud_profiles=github_data.get("backup_cloud_profiles", True),
|
||||
backup_settings=github_data.get("backup_settings", False),
|
||||
enabled=False, # Disabled until token is entered
|
||||
)
|
||||
db.add(config)
|
||||
restored["github_backup"] += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
# If printers were in the backup (restored, updated, or skipped), reconnect all active printers
|
||||
|
||||
@@ -38,6 +38,7 @@ async def init_db():
|
||||
archive,
|
||||
external_link,
|
||||
filament,
|
||||
github_backup,
|
||||
kprofile_note,
|
||||
library,
|
||||
maintenance,
|
||||
|
||||
@@ -182,6 +182,7 @@ from backend.app.api.routes import (
|
||||
external_links,
|
||||
filaments,
|
||||
firmware,
|
||||
github_backup,
|
||||
kprofiles,
|
||||
library,
|
||||
maintenance,
|
||||
@@ -209,6 +210,7 @@ from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.services.archive import ArchiveService
|
||||
from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
|
||||
from backend.app.services.bambu_mqtt import PrinterState
|
||||
from backend.app.services.github_backup import github_backup_service
|
||||
from backend.app.services.homeassistant import homeassistant_service
|
||||
from backend.app.services.mqtt_relay import mqtt_relay
|
||||
from backend.app.services.notification_service import notification_service
|
||||
@@ -2383,6 +2385,9 @@ async def lifespan(app: FastAPI):
|
||||
# Start the notification digest scheduler
|
||||
notification_service.start_digest_scheduler()
|
||||
|
||||
# Start the GitHub backup scheduler
|
||||
await github_backup_service.start_scheduler()
|
||||
|
||||
# Start AMS history recording
|
||||
start_ams_history_recording()
|
||||
|
||||
@@ -2422,6 +2427,7 @@ async def lifespan(app: FastAPI):
|
||||
print_scheduler.stop()
|
||||
smart_plug_manager.stop_scheduler()
|
||||
notification_service.stop_digest_scheduler()
|
||||
github_backup_service.stop_scheduler()
|
||||
stop_ams_history_recording()
|
||||
stop_runtime_tracking()
|
||||
printer_manager.disconnect_all()
|
||||
@@ -2468,6 +2474,7 @@ app.include_router(websocket.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(discovery.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(firmware.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(github_backup.router, prefix=app_settings.api_prefix)
|
||||
|
||||
|
||||
# Serve static files (React build)
|
||||
|
||||
@@ -2,6 +2,7 @@ from backend.app.models.ams_history import AMSSensorHistory
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.models.kprofile_note import KProfileNote
|
||||
from backend.app.models.library import LibraryFile, LibraryFolder
|
||||
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
|
||||
@@ -33,4 +34,6 @@ __all__ = [
|
||||
"LibraryFolder",
|
||||
"LibraryFile",
|
||||
"User",
|
||||
"GitHubBackupConfig",
|
||||
"GitHubBackupLog",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""GitHub backup configuration and log models."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class GitHubBackupConfig(Base):
|
||||
"""Configuration for GitHub profile backup."""
|
||||
|
||||
__tablename__ = "github_backup_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
repository_url: Mapped[str] = mapped_column(String(500)) # Full GitHub URL
|
||||
access_token: Mapped[str] = mapped_column(Text) # Personal Access Token
|
||||
branch: Mapped[str] = mapped_column(String(100), default="main")
|
||||
|
||||
# Schedule configuration
|
||||
schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
schedule_type: Mapped[str] = mapped_column(String(20), default="daily") # hourly/daily/weekly
|
||||
schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True) # For future cron support
|
||||
|
||||
# What to backup
|
||||
backup_kprofiles: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
backup_cloud_profiles: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
backup_settings: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Status tracking
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_backup_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_backup_status: Mapped[str | None] = mapped_column(String(20), nullable=True) # success/failed/skipped
|
||||
last_backup_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_backup_commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
next_scheduled_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
logs: Mapped[list["GitHubBackupLog"]] = relationship(back_populates="config", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class GitHubBackupLog(Base):
|
||||
"""Log entry for GitHub backup runs."""
|
||||
|
||||
__tablename__ = "github_backup_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
config_id: Mapped[int] = mapped_column(ForeignKey("github_backup_config.id", ondelete="CASCADE"))
|
||||
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20)) # running/success/failed/skipped
|
||||
trigger: Mapped[str] = mapped_column(String(20)) # manual/scheduled
|
||||
|
||||
commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
files_changed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
config: Mapped["GitHubBackupConfig"] = relationship(back_populates="logs")
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Pydantic schemas for GitHub backup configuration."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ScheduleType(str, Enum):
|
||||
"""Backup schedule types."""
|
||||
|
||||
HOURLY = "hourly"
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
|
||||
|
||||
class GitHubBackupConfigCreate(BaseModel):
|
||||
"""Schema for creating/updating GitHub backup config."""
|
||||
|
||||
repository_url: str = Field(..., min_length=1, max_length=500, description="GitHub repository URL")
|
||||
access_token: str = Field(..., min_length=1, description="Personal Access Token")
|
||||
branch: str = Field(default="main", max_length=100, description="Branch to push to")
|
||||
|
||||
schedule_enabled: bool = Field(default=False, description="Enable scheduled backups")
|
||||
schedule_type: ScheduleType = Field(default=ScheduleType.DAILY, description="Schedule frequency")
|
||||
|
||||
backup_kprofiles: bool = Field(default=True, description="Backup K-profiles")
|
||||
backup_cloud_profiles: bool = Field(default=True, description="Backup Bambu Cloud profiles")
|
||||
backup_settings: bool = Field(default=False, description="Backup app settings")
|
||||
|
||||
enabled: bool = Field(default=True, description="Enable backup feature")
|
||||
|
||||
@field_validator("repository_url")
|
||||
@classmethod
|
||||
def validate_repo_url(cls, v: str) -> str:
|
||||
"""Validate GitHub repository URL format."""
|
||||
# Accept various GitHub URL formats
|
||||
patterns = [
|
||||
r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
]
|
||||
v = v.strip().rstrip("/")
|
||||
if not any(re.match(p, v) for p in patterns):
|
||||
raise ValueError("Invalid GitHub repository URL. Expected format: https://github.com/owner/repo")
|
||||
return v
|
||||
|
||||
|
||||
class GitHubBackupConfigUpdate(BaseModel):
|
||||
"""Schema for updating GitHub backup config (all fields optional)."""
|
||||
|
||||
repository_url: str | None = Field(default=None, max_length=500)
|
||||
access_token: str | None = Field(default=None)
|
||||
branch: str | None = Field(default=None, max_length=100)
|
||||
|
||||
schedule_enabled: bool | None = None
|
||||
schedule_type: ScheduleType | None = None
|
||||
|
||||
backup_kprofiles: bool | None = None
|
||||
backup_cloud_profiles: bool | None = None
|
||||
backup_settings: bool | None = None
|
||||
|
||||
enabled: bool | None = None
|
||||
|
||||
@field_validator("repository_url")
|
||||
@classmethod
|
||||
def validate_repo_url(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
patterns = [
|
||||
r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
]
|
||||
v = v.strip().rstrip("/")
|
||||
if not any(re.match(p, v) for p in patterns):
|
||||
raise ValueError("Invalid GitHub repository URL")
|
||||
return v
|
||||
|
||||
|
||||
class GitHubBackupConfigResponse(BaseModel):
|
||||
"""Schema for GitHub backup config API response."""
|
||||
|
||||
id: int
|
||||
repository_url: str
|
||||
has_token: bool = Field(description="Whether an access token is configured")
|
||||
branch: str
|
||||
|
||||
schedule_enabled: bool
|
||||
schedule_type: str
|
||||
|
||||
backup_kprofiles: bool
|
||||
backup_cloud_profiles: bool
|
||||
backup_settings: bool
|
||||
|
||||
enabled: bool
|
||||
last_backup_at: datetime | None
|
||||
last_backup_status: str | None
|
||||
last_backup_message: str | None
|
||||
last_backup_commit_sha: str | None
|
||||
next_scheduled_run: datetime | None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GitHubBackupLogResponse(BaseModel):
|
||||
"""Schema for backup log API response."""
|
||||
|
||||
id: int
|
||||
config_id: int
|
||||
started_at: datetime
|
||||
completed_at: datetime | None
|
||||
status: str
|
||||
trigger: str
|
||||
commit_sha: str | None
|
||||
files_changed: int
|
||||
error_message: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GitHubBackupStatus(BaseModel):
|
||||
"""Schema for current backup status."""
|
||||
|
||||
configured: bool = Field(description="Whether backup is configured")
|
||||
enabled: bool = Field(description="Whether backup is enabled")
|
||||
is_running: bool = Field(description="Whether a backup is currently running")
|
||||
progress: str | None = Field(default=None, description="Current backup progress message")
|
||||
last_backup_at: datetime | None
|
||||
last_backup_status: str | None
|
||||
next_scheduled_run: datetime | None
|
||||
|
||||
|
||||
class GitHubTestConnectionResponse(BaseModel):
|
||||
"""Schema for test connection response."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
repo_name: str | None = None
|
||||
permissions: dict | None = None
|
||||
|
||||
|
||||
class GitHubBackupTriggerResponse(BaseModel):
|
||||
"""Schema for manual backup trigger response."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
log_id: int | None = None
|
||||
commit_sha: str | None = None
|
||||
files_changed: int = 0
|
||||
@@ -0,0 +1,734 @@
|
||||
"""GitHub backup service for printer profiles.
|
||||
|
||||
Handles scheduled and on-demand backups of K-profiles and cloud profiles to GitHub.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.services.bambu_cloud import get_cloud_service
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Schedule intervals in seconds
|
||||
SCHEDULE_INTERVALS = {
|
||||
"hourly": 3600,
|
||||
"daily": 86400,
|
||||
"weekly": 604800,
|
||||
}
|
||||
|
||||
|
||||
class GitHubBackupService:
|
||||
"""Service for backing up profiles to GitHub."""
|
||||
|
||||
def __init__(self):
|
||||
self._scheduler_task: asyncio.Task | None = None
|
||||
self._check_interval = 60 # Check every minute for scheduled runs
|
||||
self._running_backup: bool = False
|
||||
self._backup_progress: str | None = None
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client."""
|
||||
if self._http_client is None or self._http_client.is_closed:
|
||||
self._http_client = httpx.AsyncClient(timeout=60.0)
|
||||
return self._http_client
|
||||
|
||||
async def start_scheduler(self):
|
||||
"""Start the background scheduler loop."""
|
||||
if self._scheduler_task is not None:
|
||||
return
|
||||
logger.info("Starting GitHub backup scheduler")
|
||||
self._scheduler_task = asyncio.create_task(self._scheduler_loop())
|
||||
|
||||
def stop_scheduler(self):
|
||||
"""Stop the scheduler."""
|
||||
if self._scheduler_task:
|
||||
self._scheduler_task.cancel()
|
||||
self._scheduler_task = None
|
||||
logger.info("Stopped GitHub backup scheduler")
|
||||
|
||||
async def _scheduler_loop(self):
|
||||
"""Main scheduler loop - checks for due backups."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self._check_interval)
|
||||
await self._check_scheduled_backups()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in GitHub backup scheduler: {e}")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
async def _check_scheduled_backups(self):
|
||||
"""Check if any scheduled backups are due."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(GitHubBackupConfig).where(
|
||||
GitHubBackupConfig.enabled == True, # noqa: E712
|
||||
GitHubBackupConfig.schedule_enabled == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
configs = result.scalars().all()
|
||||
|
||||
now = datetime.now(UTC)
|
||||
for config in configs:
|
||||
if config.next_scheduled_run and config.next_scheduled_run <= now:
|
||||
logger.info(f"Running scheduled backup for config {config.id}")
|
||||
await self.run_backup(config.id, trigger="scheduled")
|
||||
|
||||
def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
|
||||
"""Calculate the next scheduled run time."""
|
||||
now = from_time or datetime.now(UTC)
|
||||
interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS["daily"])
|
||||
return now + timedelta(seconds=interval)
|
||||
|
||||
async def test_connection(self, repo_url: str, token: str) -> dict:
|
||||
"""Test GitHub connection and permissions.
|
||||
|
||||
Args:
|
||||
repo_url: GitHub repository URL
|
||||
token: Personal Access Token
|
||||
|
||||
Returns:
|
||||
dict with success, message, repo_name, permissions
|
||||
"""
|
||||
try:
|
||||
owner, repo = self._parse_repo_url(repo_url)
|
||||
client = await self._get_client()
|
||||
|
||||
# Test API access
|
||||
response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "Bambuddy-Backup",
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
|
||||
|
||||
if response.status_code == 404:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Repository not found. Check URL and token permissions.",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"GitHub API error: {response.status_code}",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
data = response.json()
|
||||
permissions = data.get("permissions", {})
|
||||
|
||||
# Check for push permission
|
||||
if not permissions.get("push", False):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Token does not have push permission to this repository",
|
||||
"repo_name": data.get("full_name"),
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Connection successful",
|
||||
"repo_name": data.get("full_name"),
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"GitHub connection test failed: {e}")
|
||||
return {"success": False, "message": str(e), "repo_name": None, "permissions": None}
|
||||
|
||||
def _parse_repo_url(self, url: str) -> tuple[str, str]:
|
||||
"""Parse owner and repo from GitHub URL."""
|
||||
# Handle HTTPS URLs
|
||||
match = re.match(r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$", url)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
# Handle SSH URLs
|
||||
match = re.match(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", url)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
raise ValueError(f"Invalid GitHub URL: {url}")
|
||||
|
||||
async def run_backup(self, config_id: int, trigger: str = "manual") -> dict:
|
||||
"""Run a backup operation.
|
||||
|
||||
Args:
|
||||
config_id: ID of the backup configuration
|
||||
trigger: "manual" or "scheduled"
|
||||
|
||||
Returns:
|
||||
dict with success, message, log_id, commit_sha, files_changed
|
||||
"""
|
||||
if self._running_backup:
|
||||
return {"success": False, "message": "A backup is already running", "log_id": None}
|
||||
|
||||
self._running_backup = True
|
||||
log_id = None
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
# Get config
|
||||
result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return {"success": False, "message": "Configuration not found", "log_id": None}
|
||||
|
||||
if not config.enabled:
|
||||
return {"success": False, "message": "Backup is disabled", "log_id": None}
|
||||
|
||||
# Create log entry
|
||||
log = GitHubBackupLog(config_id=config_id, status="running", trigger=trigger)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
log_id = log.id
|
||||
|
||||
try:
|
||||
# Collect backup data
|
||||
self._backup_progress = "Collecting profiles..."
|
||||
backup_data = await self._collect_backup_data(db, config)
|
||||
|
||||
if not backup_data:
|
||||
# No data to backup
|
||||
log.status = "skipped"
|
||||
log.completed_at = datetime.now(UTC)
|
||||
log.error_message = "No data to backup"
|
||||
config.last_backup_at = datetime.now(UTC)
|
||||
config.last_backup_status = "skipped"
|
||||
config.last_backup_message = "No data to backup"
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
|
||||
await db.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"message": "No data to backup",
|
||||
"log_id": log_id,
|
||||
"commit_sha": None,
|
||||
"files_changed": 0,
|
||||
}
|
||||
|
||||
# Push to GitHub
|
||||
self._backup_progress = "Pushing to GitHub..."
|
||||
push_result = await self._push_to_github(config, backup_data)
|
||||
|
||||
# Update log and config
|
||||
log.status = push_result["status"]
|
||||
log.completed_at = datetime.now(UTC)
|
||||
log.commit_sha = push_result.get("commit_sha")
|
||||
log.files_changed = push_result.get("files_changed", 0)
|
||||
log.error_message = push_result.get("error")
|
||||
|
||||
config.last_backup_at = datetime.now(UTC)
|
||||
config.last_backup_status = push_result["status"]
|
||||
config.last_backup_message = push_result.get("message", "")
|
||||
config.last_backup_commit_sha = push_result.get("commit_sha")
|
||||
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"success": push_result["status"] in ("success", "skipped"),
|
||||
"message": push_result.get("message", "Backup completed"),
|
||||
"log_id": log_id,
|
||||
"commit_sha": push_result.get("commit_sha"),
|
||||
"files_changed": push_result.get("files_changed", 0),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Backup failed: {e}")
|
||||
log.status = "failed"
|
||||
log.completed_at = datetime.now(UTC)
|
||||
log.error_message = str(e)
|
||||
|
||||
config.last_backup_at = datetime.now(UTC)
|
||||
config.last_backup_status = "failed"
|
||||
config.last_backup_message = str(e)
|
||||
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"log_id": log_id,
|
||||
"commit_sha": None,
|
||||
"files_changed": 0,
|
||||
}
|
||||
|
||||
finally:
|
||||
self._running_backup = False
|
||||
self._backup_progress = None
|
||||
|
||||
async def _collect_backup_data(self, db: AsyncSession, config: GitHubBackupConfig) -> dict:
|
||||
"""Collect data to backup based on config settings.
|
||||
|
||||
Returns dict with structure:
|
||||
{
|
||||
"backup_metadata.json": {...},
|
||||
"kprofiles/{serial}/{nozzle}.json": {...},
|
||||
"cloud_profiles/filament.json": [...],
|
||||
"cloud_profiles/printer.json": [...],
|
||||
"cloud_profiles/process.json": [...],
|
||||
"settings/app_settings.json": {...},
|
||||
}
|
||||
"""
|
||||
files: dict[str, dict | list] = {}
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Metadata file
|
||||
metadata = {
|
||||
"version": "1.0",
|
||||
"backup_type": "bambuddy_profiles",
|
||||
"created_at": now.isoformat(),
|
||||
"contents": {
|
||||
"kprofiles": config.backup_kprofiles,
|
||||
"cloud_profiles": config.backup_cloud_profiles,
|
||||
"settings": config.backup_settings,
|
||||
},
|
||||
}
|
||||
files["backup_metadata.json"] = metadata
|
||||
|
||||
# Collect K-profiles from all connected printers
|
||||
if config.backup_kprofiles:
|
||||
self._backup_progress = "Collecting K-profiles from printers..."
|
||||
await self._collect_kprofiles(db, files)
|
||||
|
||||
# Collect cloud profiles
|
||||
if config.backup_cloud_profiles:
|
||||
self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
|
||||
await self._collect_cloud_profiles(db, files)
|
||||
|
||||
# Collect app settings
|
||||
if config.backup_settings:
|
||||
self._backup_progress = "Collecting app settings..."
|
||||
await self._collect_settings(db, files)
|
||||
|
||||
return files
|
||||
|
||||
async def _collect_kprofiles(self, db: AsyncSession, files: dict):
|
||||
"""Collect K-profiles from all connected printers."""
|
||||
result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712
|
||||
printers = result.scalars().all()
|
||||
|
||||
nozzle_diameters = ["0.2", "0.4", "0.6", "0.8"]
|
||||
|
||||
for printer in printers:
|
||||
client = printer_manager.get_client(printer.id)
|
||||
if not client or not client.state.connected:
|
||||
continue
|
||||
|
||||
serial = printer.serial_number
|
||||
printer_profiles = {}
|
||||
|
||||
for nozzle in nozzle_diameters:
|
||||
try:
|
||||
profiles = await client.get_kprofiles(nozzle_diameter=nozzle)
|
||||
if profiles:
|
||||
profile_data = {
|
||||
"version": "1.0",
|
||||
"printer_name": printer.name,
|
||||
"printer_serial": serial,
|
||||
"nozzle_diameter": nozzle,
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"profiles": [
|
||||
{
|
||||
"slot_id": p.slot_id,
|
||||
"name": p.name,
|
||||
"k_value": p.k_value,
|
||||
"filament_id": p.filament_id,
|
||||
"nozzle_id": p.nozzle_id,
|
||||
"extruder_id": p.extruder_id,
|
||||
"setting_id": p.setting_id,
|
||||
"n_coef": p.n_coef,
|
||||
}
|
||||
for p in profiles
|
||||
],
|
||||
}
|
||||
files[f"kprofiles/{serial}/{nozzle}.json"] = profile_data
|
||||
printer_profiles[nozzle] = len(profiles)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get K-profiles for printer {serial} nozzle {nozzle}: {e}")
|
||||
|
||||
if printer_profiles:
|
||||
logger.info(f"Collected K-profiles for {serial}: {printer_profiles}")
|
||||
|
||||
async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
|
||||
"""Collect Bambu Cloud profiles if authenticated."""
|
||||
# Check if cloud is authenticated
|
||||
cloud = get_cloud_service()
|
||||
|
||||
# Try to restore token from DB
|
||||
result = await db.execute(select(Settings).where(Settings.key == "bambu_cloud_token"))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting and setting.value:
|
||||
cloud.set_token(setting.value)
|
||||
|
||||
if not cloud.is_authenticated:
|
||||
logger.info("Cloud not authenticated, skipping cloud profiles")
|
||||
return
|
||||
|
||||
try:
|
||||
settings = await cloud.get_slicer_settings()
|
||||
if not settings:
|
||||
return
|
||||
|
||||
# Separate by type
|
||||
filament_settings = []
|
||||
printer_settings = []
|
||||
process_settings = []
|
||||
|
||||
for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
|
||||
setting_type = setting.get("type", "")
|
||||
if setting_type == "filament":
|
||||
filament_settings.append(setting)
|
||||
elif setting_type == "printer":
|
||||
printer_settings.append(setting)
|
||||
elif setting_type == "process":
|
||||
process_settings.append(setting)
|
||||
|
||||
if filament_settings:
|
||||
files["cloud_profiles/filament.json"] = {
|
||||
"version": "1.0",
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"profiles": filament_settings,
|
||||
}
|
||||
|
||||
if printer_settings:
|
||||
files["cloud_profiles/printer.json"] = {
|
||||
"version": "1.0",
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"profiles": printer_settings,
|
||||
}
|
||||
|
||||
if process_settings:
|
||||
files["cloud_profiles/process.json"] = {
|
||||
"version": "1.0",
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"profiles": process_settings,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Collected cloud profiles: {len(filament_settings)} filament, "
|
||||
f"{len(printer_settings)} printer, {len(process_settings)} process"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect cloud profiles: {e}")
|
||||
|
||||
async def _collect_settings(self, db: AsyncSession, files: dict):
|
||||
"""Collect app settings."""
|
||||
result = await db.execute(select(Settings))
|
||||
settings = result.scalars().all()
|
||||
|
||||
# Filter out sensitive settings
|
||||
sensitive_keys = {"bambu_cloud_token", "auth_secret_key"}
|
||||
settings_data = {s.key: s.value for s in settings if s.key not in sensitive_keys}
|
||||
|
||||
files["settings/app_settings.json"] = {
|
||||
"version": "1.0",
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"settings": settings_data,
|
||||
}
|
||||
|
||||
async def _push_to_github(self, config: GitHubBackupConfig, files: dict) -> dict:
|
||||
"""Push files to GitHub using the GitHub API.
|
||||
|
||||
Uses the Git Data API to create blobs, tree, and commit.
|
||||
|
||||
Returns:
|
||||
dict with status, message, commit_sha, files_changed
|
||||
"""
|
||||
try:
|
||||
owner, repo = self._parse_repo_url(config.repository_url)
|
||||
branch = config.branch
|
||||
client = await self._get_client()
|
||||
headers = {
|
||||
"Authorization": f"token {config.access_token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "Bambuddy-Backup",
|
||||
}
|
||||
|
||||
# Get current branch reference
|
||||
ref_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers
|
||||
)
|
||||
|
||||
if ref_response.status_code == 404:
|
||||
# Branch doesn't exist, need to create it from default branch
|
||||
return await self._create_branch_and_push(client, headers, owner, repo, branch, files)
|
||||
|
||||
if ref_response.status_code != 200:
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": f"Failed to get branch ref: {ref_response.status_code}",
|
||||
"error": ref_response.text,
|
||||
}
|
||||
|
||||
ref_data = ref_response.json()
|
||||
current_commit_sha = ref_data["object"]["sha"]
|
||||
|
||||
# Get the current tree
|
||||
commit_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
|
||||
)
|
||||
if commit_response.status_code != 200:
|
||||
return {"status": "failed", "message": "Failed to get current commit"}
|
||||
|
||||
current_tree_sha = commit_response.json()["tree"]["sha"]
|
||||
|
||||
# Get existing files to check for changes
|
||||
tree_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
|
||||
)
|
||||
existing_files = {}
|
||||
if tree_response.status_code == 200:
|
||||
for item in tree_response.json().get("tree", []):
|
||||
if item["type"] == "blob":
|
||||
existing_files[item["path"]] = item["sha"]
|
||||
|
||||
# Create blobs for changed files
|
||||
tree_items = []
|
||||
files_changed = 0
|
||||
|
||||
for path, content in files.items():
|
||||
content_str = json.dumps(content, indent=2, default=str)
|
||||
content_bytes = content_str.encode("utf-8")
|
||||
content_sha = hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes).hexdigest()
|
||||
|
||||
# Skip if file hasn't changed
|
||||
if path in existing_files and existing_files[path] == content_sha:
|
||||
continue
|
||||
|
||||
# Create blob
|
||||
blob_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
|
||||
headers=headers,
|
||||
json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
|
||||
)
|
||||
|
||||
if blob_response.status_code != 201:
|
||||
logger.error(f"Failed to create blob for {path}: {blob_response.text}")
|
||||
continue
|
||||
|
||||
blob_sha = blob_response.json()["sha"]
|
||||
tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_sha})
|
||||
files_changed += 1
|
||||
|
||||
if not tree_items:
|
||||
return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
|
||||
|
||||
# Create new tree
|
||||
tree_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/trees",
|
||||
headers=headers,
|
||||
json={"base_tree": current_tree_sha, "tree": tree_items},
|
||||
)
|
||||
|
||||
if tree_response.status_code != 201:
|
||||
return {"status": "failed", "message": f"Failed to create tree: {tree_response.text}"}
|
||||
|
||||
new_tree_sha = tree_response.json()["sha"]
|
||||
|
||||
# Create commit
|
||||
commit_message = f"Bambuddy backup - {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}"
|
||||
commit_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/commits",
|
||||
headers=headers,
|
||||
json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
|
||||
)
|
||||
|
||||
if commit_response.status_code != 201:
|
||||
return {"status": "failed", "message": f"Failed to create commit: {commit_response.text}"}
|
||||
|
||||
new_commit_sha = commit_response.json()["sha"]
|
||||
|
||||
# Update branch reference
|
||||
ref_update = await client.patch(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}",
|
||||
headers=headers,
|
||||
json={"sha": new_commit_sha},
|
||||
)
|
||||
|
||||
if ref_update.status_code != 200:
|
||||
return {"status": "failed", "message": f"Failed to update branch: {ref_update.text}"}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Backup successful - {files_changed} files updated",
|
||||
"commit_sha": new_commit_sha,
|
||||
"files_changed": files_changed,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Push to GitHub failed: {e}")
|
||||
return {"status": "failed", "message": str(e), "error": str(e)}
|
||||
|
||||
async def _create_branch_and_push(
|
||||
self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
|
||||
) -> dict:
|
||||
"""Create a new branch and push files when branch doesn't exist."""
|
||||
try:
|
||||
# Get default branch
|
||||
repo_response = await client.get(f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
|
||||
if repo_response.status_code != 200:
|
||||
return {"status": "failed", "message": "Failed to get repo info"}
|
||||
|
||||
default_branch = repo_response.json().get("default_branch", "main")
|
||||
|
||||
# Get default branch ref
|
||||
ref_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
|
||||
)
|
||||
if ref_response.status_code != 200:
|
||||
# Empty repo - create initial commit
|
||||
return await self._create_initial_commit(client, headers, owner, repo, branch, files)
|
||||
|
||||
base_sha = ref_response.json()["object"]["sha"]
|
||||
|
||||
# Create new branch
|
||||
create_ref = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs",
|
||||
headers=headers,
|
||||
json={"ref": f"refs/heads/{branch}", "sha": base_sha},
|
||||
)
|
||||
|
||||
if create_ref.status_code != 201:
|
||||
return {"status": "failed", "message": f"Failed to create branch: {create_ref.text}"}
|
||||
|
||||
# Now push to the new branch (recursive call will find the branch)
|
||||
return await self._push_to_github(
|
||||
type(
|
||||
"Config",
|
||||
(),
|
||||
{
|
||||
"repository_url": f"https://github.com/{owner}/{repo}",
|
||||
"access_token": headers["Authorization"].replace("token ", ""),
|
||||
"branch": branch,
|
||||
},
|
||||
)(),
|
||||
files,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "failed", "message": str(e)}
|
||||
|
||||
async def _create_initial_commit(
|
||||
self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
|
||||
) -> dict:
|
||||
"""Create initial commit in an empty repository."""
|
||||
try:
|
||||
# Create blobs
|
||||
tree_items = []
|
||||
for path, content in files.items():
|
||||
content_str = json.dumps(content, indent=2, default=str)
|
||||
blob_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
|
||||
headers=headers,
|
||||
json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
|
||||
)
|
||||
if blob_response.status_code == 201:
|
||||
tree_items.append(
|
||||
{"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]}
|
||||
)
|
||||
|
||||
# Create tree
|
||||
tree_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/trees",
|
||||
headers=headers,
|
||||
json={"tree": tree_items},
|
||||
)
|
||||
if tree_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create tree"}
|
||||
|
||||
tree_sha = tree_response.json()["sha"]
|
||||
|
||||
# Create commit (no parents for initial)
|
||||
commit_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/commits",
|
||||
headers=headers,
|
||||
json={
|
||||
"message": f"Initial Bambuddy backup - {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}",
|
||||
"tree": tree_sha,
|
||||
},
|
||||
)
|
||||
if commit_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create commit"}
|
||||
|
||||
commit_sha = commit_response.json()["sha"]
|
||||
|
||||
# Create branch ref
|
||||
ref_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs",
|
||||
headers=headers,
|
||||
json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
|
||||
)
|
||||
if ref_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create branch ref"}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Initial backup created - {len(files)} files",
|
||||
"commit_sha": commit_sha,
|
||||
"files_changed": len(files),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "failed", "message": str(e)}
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if a backup is currently running."""
|
||||
return self._running_backup
|
||||
|
||||
@property
|
||||
def progress(self) -> str | None:
|
||||
"""Get current backup progress message."""
|
||||
return self._backup_progress
|
||||
|
||||
async def get_logs(self, config_id: int, limit: int = 50, offset: int = 0) -> list[GitHubBackupLog]:
|
||||
"""Get backup logs for a configuration."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(GitHubBackupLog)
|
||||
.where(GitHubBackupLog.config_id == config_id)
|
||||
.order_by(desc(GitHubBackupLog.started_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# Singleton instance
|
||||
github_backup_service = GitHubBackupService()
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Integration tests for GitHub Backup API endpoints."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestGitHubBackupConfigAPI:
|
||||
"""Integration tests for /api/v1/github-backup endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_config_no_config(self, async_client: AsyncClient):
|
||||
"""Verify getting config when none exists returns null."""
|
||||
response = await async_client.get("/api/v1/github-backup/config")
|
||||
assert response.status_code == 200
|
||||
assert response.json() is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_config(self, async_client: AsyncClient):
|
||||
"""Verify GitHub backup config can be created."""
|
||||
data = {
|
||||
"repository_url": "https://github.com/test/repo",
|
||||
"access_token": "ghp_testtoken123",
|
||||
"branch": "main",
|
||||
"schedule_enabled": False,
|
||||
"schedule_type": "daily",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": False,
|
||||
"enabled": True,
|
||||
}
|
||||
response = await async_client.post("/api/v1/github-backup/config", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["repository_url"] == "https://github.com/test/repo"
|
||||
assert result["branch"] == "main"
|
||||
assert result["has_token"] is True
|
||||
assert result["enabled"] is True
|
||||
# Token should not be exposed in response
|
||||
assert "access_token" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_config_after_create(self, async_client: AsyncClient):
|
||||
"""Verify getting config after creation returns the config."""
|
||||
# Create config first
|
||||
data = {
|
||||
"repository_url": "https://github.com/test/getrepo",
|
||||
"access_token": "ghp_testtoken456",
|
||||
"branch": "develop",
|
||||
"schedule_enabled": True,
|
||||
"schedule_type": "weekly",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": False,
|
||||
"backup_settings": True,
|
||||
"enabled": True,
|
||||
}
|
||||
await async_client.post("/api/v1/github-backup/config", json=data)
|
||||
|
||||
# Get config
|
||||
response = await async_client.get("/api/v1/github-backup/config")
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result is not None
|
||||
assert result["repository_url"] == "https://github.com/test/getrepo"
|
||||
assert result["branch"] == "develop"
|
||||
assert result["schedule_type"] == "weekly"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_config_partial(self, async_client: AsyncClient):
|
||||
"""Verify partial update of GitHub backup config."""
|
||||
# Create config first
|
||||
create_data = {
|
||||
"repository_url": "https://github.com/test/update",
|
||||
"access_token": "ghp_token",
|
||||
"branch": "main",
|
||||
"schedule_enabled": False,
|
||||
"schedule_type": "daily",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": False,
|
||||
"enabled": True,
|
||||
}
|
||||
await async_client.post("/api/v1/github-backup/config", json=create_data)
|
||||
|
||||
# Partial update
|
||||
update_data = {
|
||||
"branch": "develop",
|
||||
"schedule_enabled": True,
|
||||
}
|
||||
response = await async_client.patch("/api/v1/github-backup/config", json=update_data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["branch"] == "develop"
|
||||
assert result["schedule_enabled"] is True
|
||||
# Original values should be preserved
|
||||
assert result["repository_url"] == "https://github.com/test/update"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_config(self, async_client: AsyncClient):
|
||||
"""Verify GitHub backup config can be deleted."""
|
||||
# Create config first
|
||||
create_data = {
|
||||
"repository_url": "https://github.com/test/delete",
|
||||
"access_token": "ghp_deletetoken",
|
||||
"branch": "main",
|
||||
"schedule_enabled": False,
|
||||
"schedule_type": "daily",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": False,
|
||||
"enabled": True,
|
||||
}
|
||||
await async_client.post("/api/v1/github-backup/config", json=create_data)
|
||||
|
||||
# Delete
|
||||
response = await async_client.delete("/api/v1/github-backup/config")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify it's deleted
|
||||
get_response = await async_client.get("/api/v1/github-backup/config")
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json() is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_config_not_found(self, async_client: AsyncClient):
|
||||
"""Verify deleting non-existent config returns 404."""
|
||||
# Make sure no config exists
|
||||
await async_client.delete("/api/v1/github-backup/config")
|
||||
|
||||
# Try to delete again
|
||||
response = await async_client.delete("/api/v1/github-backup/config")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestGitHubBackupStatusAPI:
|
||||
"""Integration tests for /api/v1/github-backup/status endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_status_no_config(self, async_client: AsyncClient):
|
||||
"""Verify status when no config exists."""
|
||||
# Ensure no config
|
||||
await async_client.delete("/api/v1/github-backup/config")
|
||||
|
||||
response = await async_client.get("/api/v1/github-backup/status")
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["configured"] is False
|
||||
assert result["enabled"] is False
|
||||
assert result["is_running"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_status_with_config(self, async_client: AsyncClient):
|
||||
"""Verify status when config exists."""
|
||||
# Create config
|
||||
create_data = {
|
||||
"repository_url": "https://github.com/test/status",
|
||||
"access_token": "ghp_statustoken",
|
||||
"branch": "main",
|
||||
"schedule_enabled": True,
|
||||
"schedule_type": "hourly",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": False,
|
||||
"enabled": True,
|
||||
}
|
||||
await async_client.post("/api/v1/github-backup/config", json=create_data)
|
||||
|
||||
response = await async_client.get("/api/v1/github-backup/status")
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["configured"] is True
|
||||
assert result["enabled"] is True
|
||||
assert result["is_running"] is False
|
||||
assert result["next_scheduled_run"] is not None
|
||||
|
||||
|
||||
class TestGitHubBackupLogsAPI:
|
||||
"""Integration tests for /api/v1/github-backup/logs endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_logs_no_config(self, async_client: AsyncClient):
|
||||
"""Verify getting logs when no config exists returns empty list."""
|
||||
# Ensure no config
|
||||
await async_client.delete("/api/v1/github-backup/config")
|
||||
|
||||
response = await async_client.get("/api/v1/github-backup/logs")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_logs_with_config(self, async_client: AsyncClient):
|
||||
"""Verify getting logs with config."""
|
||||
# Create config
|
||||
create_data = {
|
||||
"repository_url": "https://github.com/test/logs",
|
||||
"access_token": "ghp_logstoken",
|
||||
"branch": "main",
|
||||
"schedule_enabled": False,
|
||||
"schedule_type": "daily",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": False,
|
||||
"enabled": True,
|
||||
}
|
||||
await async_client.post("/api/v1/github-backup/config", json=create_data)
|
||||
|
||||
response = await async_client.get("/api/v1/github-backup/logs")
|
||||
assert response.status_code == 200
|
||||
# No backups run yet, so empty list
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
class TestGitHubBackupTriggerAPI:
|
||||
"""Integration tests for /api/v1/github-backup/run endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_trigger_no_config(self, async_client: AsyncClient):
|
||||
"""Verify triggering backup without config returns 404."""
|
||||
# Ensure no config
|
||||
await async_client.delete("/api/v1/github-backup/config")
|
||||
|
||||
response = await async_client.post("/api/v1/github-backup/run")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_trigger_disabled_config(self, async_client: AsyncClient):
|
||||
"""Verify triggering backup with disabled config returns 400."""
|
||||
# Create disabled config
|
||||
create_data = {
|
||||
"repository_url": "https://github.com/test/trigger",
|
||||
"access_token": "ghp_triggertoken",
|
||||
"branch": "main",
|
||||
"schedule_enabled": False,
|
||||
"schedule_type": "daily",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": False,
|
||||
"enabled": False, # Disabled
|
||||
}
|
||||
await async_client.post("/api/v1/github-backup/config", json=create_data)
|
||||
|
||||
response = await async_client.post("/api/v1/github-backup/run")
|
||||
assert response.status_code == 400
|
||||
assert "disabled" in response.json()["detail"].lower()
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-C8T392Mv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BQIOMqJ9.css">
|
||||
<script type="module" crossorigin src="/assets/index-BpwPeQpb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C4B4iLcH.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user