Add GitHub profile backup feature

New feature to automatically backup K-profiles, cloud profiles, and app
settings to a GitHub repository with scheduled or on-demand execution.

Features:
- Configure GitHub repo URL and Personal Access Token
- Schedule backups hourly, daily, or weekly (background scheduler)
- Manual backup trigger with real-time progress tracking
- Skip unchanged commits (only creates commit when data changes)
- Backup history log with status and commit links
- Requires Bambu Cloud login for full profile access
- New Settings → Backup & Restore tab consolidating all backup options
- GitHub backup config included in local backup/restore (except PAT)

Backend:
- New models: GitHubBackupConfig, GitHubBackupLog
- New service: GitHubBackupService with scheduler and GitHub API client
- New routes: /github-backup/* for config, status, logs, and triggers
- Updated settings.py to include github_backup in backup/restore

Frontend:
- New GitHubBackupSettings.tsx component with auto-save
- Updated SettingsPage with Backup tab and status indicator
- Added API types and methods to client.ts

Tests:
- Backend integration tests for all GitHub backup API endpoints
- Frontend API type and endpoint tests
This commit is contained in:
maziggy
2026-01-27 15:01:06 +01:00
parent ea737c4719
commit 4ea7f7c4aa
20 changed files with 11536 additions and 8848 deletions
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -38,6 +38,7 @@ async def init_db():
archive,
external_link,
filament,
github_backup,
kprofile_note,
library,
maintenance,
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,734 @@
"""GitHub backup service for printer profiles.
Handles scheduled and on-demand backups of K-profiles and cloud profiles to GitHub.
"""
import asyncio
import base64
import hashlib
import json
import logging
import re
from datetime import UTC, datetime, timedelta
import httpx
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import async_session
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
from backend.app.models.printer import Printer
from backend.app.models.settings import Settings
from backend.app.services.bambu_cloud import get_cloud_service
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
# Schedule intervals in seconds
SCHEDULE_INTERVALS = {
"hourly": 3600,
"daily": 86400,
"weekly": 604800,
}
class GitHubBackupService:
"""Service for backing up profiles to GitHub."""
def __init__(self):
self._scheduler_task: asyncio.Task | None = None
self._check_interval = 60 # Check every minute for scheduled runs
self._running_backup: bool = False
self._backup_progress: str | None = None
self._http_client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(timeout=60.0)
return self._http_client
async def start_scheduler(self):
"""Start the background scheduler loop."""
if self._scheduler_task is not None:
return
logger.info("Starting GitHub backup scheduler")
self._scheduler_task = asyncio.create_task(self._scheduler_loop())
def stop_scheduler(self):
"""Stop the scheduler."""
if self._scheduler_task:
self._scheduler_task.cancel()
self._scheduler_task = None
logger.info("Stopped GitHub backup scheduler")
async def _scheduler_loop(self):
"""Main scheduler loop - checks for due backups."""
while True:
try:
await asyncio.sleep(self._check_interval)
await self._check_scheduled_backups()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in GitHub backup scheduler: {e}")
await asyncio.sleep(60)
async def _check_scheduled_backups(self):
"""Check if any scheduled backups are due."""
async with async_session() as db:
result = await db.execute(
select(GitHubBackupConfig).where(
GitHubBackupConfig.enabled == True, # noqa: E712
GitHubBackupConfig.schedule_enabled == True, # noqa: E712
)
)
configs = result.scalars().all()
now = datetime.now(UTC)
for config in configs:
if config.next_scheduled_run and config.next_scheduled_run <= now:
logger.info(f"Running scheduled backup for config {config.id}")
await self.run_backup(config.id, trigger="scheduled")
def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
"""Calculate the next scheduled run time."""
now = from_time or datetime.now(UTC)
interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS["daily"])
return now + timedelta(seconds=interval)
async def test_connection(self, repo_url: str, token: str) -> dict:
"""Test GitHub connection and permissions.
Args:
repo_url: GitHub repository URL
token: Personal Access Token
Returns:
dict with success, message, repo_name, permissions
"""
try:
owner, repo = self._parse_repo_url(repo_url)
client = await self._get_client()
# Test API access
response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}",
headers={
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "Bambuddy-Backup",
},
)
if response.status_code == 401:
return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
if response.status_code == 404:
return {
"success": False,
"message": "Repository not found. Check URL and token permissions.",
"repo_name": None,
"permissions": None,
}
if response.status_code != 200:
return {
"success": False,
"message": f"GitHub API error: {response.status_code}",
"repo_name": None,
"permissions": None,
}
data = response.json()
permissions = data.get("permissions", {})
# Check for push permission
if not permissions.get("push", False):
return {
"success": False,
"message": "Token does not have push permission to this repository",
"repo_name": data.get("full_name"),
"permissions": permissions,
}
return {
"success": True,
"message": "Connection successful",
"repo_name": data.get("full_name"),
"permissions": permissions,
}
except Exception as e:
logger.error(f"GitHub connection test failed: {e}")
return {"success": False, "message": str(e), "repo_name": None, "permissions": None}
def _parse_repo_url(self, url: str) -> tuple[str, str]:
"""Parse owner and repo from GitHub URL."""
# Handle HTTPS URLs
match = re.match(r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$", url)
if match:
return match.group(1), match.group(2)
# Handle SSH URLs
match = re.match(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", url)
if match:
return match.group(1), match.group(2)
raise ValueError(f"Invalid GitHub URL: {url}")
async def run_backup(self, config_id: int, trigger: str = "manual") -> dict:
"""Run a backup operation.
Args:
config_id: ID of the backup configuration
trigger: "manual" or "scheduled"
Returns:
dict with success, message, log_id, commit_sha, files_changed
"""
if self._running_backup:
return {"success": False, "message": "A backup is already running", "log_id": None}
self._running_backup = True
log_id = None
try:
async with async_session() as db:
# Get config
result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
config = result.scalar_one_or_none()
if not config:
return {"success": False, "message": "Configuration not found", "log_id": None}
if not config.enabled:
return {"success": False, "message": "Backup is disabled", "log_id": None}
# Create log entry
log = GitHubBackupLog(config_id=config_id, status="running", trigger=trigger)
db.add(log)
await db.commit()
await db.refresh(log)
log_id = log.id
try:
# Collect backup data
self._backup_progress = "Collecting profiles..."
backup_data = await self._collect_backup_data(db, config)
if not backup_data:
# No data to backup
log.status = "skipped"
log.completed_at = datetime.now(UTC)
log.error_message = "No data to backup"
config.last_backup_at = datetime.now(UTC)
config.last_backup_status = "skipped"
config.last_backup_message = "No data to backup"
if config.schedule_enabled:
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
await db.commit()
return {
"success": True,
"message": "No data to backup",
"log_id": log_id,
"commit_sha": None,
"files_changed": 0,
}
# Push to GitHub
self._backup_progress = "Pushing to GitHub..."
push_result = await self._push_to_github(config, backup_data)
# Update log and config
log.status = push_result["status"]
log.completed_at = datetime.now(UTC)
log.commit_sha = push_result.get("commit_sha")
log.files_changed = push_result.get("files_changed", 0)
log.error_message = push_result.get("error")
config.last_backup_at = datetime.now(UTC)
config.last_backup_status = push_result["status"]
config.last_backup_message = push_result.get("message", "")
config.last_backup_commit_sha = push_result.get("commit_sha")
if config.schedule_enabled:
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
await db.commit()
return {
"success": push_result["status"] in ("success", "skipped"),
"message": push_result.get("message", "Backup completed"),
"log_id": log_id,
"commit_sha": push_result.get("commit_sha"),
"files_changed": push_result.get("files_changed", 0),
}
except Exception as e:
logger.error(f"Backup failed: {e}")
log.status = "failed"
log.completed_at = datetime.now(UTC)
log.error_message = str(e)
config.last_backup_at = datetime.now(UTC)
config.last_backup_status = "failed"
config.last_backup_message = str(e)
if config.schedule_enabled:
config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
await db.commit()
return {
"success": False,
"message": str(e),
"log_id": log_id,
"commit_sha": None,
"files_changed": 0,
}
finally:
self._running_backup = False
self._backup_progress = None
async def _collect_backup_data(self, db: AsyncSession, config: GitHubBackupConfig) -> dict:
"""Collect data to backup based on config settings.
Returns dict with structure:
{
"backup_metadata.json": {...},
"kprofiles/{serial}/{nozzle}.json": {...},
"cloud_profiles/filament.json": [...],
"cloud_profiles/printer.json": [...],
"cloud_profiles/process.json": [...],
"settings/app_settings.json": {...},
}
"""
files: dict[str, dict | list] = {}
now = datetime.now(UTC)
# Metadata file
metadata = {
"version": "1.0",
"backup_type": "bambuddy_profiles",
"created_at": now.isoformat(),
"contents": {
"kprofiles": config.backup_kprofiles,
"cloud_profiles": config.backup_cloud_profiles,
"settings": config.backup_settings,
},
}
files["backup_metadata.json"] = metadata
# Collect K-profiles from all connected printers
if config.backup_kprofiles:
self._backup_progress = "Collecting K-profiles from printers..."
await self._collect_kprofiles(db, files)
# Collect cloud profiles
if config.backup_cloud_profiles:
self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
await self._collect_cloud_profiles(db, files)
# Collect app settings
if config.backup_settings:
self._backup_progress = "Collecting app settings..."
await self._collect_settings(db, files)
return files
async def _collect_kprofiles(self, db: AsyncSession, files: dict):
"""Collect K-profiles from all connected printers."""
result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712
printers = result.scalars().all()
nozzle_diameters = ["0.2", "0.4", "0.6", "0.8"]
for printer in printers:
client = printer_manager.get_client(printer.id)
if not client or not client.state.connected:
continue
serial = printer.serial_number
printer_profiles = {}
for nozzle in nozzle_diameters:
try:
profiles = await client.get_kprofiles(nozzle_diameter=nozzle)
if profiles:
profile_data = {
"version": "1.0",
"printer_name": printer.name,
"printer_serial": serial,
"nozzle_diameter": nozzle,
"exported_at": datetime.now(UTC).isoformat(),
"profiles": [
{
"slot_id": p.slot_id,
"name": p.name,
"k_value": p.k_value,
"filament_id": p.filament_id,
"nozzle_id": p.nozzle_id,
"extruder_id": p.extruder_id,
"setting_id": p.setting_id,
"n_coef": p.n_coef,
}
for p in profiles
],
}
files[f"kprofiles/{serial}/{nozzle}.json"] = profile_data
printer_profiles[nozzle] = len(profiles)
except Exception as e:
logger.warning(f"Failed to get K-profiles for printer {serial} nozzle {nozzle}: {e}")
if printer_profiles:
logger.info(f"Collected K-profiles for {serial}: {printer_profiles}")
async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
"""Collect Bambu Cloud profiles if authenticated."""
# Check if cloud is authenticated
cloud = get_cloud_service()
# Try to restore token from DB
result = await db.execute(select(Settings).where(Settings.key == "bambu_cloud_token"))
setting = result.scalar_one_or_none()
if setting and setting.value:
cloud.set_token(setting.value)
if not cloud.is_authenticated:
logger.info("Cloud not authenticated, skipping cloud profiles")
return
try:
settings = await cloud.get_slicer_settings()
if not settings:
return
# Separate by type
filament_settings = []
printer_settings = []
process_settings = []
for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
setting_type = setting.get("type", "")
if setting_type == "filament":
filament_settings.append(setting)
elif setting_type == "printer":
printer_settings.append(setting)
elif setting_type == "process":
process_settings.append(setting)
if filament_settings:
files["cloud_profiles/filament.json"] = {
"version": "1.0",
"exported_at": datetime.now(UTC).isoformat(),
"profiles": filament_settings,
}
if printer_settings:
files["cloud_profiles/printer.json"] = {
"version": "1.0",
"exported_at": datetime.now(UTC).isoformat(),
"profiles": printer_settings,
}
if process_settings:
files["cloud_profiles/process.json"] = {
"version": "1.0",
"exported_at": datetime.now(UTC).isoformat(),
"profiles": process_settings,
}
logger.info(
f"Collected cloud profiles: {len(filament_settings)} filament, "
f"{len(printer_settings)} printer, {len(process_settings)} process"
)
except Exception as e:
logger.warning(f"Failed to collect cloud profiles: {e}")
async def _collect_settings(self, db: AsyncSession, files: dict):
"""Collect app settings."""
result = await db.execute(select(Settings))
settings = result.scalars().all()
# Filter out sensitive settings
sensitive_keys = {"bambu_cloud_token", "auth_secret_key"}
settings_data = {s.key: s.value for s in settings if s.key not in sensitive_keys}
files["settings/app_settings.json"] = {
"version": "1.0",
"exported_at": datetime.now(UTC).isoformat(),
"settings": settings_data,
}
async def _push_to_github(self, config: GitHubBackupConfig, files: dict) -> dict:
"""Push files to GitHub using the GitHub API.
Uses the Git Data API to create blobs, tree, and commit.
Returns:
dict with status, message, commit_sha, files_changed
"""
try:
owner, repo = self._parse_repo_url(config.repository_url)
branch = config.branch
client = await self._get_client()
headers = {
"Authorization": f"token {config.access_token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "Bambuddy-Backup",
}
# Get current branch reference
ref_response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers
)
if ref_response.status_code == 404:
# Branch doesn't exist, need to create it from default branch
return await self._create_branch_and_push(client, headers, owner, repo, branch, files)
if ref_response.status_code != 200:
return {
"status": "failed",
"message": f"Failed to get branch ref: {ref_response.status_code}",
"error": ref_response.text,
}
ref_data = ref_response.json()
current_commit_sha = ref_data["object"]["sha"]
# Get the current tree
commit_response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
)
if commit_response.status_code != 200:
return {"status": "failed", "message": "Failed to get current commit"}
current_tree_sha = commit_response.json()["tree"]["sha"]
# Get existing files to check for changes
tree_response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
)
existing_files = {}
if tree_response.status_code == 200:
for item in tree_response.json().get("tree", []):
if item["type"] == "blob":
existing_files[item["path"]] = item["sha"]
# Create blobs for changed files
tree_items = []
files_changed = 0
for path, content in files.items():
content_str = json.dumps(content, indent=2, default=str)
content_bytes = content_str.encode("utf-8")
content_sha = hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes).hexdigest()
# Skip if file hasn't changed
if path in existing_files and existing_files[path] == content_sha:
continue
# Create blob
blob_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
headers=headers,
json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
)
if blob_response.status_code != 201:
logger.error(f"Failed to create blob for {path}: {blob_response.text}")
continue
blob_sha = blob_response.json()["sha"]
tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_sha})
files_changed += 1
if not tree_items:
return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
# Create new tree
tree_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/trees",
headers=headers,
json={"base_tree": current_tree_sha, "tree": tree_items},
)
if tree_response.status_code != 201:
return {"status": "failed", "message": f"Failed to create tree: {tree_response.text}"}
new_tree_sha = tree_response.json()["sha"]
# Create commit
commit_message = f"Bambuddy backup - {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}"
commit_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/commits",
headers=headers,
json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
)
if commit_response.status_code != 201:
return {"status": "failed", "message": f"Failed to create commit: {commit_response.text}"}
new_commit_sha = commit_response.json()["sha"]
# Update branch reference
ref_update = await client.patch(
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}",
headers=headers,
json={"sha": new_commit_sha},
)
if ref_update.status_code != 200:
return {"status": "failed", "message": f"Failed to update branch: {ref_update.text}"}
return {
"status": "success",
"message": f"Backup successful - {files_changed} files updated",
"commit_sha": new_commit_sha,
"files_changed": files_changed,
}
except Exception as e:
logger.error(f"Push to GitHub failed: {e}")
return {"status": "failed", "message": str(e), "error": str(e)}
async def _create_branch_and_push(
self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
) -> dict:
"""Create a new branch and push files when branch doesn't exist."""
try:
# Get default branch
repo_response = await client.get(f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
if repo_response.status_code != 200:
return {"status": "failed", "message": "Failed to get repo info"}
default_branch = repo_response.json().get("default_branch", "main")
# Get default branch ref
ref_response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
)
if ref_response.status_code != 200:
# Empty repo - create initial commit
return await self._create_initial_commit(client, headers, owner, repo, branch, files)
base_sha = ref_response.json()["object"]["sha"]
# Create new branch
create_ref = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/refs",
headers=headers,
json={"ref": f"refs/heads/{branch}", "sha": base_sha},
)
if create_ref.status_code != 201:
return {"status": "failed", "message": f"Failed to create branch: {create_ref.text}"}
# Now push to the new branch (recursive call will find the branch)
return await self._push_to_github(
type(
"Config",
(),
{
"repository_url": f"https://github.com/{owner}/{repo}",
"access_token": headers["Authorization"].replace("token ", ""),
"branch": branch,
},
)(),
files,
)
except Exception as e:
return {"status": "failed", "message": str(e)}
async def _create_initial_commit(
self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
) -> dict:
"""Create initial commit in an empty repository."""
try:
# Create blobs
tree_items = []
for path, content in files.items():
content_str = json.dumps(content, indent=2, default=str)
blob_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
headers=headers,
json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
)
if blob_response.status_code == 201:
tree_items.append(
{"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]}
)
# Create tree
tree_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/trees",
headers=headers,
json={"tree": tree_items},
)
if tree_response.status_code != 201:
return {"status": "failed", "message": "Failed to create tree"}
tree_sha = tree_response.json()["sha"]
# Create commit (no parents for initial)
commit_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/commits",
headers=headers,
json={
"message": f"Initial Bambuddy backup - {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}",
"tree": tree_sha,
},
)
if commit_response.status_code != 201:
return {"status": "failed", "message": "Failed to create commit"}
commit_sha = commit_response.json()["sha"]
# Create branch ref
ref_response = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/git/refs",
headers=headers,
json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
)
if ref_response.status_code != 201:
return {"status": "failed", "message": "Failed to create branch ref"}
return {
"status": "success",
"message": f"Initial backup created - {len(files)} files",
"commit_sha": commit_sha,
"files_changed": len(files),
}
except Exception as e:
return {"status": "failed", "message": str(e)}
@property
def is_running(self) -> bool:
"""Check if a backup is currently running."""
return self._running_backup
@property
def progress(self) -> str | None:
"""Get current backup progress message."""
return self._backup_progress
async def get_logs(self, config_id: int, limit: int = 50, offset: int = 0) -> list[GitHubBackupLog]:
"""Get backup logs for a configuration."""
async with async_session() as db:
result = await db.execute(
select(GitHubBackupLog)
.where(GitHubBackupLog.config_id == config_id)
.order_by(desc(GitHubBackupLog.started_at))
.offset(offset)
.limit(limit)
)
return list(result.scalars().all())
# Singleton instance
github_backup_service = GitHubBackupService()
@@ -0,0 +1,255 @@
"""Integration tests for GitHub Backup API endpoints."""
import pytest
from httpx import AsyncClient
class TestGitHubBackupConfigAPI:
"""Integration tests for /api/v1/github-backup endpoints."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_config_no_config(self, async_client: AsyncClient):
"""Verify getting config when none exists returns null."""
response = await async_client.get("/api/v1/github-backup/config")
assert response.status_code == 200
assert response.json() is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_config(self, async_client: AsyncClient):
"""Verify GitHub backup config can be created."""
data = {
"repository_url": "https://github.com/test/repo",
"access_token": "ghp_testtoken123",
"branch": "main",
"schedule_enabled": False,
"schedule_type": "daily",
"backup_kprofiles": True,
"backup_cloud_profiles": True,
"backup_settings": False,
"enabled": True,
}
response = await async_client.post("/api/v1/github-backup/config", json=data)
assert response.status_code == 200
result = response.json()
assert result["repository_url"] == "https://github.com/test/repo"
assert result["branch"] == "main"
assert result["has_token"] is True
assert result["enabled"] is True
# Token should not be exposed in response
assert "access_token" not in result
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_config_after_create(self, async_client: AsyncClient):
"""Verify getting config after creation returns the config."""
# Create config first
data = {
"repository_url": "https://github.com/test/getrepo",
"access_token": "ghp_testtoken456",
"branch": "develop",
"schedule_enabled": True,
"schedule_type": "weekly",
"backup_kprofiles": True,
"backup_cloud_profiles": False,
"backup_settings": True,
"enabled": True,
}
await async_client.post("/api/v1/github-backup/config", json=data)
# Get config
response = await async_client.get("/api/v1/github-backup/config")
assert response.status_code == 200
result = response.json()
assert result is not None
assert result["repository_url"] == "https://github.com/test/getrepo"
assert result["branch"] == "develop"
assert result["schedule_type"] == "weekly"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_config_partial(self, async_client: AsyncClient):
"""Verify partial update of GitHub backup config."""
# Create config first
create_data = {
"repository_url": "https://github.com/test/update",
"access_token": "ghp_token",
"branch": "main",
"schedule_enabled": False,
"schedule_type": "daily",
"backup_kprofiles": True,
"backup_cloud_profiles": True,
"backup_settings": False,
"enabled": True,
}
await async_client.post("/api/v1/github-backup/config", json=create_data)
# Partial update
update_data = {
"branch": "develop",
"schedule_enabled": True,
}
response = await async_client.patch("/api/v1/github-backup/config", json=update_data)
assert response.status_code == 200
result = response.json()
assert result["branch"] == "develop"
assert result["schedule_enabled"] is True
# Original values should be preserved
assert result["repository_url"] == "https://github.com/test/update"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_config(self, async_client: AsyncClient):
"""Verify GitHub backup config can be deleted."""
# Create config first
create_data = {
"repository_url": "https://github.com/test/delete",
"access_token": "ghp_deletetoken",
"branch": "main",
"schedule_enabled": False,
"schedule_type": "daily",
"backup_kprofiles": True,
"backup_cloud_profiles": True,
"backup_settings": False,
"enabled": True,
}
await async_client.post("/api/v1/github-backup/config", json=create_data)
# Delete
response = await async_client.delete("/api/v1/github-backup/config")
assert response.status_code == 200
# Verify it's deleted
get_response = await async_client.get("/api/v1/github-backup/config")
assert get_response.status_code == 200
assert get_response.json() is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_config_not_found(self, async_client: AsyncClient):
"""Verify deleting non-existent config returns 404."""
# Make sure no config exists
await async_client.delete("/api/v1/github-backup/config")
# Try to delete again
response = await async_client.delete("/api/v1/github-backup/config")
assert response.status_code == 404
class TestGitHubBackupStatusAPI:
"""Integration tests for /api/v1/github-backup/status endpoint."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_status_no_config(self, async_client: AsyncClient):
"""Verify status when no config exists."""
# Ensure no config
await async_client.delete("/api/v1/github-backup/config")
response = await async_client.get("/api/v1/github-backup/status")
assert response.status_code == 200
result = response.json()
assert result["configured"] is False
assert result["enabled"] is False
assert result["is_running"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_status_with_config(self, async_client: AsyncClient):
"""Verify status when config exists."""
# Create config
create_data = {
"repository_url": "https://github.com/test/status",
"access_token": "ghp_statustoken",
"branch": "main",
"schedule_enabled": True,
"schedule_type": "hourly",
"backup_kprofiles": True,
"backup_cloud_profiles": True,
"backup_settings": False,
"enabled": True,
}
await async_client.post("/api/v1/github-backup/config", json=create_data)
response = await async_client.get("/api/v1/github-backup/status")
assert response.status_code == 200
result = response.json()
assert result["configured"] is True
assert result["enabled"] is True
assert result["is_running"] is False
assert result["next_scheduled_run"] is not None
class TestGitHubBackupLogsAPI:
"""Integration tests for /api/v1/github-backup/logs endpoints."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_logs_no_config(self, async_client: AsyncClient):
"""Verify getting logs when no config exists returns empty list."""
# Ensure no config
await async_client.delete("/api/v1/github-backup/config")
response = await async_client.get("/api/v1/github-backup/logs")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_logs_with_config(self, async_client: AsyncClient):
"""Verify getting logs with config."""
# Create config
create_data = {
"repository_url": "https://github.com/test/logs",
"access_token": "ghp_logstoken",
"branch": "main",
"schedule_enabled": False,
"schedule_type": "daily",
"backup_kprofiles": True,
"backup_cloud_profiles": True,
"backup_settings": False,
"enabled": True,
}
await async_client.post("/api/v1/github-backup/config", json=create_data)
response = await async_client.get("/api/v1/github-backup/logs")
assert response.status_code == 200
# No backups run yet, so empty list
assert response.json() == []
class TestGitHubBackupTriggerAPI:
"""Integration tests for /api/v1/github-backup/run endpoint."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_trigger_no_config(self, async_client: AsyncClient):
"""Verify triggering backup without config returns 404."""
# Ensure no config
await async_client.delete("/api/v1/github-backup/config")
response = await async_client.post("/api/v1/github-backup/run")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_trigger_disabled_config(self, async_client: AsyncClient):
"""Verify triggering backup with disabled config returns 400."""
# Create disabled config
create_data = {
"repository_url": "https://github.com/test/trigger",
"access_token": "ghp_triggertoken",
"branch": "main",
"schedule_enabled": False,
"schedule_type": "daily",
"backup_kprofiles": True,
"backup_cloud_profiles": True,
"backup_settings": False,
"enabled": False, # Disabled
}
await async_client.post("/api/v1/github-backup/config", json=create_data)
response = await async_client.post("/api/v1/github-backup/run")
assert response.status_code == 400
assert "disabled" in response.json()["detail"].lower()
@@ -0,0 +1,283 @@
/**
* Tests for the GitHub Backup API client functions.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import type {
GitHubBackupConfig,
GitHubBackupStatus,
GitHubBackupLog,
} from '../../api/client';
// Mock API base URL
const API_BASE = 'http://localhost:5000/api/v1';
// Create MSW server
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('GitHub Backup API Types', () => {
it('GitHubBackupConfig has correct shape', () => {
const config: GitHubBackupConfig = {
id: 1,
repository_url: 'https://github.com/test/repo',
has_token: true,
branch: 'main',
schedule_enabled: true,
schedule_type: 'daily',
backup_kprofiles: true,
backup_cloud_profiles: true,
backup_settings: false,
enabled: true,
last_backup_at: '2026-01-27T10:00:00Z',
last_backup_status: 'success',
last_backup_message: null,
last_backup_commit_sha: 'abc123',
next_scheduled_run: '2026-01-28T00:00:00Z',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-27T10:00:00Z',
};
expect(config.id).toBe(1);
expect(config.has_token).toBe(true);
expect(config.schedule_type).toBe('daily');
});
it('GitHubBackupStatus has correct shape', () => {
const status: GitHubBackupStatus = {
configured: true,
enabled: true,
is_running: false,
progress: null,
last_backup_at: '2026-01-27T10:00:00Z',
last_backup_status: 'success',
next_scheduled_run: '2026-01-28T00:00:00Z',
};
expect(status.configured).toBe(true);
expect(status.is_running).toBe(false);
});
it('GitHubBackupStatus can have progress', () => {
const status: GitHubBackupStatus = {
configured: true,
enabled: true,
is_running: true,
progress: 'Pushing to GitHub...',
last_backup_at: null,
last_backup_status: null,
next_scheduled_run: null,
};
expect(status.is_running).toBe(true);
expect(status.progress).toBe('Pushing to GitHub...');
});
it('GitHubBackupLog has correct shape', () => {
const log: GitHubBackupLog = {
id: 1,
config_id: 1,
started_at: '2026-01-27T10:00:00Z',
completed_at: '2026-01-27T10:01:00Z',
status: 'success',
trigger: 'manual',
commit_sha: 'abc123',
files_changed: 5,
error_message: null,
};
expect(log.status).toBe('success');
expect(log.trigger).toBe('manual');
expect(log.files_changed).toBe(5);
});
it('GitHubBackupLog can have error', () => {
const log: GitHubBackupLog = {
id: 2,
config_id: 1,
started_at: '2026-01-27T10:00:00Z',
completed_at: '2026-01-27T10:00:30Z',
status: 'failed',
trigger: 'scheduled',
commit_sha: null,
files_changed: 0,
error_message: 'Authentication failed',
};
expect(log.status).toBe('failed');
expect(log.error_message).toBe('Authentication failed');
expect(log.commit_sha).toBeNull();
});
});
describe('GitHub Backup API Endpoints', () => {
it('GET /github-backup/config returns null when not configured', async () => {
server.use(
http.get(`${API_BASE}/github-backup/config`, () => {
return HttpResponse.json(null);
})
);
const response = await fetch(`${API_BASE}/github-backup/config`);
const data = await response.json();
expect(data).toBeNull();
});
it('GET /github-backup/config returns config when exists', async () => {
const mockConfig: GitHubBackupConfig = {
id: 1,
repository_url: 'https://github.com/test/repo',
has_token: true,
branch: 'main',
schedule_enabled: false,
schedule_type: 'daily',
backup_kprofiles: true,
backup_cloud_profiles: true,
backup_settings: false,
enabled: true,
last_backup_at: null,
last_backup_status: null,
last_backup_message: null,
last_backup_commit_sha: null,
next_scheduled_run: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
server.use(
http.get(`${API_BASE}/github-backup/config`, () => {
return HttpResponse.json(mockConfig);
})
);
const response = await fetch(`${API_BASE}/github-backup/config`);
const data = await response.json();
expect(data.repository_url).toBe('https://github.com/test/repo');
expect(data.has_token).toBe(true);
});
it('GET /github-backup/status returns not configured status', async () => {
const mockStatus: GitHubBackupStatus = {
configured: false,
enabled: false,
is_running: false,
progress: null,
last_backup_at: null,
last_backup_status: null,
next_scheduled_run: null,
};
server.use(
http.get(`${API_BASE}/github-backup/status`, () => {
return HttpResponse.json(mockStatus);
})
);
const response = await fetch(`${API_BASE}/github-backup/status`);
const data = await response.json();
expect(data.configured).toBe(false);
expect(data.enabled).toBe(false);
});
it('GET /github-backup/logs returns empty list when no logs', async () => {
server.use(
http.get(`${API_BASE}/github-backup/logs`, () => {
return HttpResponse.json([]);
})
);
const response = await fetch(`${API_BASE}/github-backup/logs`);
const data = await response.json();
expect(data).toEqual([]);
});
it('GET /github-backup/logs returns log entries', async () => {
const mockLogs: GitHubBackupLog[] = [
{
id: 1,
config_id: 1,
started_at: '2026-01-27T10:00:00Z',
completed_at: '2026-01-27T10:01:00Z',
status: 'success',
trigger: 'manual',
commit_sha: 'abc123',
files_changed: 5,
error_message: null,
},
];
server.use(
http.get(`${API_BASE}/github-backup/logs`, () => {
return HttpResponse.json(mockLogs);
})
);
const response = await fetch(`${API_BASE}/github-backup/logs`);
const data = await response.json();
expect(data.length).toBe(1);
expect(data[0].status).toBe('success');
});
it('POST /github-backup/run returns 404 when not configured', async () => {
server.use(
http.post(`${API_BASE}/github-backup/run`, () => {
return HttpResponse.json(
{ detail: 'No configuration found' },
{ status: 404 }
);
})
);
const response = await fetch(`${API_BASE}/github-backup/run`, {
method: 'POST',
});
expect(response.status).toBe(404);
});
it('POST /github-backup/test returns success on valid credentials', async () => {
server.use(
http.post(`${API_BASE}/github-backup/test`, () => {
return HttpResponse.json({
success: true,
message: 'Connection successful',
repo_name: 'test/repo',
default_branch: 'main',
});
})
);
const response = await fetch(
`${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=ghp_test`,
{ method: 'POST' }
);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.repo_name).toBe('test/repo');
});
it('POST /github-backup/test returns failure on invalid credentials', async () => {
server.use(
http.post(`${API_BASE}/github-backup/test`, () => {
return HttpResponse.json({
success: false,
message: 'Authentication failed',
repo_name: null,
default_branch: null,
});
})
);
const response = await fetch(
`${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=invalid`,
{ method: 'POST' }
);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toBe('Authentication failed');
});
});
+112
View File
@@ -1296,6 +1296,78 @@ export interface NotificationProviderUpdate {
printer_id?: number | null;
}
// GitHub Backup types
export type ScheduleType = 'hourly' | 'daily' | 'weekly';
export interface GitHubBackupConfig {
id: number;
repository_url: string;
has_token: boolean;
branch: string;
schedule_enabled: boolean;
schedule_type: ScheduleType;
backup_kprofiles: boolean;
backup_cloud_profiles: boolean;
backup_settings: boolean;
enabled: boolean;
last_backup_at: string | null;
last_backup_status: string | null;
last_backup_message: string | null;
last_backup_commit_sha: string | null;
next_scheduled_run: string | null;
created_at: string;
updated_at: string;
}
export interface GitHubBackupConfigCreate {
repository_url: string;
access_token: string;
branch?: string;
schedule_enabled?: boolean;
schedule_type?: ScheduleType;
backup_kprofiles?: boolean;
backup_cloud_profiles?: boolean;
backup_settings?: boolean;
enabled?: boolean;
}
export interface GitHubBackupLog {
id: number;
config_id: number;
started_at: string;
completed_at: string | null;
status: string;
trigger: string;
commit_sha: string | null;
files_changed: number;
error_message: string | null;
}
export interface GitHubBackupStatus {
configured: boolean;
enabled: boolean;
is_running: boolean;
progress: string | null;
last_backup_at: string | null;
last_backup_status: string | null;
next_scheduled_run: string | null;
}
export interface GitHubTestConnectionResponse {
success: boolean;
message: string;
repo_name: string | null;
permissions: Record<string, boolean> | null;
}
export interface GitHubBackupTriggerResponse {
success: boolean;
message: string;
log_id: number | null;
commit_sha: string | null;
files_changed: number;
}
export interface NotificationTestRequest {
provider_type: ProviderType;
config: Record<string, unknown>;
@@ -3038,6 +3110,46 @@ export const api = {
used_meters: number;
}>;
}>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),
// GitHub Backup
getGitHubBackupConfig: () =>
request<GitHubBackupConfig | null>('/github-backup/config'),
saveGitHubBackupConfig: (config: GitHubBackupConfigCreate) =>
request<GitHubBackupConfig>('/github-backup/config', {
method: 'POST',
body: JSON.stringify(config),
}),
updateGitHubBackupConfig: (config: Partial<GitHubBackupConfigCreate>) =>
request<GitHubBackupConfig>('/github-backup/config', {
method: 'PATCH',
body: JSON.stringify(config),
}),
deleteGitHubBackupConfig: () =>
request<{ message: string }>('/github-backup/config', { method: 'DELETE' }),
testGitHubConnection: (repoUrl: string, token: string) =>
request<GitHubTestConnectionResponse>(
`/github-backup/test?repo_url=${encodeURIComponent(repoUrl)}&token=${encodeURIComponent(token)}`,
{ method: 'POST' }
),
testGitHubStoredConnection: () =>
request<GitHubTestConnectionResponse>('/github-backup/test-stored', { method: 'POST' }),
triggerGitHubBackup: () =>
request<GitHubBackupTriggerResponse>('/github-backup/run', { method: 'POST' }),
getGitHubBackupStatus: () =>
request<GitHubBackupStatus>('/github-backup/status'),
getGitHubBackupLogs: (limit: number = 50) =>
request<GitHubBackupLog[]>(`/github-backup/logs?limit=${limit}`),
clearGitHubBackupLogs: (keepLast: number = 10) =>
request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
};
// AMS History types
@@ -0,0 +1,746 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Github,
Play,
Clock,
CheckCircle,
XCircle,
Loader2,
Eye,
EyeOff,
ExternalLink,
RefreshCw,
Download,
Upload,
Database,
History,
SkipForward,
AlertTriangle,
Trash2,
} from 'lucide-react';
import { api } from '../api/client';
import type {
GitHubBackupConfig,
GitHubBackupConfigCreate,
GitHubBackupLog,
GitHubBackupStatus,
GitHubBackupTriggerResponse,
ScheduleType,
CloudAuthStatus,
} from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
import { BackupModal } from './BackupModal';
import { RestoreModal } from './RestoreModal';
import { useToast } from '../contexts/ToastContext';
interface StatusBadgeProps {
status: string | null;
}
function StatusBadge({ status }: StatusBadgeProps) {
if (!status) return null;
const styles: Record<string, string> = {
success: 'bg-green-500/20 text-green-400',
failed: 'bg-red-500/20 text-red-400',
skipped: 'bg-yellow-500/20 text-yellow-400',
running: 'bg-blue-500/20 text-blue-400',
};
const icons: Record<string, React.ReactNode> = {
success: <CheckCircle className="w-3 h-3" />,
failed: <XCircle className="w-3 h-3" />,
skipped: <SkipForward className="w-3 h-3" />,
running: <Loader2 className="w-3 h-3 animate-spin" />,
};
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${styles[status] || 'bg-gray-500/20 text-gray-400'}`}>
{icons[status]}
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString();
}
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 0) {
const absMins = Math.abs(diffMins);
if (absMins < 60) return `${absMins}m ago`;
const hours = Math.floor(absMins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
} else {
if (diffMins < 60) return `in ${diffMins}m`;
const hours = Math.floor(diffMins / 60);
if (hours < 24) return `in ${hours}h`;
const days = Math.floor(hours / 24);
return `in ${days}d`;
}
}
export function GitHubBackupSettings() {
const queryClient = useQueryClient();
const { showToast } = useToast();
// Local state for form
const [repoUrl, setRepoUrl] = useState('');
const [accessToken, setAccessToken] = useState('');
const [branch, setBranch] = useState('main');
const [showToken, setShowToken] = useState(false);
const [scheduleEnabled, setScheduleEnabled] = useState(false);
const [scheduleType, setScheduleType] = useState<ScheduleType>('daily');
const [backupKProfiles, setBackupKProfiles] = useState(true);
const [backupCloudProfiles, setBackupCloudProfiles] = useState(true);
const [backupSettings, setBackupSettings] = useState(false);
const [enabled, setEnabled] = useState(true);
// Local backup modals
const [showBackupModal, setShowBackupModal] = useState(false);
const [showRestoreModal, setShowRestoreModal] = useState(false);
// Test connection state
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
// Auto-save debounce
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const isInitializedRef = useRef(false);
// Queries
const { data: config, isLoading: configLoading } = useQuery<GitHubBackupConfig | null>({
queryKey: ['github-backup-config'],
queryFn: api.getGitHubBackupConfig,
});
const { data: status } = useQuery<GitHubBackupStatus>({
queryKey: ['github-backup-status'],
queryFn: api.getGitHubBackupStatus,
refetchInterval: (query) => query.state.data?.is_running ? 500 : 10000, // Poll fast during backup
});
const { data: logs } = useQuery<GitHubBackupLog[]>({
queryKey: ['github-backup-logs'],
queryFn: () => api.getGitHubBackupLogs(20),
});
const { data: cloudStatus } = useQuery<CloudAuthStatus>({
queryKey: ['cloud-status'],
queryFn: api.getCloudStatus,
});
// Initialize form from config
useEffect(() => {
if (config) {
setRepoUrl(config.repository_url);
setBranch(config.branch);
setScheduleEnabled(config.schedule_enabled);
setScheduleType(config.schedule_type);
setBackupKProfiles(config.backup_kprofiles);
setBackupCloudProfiles(config.backup_cloud_profiles);
setBackupSettings(config.backup_settings);
setEnabled(config.enabled);
setAccessToken(''); // Don't show stored token
// Mark as initialized after a tick to avoid auto-save on initial load
setTimeout(() => { isInitializedRef.current = true; }, 100);
}
}, [config]);
// Auto-save function for existing configs
const autoSave = useCallback(async (includeToken: boolean = false) => {
if (!config?.has_token) return; // Only auto-save if config already exists
try {
if (includeToken && accessToken) {
// Full save with new token
await api.saveGitHubBackupConfig({
repository_url: repoUrl,
access_token: accessToken,
branch,
schedule_enabled: scheduleEnabled,
schedule_type: scheduleType,
backup_kprofiles: backupKProfiles,
backup_cloud_profiles: backupCloudProfiles,
backup_settings: backupSettings,
enabled,
});
setAccessToken(''); // Clear after save
showToast('Token updated');
} else {
// Update without token
await api.updateGitHubBackupConfig({
repository_url: repoUrl,
branch,
schedule_enabled: scheduleEnabled,
schedule_type: scheduleType,
backup_kprofiles: backupKProfiles,
backup_cloud_profiles: backupCloudProfiles,
backup_settings: backupSettings,
enabled,
});
showToast('Settings saved');
}
queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
} catch (error) {
showToast(`Failed to save: ${(error as Error).message}`, 'error');
}
}, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, enabled, queryClient, showToast]);
// Auto-save effect for existing configs (debounced)
useEffect(() => {
if (!isInitializedRef.current || !config?.has_token) return;
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
autoSaveTimerRef.current = setTimeout(() => {
autoSave(false);
}, 500);
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, [repoUrl, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, enabled, autoSave, config?.has_token]);
// Auto-save token when it changes (with longer debounce)
useEffect(() => {
if (!isInitializedRef.current || !config?.has_token || !accessToken) return;
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
autoSaveTimerRef.current = setTimeout(() => {
autoSave(true);
}, 1000);
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, [accessToken, autoSave, config?.has_token]);
// Mutations
const saveConfigMutation = useMutation({
mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
showToast('GitHub backup enabled');
setAccessToken('');
isInitializedRef.current = true;
},
onError: (error: Error) => {
showToast(`Failed to save: ${error.message}`, 'error');
},
});
const triggerBackupMutation = useMutation<GitHubBackupTriggerResponse, Error>({
mutationFn: api.triggerGitHubBackup,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
if (result.success) {
if (result.files_changed > 0) {
showToast(`Backup complete - ${result.files_changed} files updated`);
} else {
showToast('Backup skipped - no changes');
}
} else {
showToast(`Backup failed: ${result.message}`, 'error');
}
},
onError: (error: Error) => {
showToast(`Backup failed: ${error.message}`, 'error');
},
});
const clearLogsMutation = useMutation<{ deleted: number; message: string }, Error>({
mutationFn: () => api.clearGitHubBackupLogs(10),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
showToast(`Cleared ${result.deleted} logs`);
},
onError: (error: Error) => {
showToast(`Failed to clear logs: ${error.message}`, 'error');
},
});
const handleTestConnection = async () => {
setTestLoading(true);
setTestResult(null);
try {
let result;
// If user entered a new token, test with those credentials
if (accessToken) {
if (!repoUrl) {
showToast('Enter repository URL', 'error');
setTestLoading(false);
return;
}
result = await api.testGitHubConnection(repoUrl, accessToken);
} else if (config?.has_token) {
// Use stored credentials
result = await api.testGitHubStoredConnection();
} else {
showToast('Enter repository URL and access token', 'error');
setTestLoading(false);
return;
}
setTestResult({ success: result.success, message: result.message });
} catch (error) {
setTestResult({ success: false, message: (error as Error).message });
} finally {
setTestLoading(false);
}
};
// Initial setup save (only for new configs)
const handleInitialSetup = () => {
if (!repoUrl) {
showToast('Repository URL is required', 'error');
return;
}
if (!accessToken) {
showToast('Access token is required', 'error');
return;
}
saveConfigMutation.mutate({
repository_url: repoUrl,
access_token: accessToken,
branch,
schedule_enabled: scheduleEnabled,
schedule_type: scheduleType,
backup_kprofiles: backupKProfiles,
backup_cloud_profiles: backupCloudProfiles,
backup_settings: backupSettings,
enabled,
});
};
if (configLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - GitHub Backup */}
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Github className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">GitHub Backup</h2>
</div>
{config && cloudStatus?.is_authenticated && (
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray">Enabled</span>
<Toggle
checked={enabled}
onChange={setEnabled}
/>
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Bambu Cloud required message */}
{!cloudStatus?.is_authenticated ? (
<div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-yellow-400">
Bambu Cloud login required. Sign in under Profiles → Cloud Profiles to enable GitHub backup.
</p>
</div>
) : (
<>
<p className="text-sm text-bambu-gray">
Automatically sync your profiles to a private GitHub repository for backup and version history.
</p>
{/* Repository URL */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
Repository URL
</label>
<input
type="text"
value={repoUrl}
onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); }}
placeholder="https://github.com/username/bambuddy-backup"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
{/* Access Token */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
Personal Access Token {config?.has_token && <span className="text-green-400">(saved)</span>}
</label>
<div className="relative">
<input
type={showToken ? 'text' : 'password'}
value={accessToken}
onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}
placeholder={config?.has_token ? 'Enter new token to update' : 'ghp_xxxxxxxxxxxx'}
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
>
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-bambu-gray mt-1">
Fine-grained token with Contents read/write permission
</p>
</div>
{/* Branch - inline with schedule */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">Branch</label>
<input
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Auto-backup</label>
<select
value={scheduleEnabled ? scheduleType : 'disabled'}
onChange={(e) => {
if (e.target.value === 'disabled') {
setScheduleEnabled(false);
} else {
setScheduleEnabled(true);
setScheduleType(e.target.value as ScheduleType);
}
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="disabled">Manual only</option>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
</div>
{/* What to backup */}
<div>
<label className="block text-sm text-bambu-gray mb-2">Include in backup</label>
<div className="space-y-2">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={backupKProfiles}
onChange={(e) => setBackupKProfiles(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-white text-sm">K-Profiles</span>
<p className="text-xs text-bambu-gray">Pressure advance calibration from connected printers</p>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={backupCloudProfiles}
onChange={(e) => setBackupCloudProfiles(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
disabled={!cloudStatus?.is_authenticated}
/>
<div>
<span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>Cloud Profiles</span>
<p className="text-xs text-bambu-gray">Filament, printer, and process presets from Bambu Cloud</p>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={backupSettings}
onChange={(e) => setBackupSettings(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-white text-sm">App Settings</span>
<p className="text-xs text-bambu-gray">Bambuddy configuration (excludes sensitive data)</p>
</div>
</label>
</div>
</div>
{/* Test + Status + Actions */}
<div className="border-t border-bambu-dark-tertiary pt-4 space-y-3">
{/* Status line */}
{status?.configured && (
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-bambu-gray">
{status.last_backup_at ? (
<>
<span>Last backup: {formatRelativeTime(status.last_backup_at)}</span>
<StatusBadge status={status.last_backup_status} />
</>
) : (
<span>No backups yet</span>
)}
</div>
{status.next_scheduled_run && (
<span className="text-bambu-gray">
<Clock className="w-3 h-3 inline mr-1" />
Next: {formatRelativeTime(status.next_scheduled_run)}
</span>
)}
</div>
)}
{/* Test result */}
{testResult && (
<div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
{testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{testResult.message}
</div>
)}
{/* Action buttons */}
<div className="flex flex-wrap items-center gap-2">
{status?.configured ? (
<>
{(triggerBackupMutation.isPending || status.is_running) ? (
<div className="flex items-center gap-2 text-bambu-green">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">{status.progress || 'Starting backup...'}</span>
</div>
) : (
<>
<Button
variant="primary"
size="sm"
onClick={() => triggerBackupMutation.mutate()}
disabled={!config?.enabled}
>
<Play className="w-4 h-4" />
Backup Now
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleTestConnection}
disabled={testLoading}
>
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Test
</Button>
</>
)}
</>
) : (
<>
<Button
variant="primary"
size="sm"
onClick={handleInitialSetup}
disabled={saveConfigMutation.isPending || !repoUrl || !accessToken}
>
{saveConfigMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
Enable Backup
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleTestConnection}
disabled={testLoading || !repoUrl || !accessToken}
>
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Test Connection
</Button>
</>
)}
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* Backup History - only show if configured and has logs */}
{logs && logs.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<History className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">History</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => clearLogsMutation.mutate()}
disabled={clearLogsMutation.isPending}
>
<Trash2 className="w-4 h-4" />
Clear
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
<th className="text-left py-2 px-2">Date</th>
<th className="text-left py-2 px-2">Status</th>
<th className="text-left py-2 px-2">Commit</th>
</tr>
</thead>
<tbody>
{logs.slice(0, 10).map((log) => (
<tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
<td className="py-2 px-2 text-white">{formatDateTime(log.started_at)}</td>
<td className="py-2 px-2"><StatusBadge status={log.status} /></td>
<td className="py-2 px-2">
{log.commit_sha ? (
<a
href={`${config?.repository_url}/commit/${log.commit_sha}`}
target="_blank"
rel="noopener noreferrer"
className="text-bambu-green hover:underline inline-flex items-center gap-1"
>
{log.commit_sha.substring(0, 7)}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-bambu-gray">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
{/* Right Column - Local Backup */}
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">Local Backup</h2>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
Export or import your Bambuddy data as a local file for manual backup or migration.
</p>
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
<div>
<p className="text-white">Export Data</p>
<p className="text-sm text-bambu-gray">
Download all settings, printers, and profiles
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowBackupModal(true)}
>
<Download className="w-4 h-4" />
Export
</Button>
</div>
<div className="flex items-center justify-between py-3">
<div>
<p className="text-white">Import Backup</p>
<p className="text-sm text-bambu-gray">
Restore from a previous export file
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowRestoreModal(true)}
>
<Upload className="w-4 h-4" />
Import
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Modals */}
{showBackupModal && (
<BackupModal
onClose={() => setShowBackupModal(false)}
onExport={async (categories) => {
setShowBackupModal(false);
try {
const { blob, filename } = await api.exportBackup(categories);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast('Backup downloaded successfully');
} catch {
showToast('Failed to create backup', 'error');
}
}}
/>
)}
{showRestoreModal && (
<RestoreModal
onClose={() => setShowRestoreModal(false)}
onRestore={async (file, overwrite) => {
return await api.importBackup(file, overwrite);
}}
onSuccess={() => {
setShowRestoreModal(false);
showToast('Backup restored successfully');
queryClient.invalidateQueries();
}}
/>
)}
</div>
);
}
+55 -116
View File
@@ -1,11 +1,11 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { formatDateOnly } from '../utils/date';
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
@@ -15,11 +15,10 @@ import { AddNotificationModal } from '../components/AddNotificationModal';
import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
import { NotificationLogViewer } from '../components/NotificationLogViewer';
import { ConfirmModal } from '../components/ConfirmModal';
import { BackupModal } from '../components/BackupModal';
import { RestoreModal } from '../components/RestoreModal';
import { SpoolmanSettings } from '../components/SpoolmanSettings';
import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
import { APIBrowser } from '../components/APIBrowser';
import { virtualPrinterApi } from '../api/client';
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
@@ -29,7 +28,7 @@ import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, t
import { useState, useEffect, useRef, useCallback } from 'react';
import { Palette } from 'lucide-react';
const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users'] as const;
const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
type TabType = typeof validTabs[number];
export function SettingsPage() {
@@ -37,7 +36,7 @@ export function SettingsPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { t, i18n } = useTranslation();
const { showToast, showPersistentToast, dismissToast } = useToast();
const { showToast } = useToast();
const { authEnabled, user, refreshAuth } = useAuth();
const {
mode,
@@ -85,8 +84,6 @@ export function SettingsPage() {
const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
const [showBackupModal, setShowBackupModal] = useState(false);
const [showRestoreModal, setShowRestoreModal] = useState(false);
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false);
@@ -254,6 +251,18 @@ export function SettingsPage() {
refetchInterval: activeTab === 'network' ? 5000 : false, // Poll every 5s when on Network tab
});
// GitHub backup status for Backup tab indicator
const { data: githubBackupStatus } = useQuery<GitHubBackupStatus>({
queryKey: ['github-backup-status'],
queryFn: api.getGitHubBackupStatus,
});
// Cloud auth status for Backup tab indicator
const { data: cloudAuthStatus } = useQuery<CloudAuthStatus>({
queryKey: ['cloud-status'],
queryFn: api.getCloudStatus,
});
const applyUpdateMutation = useMutation({
mutationFn: api.applyUpdate,
onSuccess: (data) => {
@@ -694,6 +703,18 @@ export function SettingsPage() {
<span className={`w-2 h-2 rounded-full ${authEnabled ? 'bg-green-400' : 'bg-gray-500'}`} />
)}
</button>
<button
onClick={() => handleTabChange('backup')}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
activeTab === 'backup'
? 'text-bambu-green border-bambu-green'
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
}`}
>
<Database className="w-4 h-4" />
Backup
<span className={`w-2 h-2 rounded-full ${cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
</button>
</div>
{/* General Tab */}
@@ -1390,58 +1411,22 @@ export function SettingsPage() {
<h2 className="text-lg font-semibold text-white">Data Management</h2>
</CardHeader>
<CardContent className="space-y-4">
{/* Backup/Restore */}
<div className="flex items-center justify-between">
<div>
<p className="text-white">Backup Data</p>
<p className="text-white">Clear Notification Logs</p>
<p className="text-sm text-bambu-gray">
Export settings, providers, printers, and more
Delete notification logs older than 30 days
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowBackupModal(true)}
onClick={() => setShowClearLogsConfirm(true)}
>
<Download className="w-4 h-4" />
Export
<Trash2 className="w-4 h-4" />
Clear
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">Restore Backup</p>
<p className="text-sm text-bambu-gray">
Import settings from a backup file with duplicate handling options
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowRestoreModal(true)}
>
<Upload className="w-4 h-4" />
Restore
</Button>
</div>
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white">Clear Notification Logs</p>
<p className="text-sm text-bambu-gray">
Delete notification logs older than 30 days
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowClearLogsConfirm(true)}
>
<Trash2 className="w-4 h-4" />
Clear
</Button>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">Reset UI Preferences</p>
@@ -1458,6 +1443,22 @@ export function SettingsPage() {
Reset
</Button>
</div>
<div className="flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary">
<div>
<p className="text-white">Backup & Restore</p>
<p className="text-sm text-bambu-gray">
Export/import settings and configure GitHub backup
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleTabChange('backup')}
>
<Database className="w-4 h-4" />
Go to Backup
</Button>
</div>
</CardContent>
</Card>
</div>
@@ -2877,73 +2878,6 @@ export function SettingsPage() {
/>
)}
{/* Backup Modal */}
{showBackupModal && (
<BackupModal
onClose={() => setShowBackupModal(false)}
onExport={async (categories) => {
setShowBackupModal(false);
const toastId = 'backup-progress';
const includesArchives = categories.archives;
// Show persistent loading toast for archive backups (can be large)
if (includesArchives) {
showPersistentToast(toastId, t('backup.preparing', { defaultValue: 'Preparing backup...' }), 'loading');
}
try {
const { blob, filename } = await api.exportBackup(categories);
// Dismiss loading toast before download starts
if (includesArchives) {
dismissToast(toastId);
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast(t('backup.downloaded', { defaultValue: 'Backup downloaded' }), 'success');
} catch {
// Dismiss loading toast on error
if (includesArchives) {
dismissToast(toastId);
}
showToast(t('backup.failed', { defaultValue: 'Failed to create backup' }), 'error');
}
}}
/>
)}
{/* Restore Modal */}
{showRestoreModal && (
<RestoreModal
onClose={() => setShowRestoreModal(false)}
onRestore={async (file, overwrite) => {
return await api.importBackup(file, overwrite);
}}
onSuccess={() => {
// Reset local settings to force re-sync from restored data
setLocalSettings(null);
isInitialLoadRef.current = true;
// Use resetQueries to clear cached data completely
// This ensures fresh data is fetched, not stale cache
queryClient.resetQueries({ queryKey: ['settings'] });
// Invalidate other queries that may have changed
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
queryClient.invalidateQueries({ queryKey: ['external-links'] });
queryClient.invalidateQueries({ queryKey: ['printers'] });
queryClient.invalidateQueries({ queryKey: ['filaments'] });
queryClient.invalidateQueries({ queryKey: ['maintenance-types'] });
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
}}
/>
)}
{/* Release Notes Modal */}
{showReleaseNotes && updateCheck?.release_notes && (
<div
@@ -3197,6 +3131,11 @@ export function SettingsPage() {
</div>
)}
{/* Backup Tab */}
{activeTab === 'backup' && (
<GitHubBackupSettings />
)}
{/* Disable Authentication Confirmation Modal */}
{showDisableAuthConfirm && (
<ConfirmModal
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>