Forráskód Böngészése

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
maziggy 4 hónapja
szülő
commit
4ea7f7c4aa

+ 11 - 0
CHANGELOG.md

@@ -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)

+ 1 - 0
README.md

@@ -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

+ 319 - 0
backend/app/api/routes/github_backup.py

@@ -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"}

+ 58 - 0
backend/app/api/routes/settings.py

@@ -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

+ 1 - 0
backend/app/core/database.py

@@ -38,6 +38,7 @@ async def init_db():
         archive,
         external_link,
         filament,
+        github_backup,
         kprofile_note,
         library,
         maintenance,

+ 7 - 0
backend/app/main.py

@@ -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)

+ 3 - 0
backend/app/models/__init__.py

@@ -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",
 ]

+ 65 - 0
backend/app/models/github_backup.py

@@ -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")

+ 154 - 0
backend/app/schemas/github_backup.py

@@ -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

+ 734 - 0
backend/app/services/github_backup.py

@@ -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()

+ 255 - 0
backend/tests/integration/test_github_backup_api.py

@@ -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()

+ 283 - 0
frontend/src/__tests__/api/githubBackupApi.test.ts

@@ -0,0 +1,283 @@
+/**
+ * Tests for the GitHub Backup API client functions.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import type {
+  GitHubBackupConfig,
+  GitHubBackupStatus,
+  GitHubBackupLog,
+} from '../../api/client';
+
+// Mock API base URL
+const API_BASE = 'http://localhost:5000/api/v1';
+
+// Create MSW server
+const server = setupServer();
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+describe('GitHub Backup API Types', () => {
+  it('GitHubBackupConfig has correct shape', () => {
+    const config: GitHubBackupConfig = {
+      id: 1,
+      repository_url: 'https://github.com/test/repo',
+      has_token: true,
+      branch: 'main',
+      schedule_enabled: true,
+      schedule_type: 'daily',
+      backup_kprofiles: true,
+      backup_cloud_profiles: true,
+      backup_settings: false,
+      enabled: true,
+      last_backup_at: '2026-01-27T10:00:00Z',
+      last_backup_status: 'success',
+      last_backup_message: null,
+      last_backup_commit_sha: 'abc123',
+      next_scheduled_run: '2026-01-28T00:00:00Z',
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-27T10:00:00Z',
+    };
+
+    expect(config.id).toBe(1);
+    expect(config.has_token).toBe(true);
+    expect(config.schedule_type).toBe('daily');
+  });
+
+  it('GitHubBackupStatus has correct shape', () => {
+    const status: GitHubBackupStatus = {
+      configured: true,
+      enabled: true,
+      is_running: false,
+      progress: null,
+      last_backup_at: '2026-01-27T10:00:00Z',
+      last_backup_status: 'success',
+      next_scheduled_run: '2026-01-28T00:00:00Z',
+    };
+
+    expect(status.configured).toBe(true);
+    expect(status.is_running).toBe(false);
+  });
+
+  it('GitHubBackupStatus can have progress', () => {
+    const status: GitHubBackupStatus = {
+      configured: true,
+      enabled: true,
+      is_running: true,
+      progress: 'Pushing to GitHub...',
+      last_backup_at: null,
+      last_backup_status: null,
+      next_scheduled_run: null,
+    };
+
+    expect(status.is_running).toBe(true);
+    expect(status.progress).toBe('Pushing to GitHub...');
+  });
+
+  it('GitHubBackupLog has correct shape', () => {
+    const log: GitHubBackupLog = {
+      id: 1,
+      config_id: 1,
+      started_at: '2026-01-27T10:00:00Z',
+      completed_at: '2026-01-27T10:01:00Z',
+      status: 'success',
+      trigger: 'manual',
+      commit_sha: 'abc123',
+      files_changed: 5,
+      error_message: null,
+    };
+
+    expect(log.status).toBe('success');
+    expect(log.trigger).toBe('manual');
+    expect(log.files_changed).toBe(5);
+  });
+
+  it('GitHubBackupLog can have error', () => {
+    const log: GitHubBackupLog = {
+      id: 2,
+      config_id: 1,
+      started_at: '2026-01-27T10:00:00Z',
+      completed_at: '2026-01-27T10:00:30Z',
+      status: 'failed',
+      trigger: 'scheduled',
+      commit_sha: null,
+      files_changed: 0,
+      error_message: 'Authentication failed',
+    };
+
+    expect(log.status).toBe('failed');
+    expect(log.error_message).toBe('Authentication failed');
+    expect(log.commit_sha).toBeNull();
+  });
+});
+
+describe('GitHub Backup API Endpoints', () => {
+  it('GET /github-backup/config returns null when not configured', async () => {
+    server.use(
+      http.get(`${API_BASE}/github-backup/config`, () => {
+        return HttpResponse.json(null);
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/config`);
+    const data = await response.json();
+    expect(data).toBeNull();
+  });
+
+  it('GET /github-backup/config returns config when exists', async () => {
+    const mockConfig: GitHubBackupConfig = {
+      id: 1,
+      repository_url: 'https://github.com/test/repo',
+      has_token: true,
+      branch: 'main',
+      schedule_enabled: false,
+      schedule_type: 'daily',
+      backup_kprofiles: true,
+      backup_cloud_profiles: true,
+      backup_settings: false,
+      enabled: true,
+      last_backup_at: null,
+      last_backup_status: null,
+      last_backup_message: null,
+      last_backup_commit_sha: null,
+      next_scheduled_run: null,
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-01T00:00:00Z',
+    };
+
+    server.use(
+      http.get(`${API_BASE}/github-backup/config`, () => {
+        return HttpResponse.json(mockConfig);
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/config`);
+    const data = await response.json();
+    expect(data.repository_url).toBe('https://github.com/test/repo');
+    expect(data.has_token).toBe(true);
+  });
+
+  it('GET /github-backup/status returns not configured status', async () => {
+    const mockStatus: GitHubBackupStatus = {
+      configured: false,
+      enabled: false,
+      is_running: false,
+      progress: null,
+      last_backup_at: null,
+      last_backup_status: null,
+      next_scheduled_run: null,
+    };
+
+    server.use(
+      http.get(`${API_BASE}/github-backup/status`, () => {
+        return HttpResponse.json(mockStatus);
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/status`);
+    const data = await response.json();
+    expect(data.configured).toBe(false);
+    expect(data.enabled).toBe(false);
+  });
+
+  it('GET /github-backup/logs returns empty list when no logs', async () => {
+    server.use(
+      http.get(`${API_BASE}/github-backup/logs`, () => {
+        return HttpResponse.json([]);
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/logs`);
+    const data = await response.json();
+    expect(data).toEqual([]);
+  });
+
+  it('GET /github-backup/logs returns log entries', async () => {
+    const mockLogs: GitHubBackupLog[] = [
+      {
+        id: 1,
+        config_id: 1,
+        started_at: '2026-01-27T10:00:00Z',
+        completed_at: '2026-01-27T10:01:00Z',
+        status: 'success',
+        trigger: 'manual',
+        commit_sha: 'abc123',
+        files_changed: 5,
+        error_message: null,
+      },
+    ];
+
+    server.use(
+      http.get(`${API_BASE}/github-backup/logs`, () => {
+        return HttpResponse.json(mockLogs);
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/logs`);
+    const data = await response.json();
+    expect(data.length).toBe(1);
+    expect(data[0].status).toBe('success');
+  });
+
+  it('POST /github-backup/run returns 404 when not configured', async () => {
+    server.use(
+      http.post(`${API_BASE}/github-backup/run`, () => {
+        return HttpResponse.json(
+          { detail: 'No configuration found' },
+          { status: 404 }
+        );
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/run`, {
+      method: 'POST',
+    });
+    expect(response.status).toBe(404);
+  });
+
+  it('POST /github-backup/test returns success on valid credentials', async () => {
+    server.use(
+      http.post(`${API_BASE}/github-backup/test`, () => {
+        return HttpResponse.json({
+          success: true,
+          message: 'Connection successful',
+          repo_name: 'test/repo',
+          default_branch: 'main',
+        });
+      })
+    );
+
+    const response = await fetch(
+      `${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=ghp_test`,
+      { method: 'POST' }
+    );
+    const data = await response.json();
+    expect(data.success).toBe(true);
+    expect(data.repo_name).toBe('test/repo');
+  });
+
+  it('POST /github-backup/test returns failure on invalid credentials', async () => {
+    server.use(
+      http.post(`${API_BASE}/github-backup/test`, () => {
+        return HttpResponse.json({
+          success: false,
+          message: 'Authentication failed',
+          repo_name: null,
+          default_branch: null,
+        });
+      })
+    );
+
+    const response = await fetch(
+      `${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=invalid`,
+      { method: 'POST' }
+    );
+    const data = await response.json();
+    expect(data.success).toBe(false);
+    expect(data.message).toBe('Authentication failed');
+  });
+});

+ 112 - 0
frontend/src/api/client.ts

@@ -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

+ 746 - 0
frontend/src/components/GitHubBackupSettings.tsx

@@ -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>
+  );
+}

+ 50 - 111
frontend/src/pages/SettingsPage.tsx

@@ -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,72 +1411,52 @@ 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-white">Reset UI Preferences</p>
                   <p className="text-sm text-bambu-gray">
-                    Import settings from a backup file with duplicate handling options
+                    Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.
                   </p>
                 </div>
                 <Button
                   variant="secondary"
                   size="sm"
-                  onClick={() => setShowRestoreModal(true)}
+                  onClick={() => setShowClearStorageConfirm(true)}
                 >
-                  <Upload className="w-4 h-4" />
-                  Restore
+                  <Trash2 className="w-4 h-4" />
+                  Reset
                 </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 className="flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary">
                 <div>
-                  <p className="text-white">Reset UI Preferences</p>
+                  <p className="text-white">Backup & Restore</p>
                   <p className="text-sm text-bambu-gray">
-                    Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.
+                    Export/import settings and configure GitHub backup
                   </p>
                 </div>
                 <Button
                   variant="secondary"
                   size="sm"
-                  onClick={() => setShowClearStorageConfirm(true)}
+                  onClick={() => handleTabChange('backup')}
                 >
-                  <Trash2 className="w-4 h-4" />
-                  Reset
+                  <Database className="w-4 h-4" />
+                  Go to Backup
                 </Button>
               </div>
             </CardContent>
@@ -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

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
static/assets/index-BQIOMqJ9.css


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
static/assets/index-BpwPeQpb.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
static/assets/index-C4B4iLcH.css


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
static/assets/index-C8T392Mv.js


+ 2 - 2
static/index.html

@@ -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>

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott