maziggy 1 month ago
parent
commit
774a639e9a

+ 1 - 0
.gitignore

@@ -74,3 +74,4 @@ spoolbuddy/ssh/
 debug_logs/
 debug_logs/
 db_backup/
 db_backup/
 support-packages/
 support-packages/
+backups/

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **AMS Drying Support for P2S** — Remote AMS drying and queue auto-drying now work on P2S printers with firmware 01.02.00.00 or later. Previously P2S was hard-blocked from the drying feature.
 - **AMS Drying Support for P2S** — Remote AMS drying and queue auto-drying now work on P2S printers with firmware 01.02.00.00 or later. Previously P2S was hard-blocked from the drying feature.
 
 
 ### New Features
 ### New Features
+- **Scheduled Local Backups** ([#884](https://github.com/maziggy/bambuddy/issues/884)) — Settings → Backup now includes a "Scheduled Backups" card that automatically creates complete backup snapshots (database + all data directories) on an hourly, daily, or weekly schedule with configurable time-of-day and retention count. Backups are written as ZIP files to a configurable output directory (defaults to `DATA_DIR/backups/`), which Docker users can mount as a volume to their NAS or external storage. Each backup in the list can be downloaded, restored directly from the UI, or deleted individually. The manual backup download endpoint has also been optimized to stream directly from disk instead of loading the entire ZIP into memory, significantly reducing download wait times for large backups. Works with both SQLite and PostgreSQL installs. Fully localized across all 7 UI languages.
 - **SpoolBuddy Device Management Tab** — Settings → SpoolBuddy now lists every registered SpoolBuddy device with live connection status, system details (firmware, IP, CPU temperature, memory, disk, OS, daemon and system uptime), hardware health flags (NFC / scale OK), and an Unregister button gated by a confirm modal. Previously, when a daemon crash caused SpoolBuddy to register itself twice, the kiosk UI silently used only the first device and there was no UI path to delete the orphaned duplicate — administrators had to delete the row directly in the database. A new `DELETE /spoolbuddy/devices/{device_id}` endpoint (gated by `inventory:delete`) handles the removal and broadcasts a `spoolbuddy_unregistered` websocket event so other tabs refresh immediately. A yellow warning banner appears when more than one device is registered to flag likely crash-duplicates. If an online device is accidentally unregistered, it will re-register itself on its next heartbeat. The Settings tab header also shows a device-count badge and a green/gray bullet indicating whether at least one registered device is online. Fully localized in English, German, and Japanese.
 - **SpoolBuddy Device Management Tab** — Settings → SpoolBuddy now lists every registered SpoolBuddy device with live connection status, system details (firmware, IP, CPU temperature, memory, disk, OS, daemon and system uptime), hardware health flags (NFC / scale OK), and an Unregister button gated by a confirm modal. Previously, when a daemon crash caused SpoolBuddy to register itself twice, the kiosk UI silently used only the first device and there was no UI path to delete the orphaned duplicate — administrators had to delete the row directly in the database. A new `DELETE /spoolbuddy/devices/{device_id}` endpoint (gated by `inventory:delete`) handles the removal and broadcasts a `spoolbuddy_unregistered` websocket event so other tabs refresh immediately. A yellow warning banner appears when more than one device is registered to flag likely crash-duplicates. If an online device is accidentally unregistered, it will re-register itself on its next heartbeat. The Settings tab header also shows a device-count badge and a green/gray bullet indicating whether at least one registered device is online. Fully localized in English, German, and Japanese.
 - **Print Files Directly from Project View** ([#930](https://github.com/maziggy/bambuddy/issues/930)) — The project detail page now lists the printable files from every linked library folder inline, with Play (Print Now) and CalendarPlus (Add to Queue) action buttons on each sliced file (`.gcode` and `.gcode.3mf`). No more round-tripping through File Manager to reprint project files. Prints triggered from the project view are automatically associated with the originating project, so the resulting archive shows up in that project's history without any manual assignment. Backend adds a `project_id` query parameter to `GET /library/files` that returns all files across linked folders in a single query (replacing the prior one-request-per-folder pattern) and validates `project_id` on both the direct-print and queue paths so a stale ID yields a 404 instead of a FK-constraint 500. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.
 - **Print Files Directly from Project View** ([#930](https://github.com/maziggy/bambuddy/issues/930)) — The project detail page now lists the printable files from every linked library folder inline, with Play (Print Now) and CalendarPlus (Add to Queue) action buttons on each sliced file (`.gcode` and `.gcode.3mf`). No more round-tripping through File Manager to reprint project files. Prints triggered from the project view are automatically associated with the originating project, so the resulting archive shows up in that project's history without any manual assignment. Backend adds a `project_id` query parameter to `GET /library/files` that returns all files across linked folders in a single query (replacing the prior one-request-per-folder pattern) and validates `project_id` on both the direct-print and queue paths so a stale ID yields a 404 instead of a FK-constraint 500. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.
 - **Printers Page Search and Filters** ([#852](https://github.com/maziggy/bambuddy/issues/852)) — The Printers page now has a live search bar and two filter dropdowns (status and location) to make finding specific printers in large setups easier, especially on mobile where Ctrl+F is impractical. Search matches printer name, model, location, and serial number (case-insensitive, whitespace-trimmed) and has a clear button. The status filter covers All / Printing / Paused / Idle / Finished / Error / Offline and is reactive to WebSocket status updates via a React Query cache subscription — so a print finishing while "Printing" is selected immediately removes the printer from the filtered list. The location filter is only shown when at least one printer has a location configured. All three filters are combinable; the controls are hidden when no printers are configured yet; and an empty-state message appears when no printer matches the current search/filters. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.
 - **Printers Page Search and Filters** ([#852](https://github.com/maziggy/bambuddy/issues/852)) — The Printers page now has a live search bar and two filter dropdowns (status and location) to make finding specific printers in large setups easier, especially on mobile where Ctrl+F is impractical. Search matches printer name, model, location, and serial number (case-insensitive, whitespace-trimmed) and has a clear button. The status filter covers All / Printing / Paused / Idle / Finished / Error / Offline and is reactive to WebSocket status updates via a React Query cache subscription — so a print finishing while "Printing" is selected immediately removes the printer from the filtered list. The location filter is only shown when at least one printer has a location configured. All three filters are combinable; the controls are hidden when no printers are configured yet; and an empty-state message appears when no printer matches the current search/filters. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.

+ 1 - 0
README.md

@@ -207,6 +207,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - **Local Profiles** - Import OrcaSlicer presets (`.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, `.json`) without Bambu Cloud
 - **Local Profiles** - Import OrcaSlicer presets (`.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, `.json`) without Bambu Cloud
 - K-profiles (pressure advance)
 - K-profiles (pressure advance)
 - **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
 - **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
+- **Scheduled local backups** - Automatic backup snapshots on hourly/daily/weekly schedule with retention management and NAS-mountable output
 - External sidebar links
 - External sidebar links
 - Webhooks & API keys
 - Webhooks & API keys
 - Interactive API browser with live testing
 - Interactive API browser with live testing

+ 104 - 0
backend/app/api/routes/local_backup.py

@@ -0,0 +1,104 @@
+"""API routes for scheduled local backups."""
+
+import logging
+
+from fastapi import APIRouter, Path
+from fastapi.responses import FileResponse, JSONResponse
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.services.local_backup import local_backup_service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/local-backup", tags=["local-backup"])
+
+
+@router.get("/status")
+async def get_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Get local backup scheduler status and configuration."""
+    settings = await local_backup_service._load_settings()
+    status = local_backup_service.get_status()
+    return {
+        **status,
+        "enabled": settings["enabled"],
+        "schedule": settings["schedule"],
+        "time": settings["time"],
+        "retention": settings["retention"],
+        "path": settings["path"],
+        "default_path": str(local_backup_service._resolve_backup_dir("")),
+    }
+
+
+@router.post("/run")
+async def trigger_backup(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Trigger a local backup immediately."""
+    result = await local_backup_service.run_backup()
+    return result
+
+
+@router.get("/backups")
+async def list_backups(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """List existing backup files."""
+    settings = await local_backup_service._load_settings()
+    return local_backup_service.list_backups(settings["path"])
+
+
+@router.get("/backups/{filename}/download")
+async def download_backup(
+    filename: str = Path(..., description="Backup filename to download"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Download a specific backup file."""
+    settings = await local_backup_service._load_settings()
+    file_path = local_backup_service.resolve_backup_file(settings["path"], filename)
+    if file_path is None:
+        return JSONResponse(status_code=404, content={"success": False, "message": "Backup not found"})
+    return FileResponse(
+        path=file_path,
+        filename=filename,
+        media_type="application/zip",
+    )
+
+
+@router.post("/backups/{filename}/restore")
+async def restore_backup(
+    filename: str = Path(..., description="Backup filename to restore"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
+):
+    """Restore from a scheduled backup file on the server."""
+    import io
+
+    from fastapi import UploadFile
+    from fastapi.responses import JSONResponse
+
+    settings = await local_backup_service._load_settings()
+    file_path = local_backup_service.resolve_backup_file(settings["path"], filename)
+    if file_path is None:
+        return JSONResponse(status_code=404, content={"success": False, "message": "Backup not found"})
+
+    from backend.app.api.routes.settings import restore_backup as settings_restore_backup
+    from backend.app.core.database import async_session
+
+    content = file_path.read_bytes()
+    upload = UploadFile(filename=filename, file=io.BytesIO(content))
+
+    async with async_session() as db:
+        return await settings_restore_backup(file=upload, db=db)
+
+
+@router.delete("/backups/{filename}")
+async def delete_backup(
+    filename: str = Path(..., description="Backup filename to delete"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Delete a specific backup file."""
+    settings = await local_backup_service._load_settings()
+    return local_backup_service.delete_backup(settings["path"], filename)

+ 117 - 106
backend/app/api/routes/settings.py

@@ -5,7 +5,7 @@ from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
 from fastapi import APIRouter, Depends, File, UploadFile
 from fastapi import APIRouter, Depends, File, UploadFile
-from fastapi.responses import JSONResponse, StreamingResponse
+from fastapi.responses import FileResponse, JSONResponse
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
@@ -350,129 +350,140 @@ async def get_homeassistant_settings(db: AsyncSession) -> dict:
     }
     }
 
 
 
 
-@router.get("/backup")
-async def create_backup(
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
-):
-    """Create a complete backup (database + all files) as a ZIP.
+async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]:
+    """Create a complete backup ZIP (database + all data directories).
 
 
-    Includes the database (SQLite file or PostgreSQL pg_dump) and all data directories.
+    If output_path is given, the ZIP is written there.
+    Otherwise a temporary file is created (caller must clean up).
+    Returns (zip_path, filename).
     """
     """
     import shutil
     import shutil
     import tempfile
     import tempfile
 
 
     from backend.app.core.db_dialect import is_sqlite
     from backend.app.core.db_dialect import is_sqlite
 
 
-    try:
-        base_dir = app_settings.base_dir
-
-        with tempfile.TemporaryDirectory() as temp_dir:
-            temp_path = Path(temp_dir)
-
-            if is_sqlite():
-                from sqlalchemy import text
-
-                from backend.app.core.database import engine
+    base_dir = app_settings.base_dir
+    filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
 
 
-                db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
+    with tempfile.TemporaryDirectory() as temp_dir:
+        temp_path = Path(temp_dir)
 
 
-                # Checkpoint WAL to ensure all data is in main db file
-                async with engine.begin() as conn:
-                    await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
+        if is_sqlite():
+            from sqlalchemy import text
 
 
-                # Copy database file
-                shutil.copy2(db_path, temp_path / "bambuddy.db")
-            else:
-                # PostgreSQL: export to a portable SQLite file via SQLAlchemy.
-                # This makes backups restorable on both SQLite and Postgres installs.
-                import sqlite3
+            from backend.app.core.database import engine
 
 
-                from backend.app.core.database import Base, engine
+            db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
 
 
-                backup_db_path = temp_path / "bambuddy.db"
-                dst = sqlite3.connect(str(backup_db_path))
-                metadata = Base.metadata
+            # Checkpoint WAL to ensure all data is in main db file
+            async with engine.begin() as conn:
+                await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
 
 
-                # Create tables in SQLite backup (simplified — just column names and types)
+            # Copy database file
+            shutil.copy2(db_path, temp_path / "bambuddy.db")
+        else:
+            # PostgreSQL: export to a portable SQLite file via SQLAlchemy.
+            # This makes backups restorable on both SQLite and Postgres installs.
+            import json
+            import sqlite3
+
+            from backend.app.core.database import Base, engine
+
+            backup_db_path = temp_path / "bambuddy.db"
+            dst = sqlite3.connect(str(backup_db_path))
+            metadata = Base.metadata
+
+            # Create tables in SQLite backup (simplified — just column names and types)
+            for table in metadata.sorted_tables:
+                cols = []
+                pk_cols = [col.name for col in table.columns if col.primary_key]
+                for col in table.columns:
+                    col_type = "TEXT"  # Default
+                    type_str = str(col.type).upper()
+                    if "INT" in type_str:
+                        col_type = "INTEGER"
+                    elif "FLOAT" in type_str or "REAL" in type_str or "NUMERIC" in type_str:
+                        col_type = "REAL"
+                    elif "BOOL" in type_str:
+                        col_type = "BOOLEAN"
+                    # Only inline PRIMARY KEY for single-column PKs
+                    pk = " PRIMARY KEY" if col.primary_key and len(pk_cols) == 1 else ""
+                    cols.append(f"{col.name} {col_type}{pk}")
+                # Add composite primary key constraint if needed
+                if len(pk_cols) > 1:
+                    cols.append(f"PRIMARY KEY ({', '.join(pk_cols)})")
+                dst.execute(f"CREATE TABLE IF NOT EXISTS {table.name} ({', '.join(cols)})")  # noqa: S608
+
+            # Export data from Postgres to SQLite
+            async with engine.connect() as conn:
                 for table in metadata.sorted_tables:
                 for table in metadata.sorted_tables:
-                    cols = []
-                    pk_cols = [col.name for col in table.columns if col.primary_key]
-                    for col in table.columns:
-                        col_type = "TEXT"  # Default
-                        type_str = str(col.type).upper()
-                        if "INT" in type_str:
-                            col_type = "INTEGER"
-                        elif "FLOAT" in type_str or "REAL" in type_str or "NUMERIC" in type_str:
-                            col_type = "REAL"
-                        elif "BOOL" in type_str:
-                            col_type = "BOOLEAN"
-                        # Only inline PRIMARY KEY for single-column PKs
-                        pk = " PRIMARY KEY" if col.primary_key and len(pk_cols) == 1 else ""
-                        cols.append(f"{col.name} {col_type}{pk}")
-                    # Add composite primary key constraint if needed
-                    if len(pk_cols) > 1:
-                        cols.append(f"PRIMARY KEY ({', '.join(pk_cols)})")
-                    dst.execute(f"CREATE TABLE IF NOT EXISTS {table.name} ({', '.join(cols)})")  # noqa: S608
-
-                # Export data from Postgres to SQLite
-                async with engine.connect() as conn:
-                    for table in metadata.sorted_tables:
-                        result = await conn.execute(table.select())
-                        rows = result.fetchall()
-                        if not rows:
-                            continue
-                        columns = list(result.keys())
-                        placeholders = ", ".join(["?"] * len(columns))
-                        col_list = ", ".join(columns)
-                        insert_sql = f"INSERT INTO {table.name} ({col_list}) VALUES ({placeholders})"  # noqa: S608  # nosec B608 — table/column names from ORM metadata, not user input
-                        import json
+                    result = await conn.execute(table.select())
+                    rows = result.fetchall()
+                    if not rows:
+                        continue
+                    columns = list(result.keys())
+                    placeholders = ", ".join(["?"] * len(columns))
+                    col_list = ", ".join(columns)
+                    insert_sql = f"INSERT INTO {table.name} ({col_list}) VALUES ({placeholders})"  # noqa: S608  # nosec B608 — table/column names from ORM metadata, not user input
+
+                    def _serialize_row(row):
+                        return tuple(json.dumps(v) if isinstance(v, (list, dict)) else v for v in row)
+
+                    dst.executemany(insert_sql, [_serialize_row(row) for row in rows])
+
+            dst.commit()
+            dst.close()
+            logger.info("PostgreSQL backup exported to portable SQLite format")
+
+        # Copy data directories (if they exist)
+        dirs_to_backup = [
+            ("archive", base_dir / "archive"),
+            ("virtual_printer", base_dir / "virtual_printer"),
+            ("plate_calibration", app_settings.plate_calibration_dir),
+            ("icons", base_dir / "icons"),
+            ("projects", base_dir / "projects"),
+        ]
+
+        for name, src_dir in dirs_to_backup:
+            if src_dir.exists() and any(src_dir.iterdir()):
+                try:
+                    shutil.copytree(src_dir, temp_path / name)
+                except shutil.Error as e:
+                    logger.warning("Some files in %s could not be copied: %s", name, e)
+                except PermissionError as e:
+                    logger.warning("Permission denied copying %s: %s", name, e)
+
+        # Create ZIP
+        if output_path is not None:
+            zip_file = output_path / filename
+        else:
+            zip_file = Path(tempfile.mktemp(suffix=".zip"))  # noqa: S306
 
 
-                        def _serialize_row(row):
-                            return tuple(json.dumps(v) if isinstance(v, (list, dict)) else v for v in row)
+        with zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) as zf:
+            for file_path in temp_path.rglob("*"):
+                if file_path.is_file():
+                    arcname = file_path.relative_to(temp_path)
+                    zf.write(file_path, arcname)
 
 
-                        dst.executemany(insert_sql, [_serialize_row(row) for row in rows])
+    return zip_file, filename
 
 
-                dst.commit()
-                dst.close()
-                logger.info("PostgreSQL backup exported to portable SQLite format")
 
 
-            # 3. Copy data directories (if they exist)
-            dirs_to_backup = [
-                ("archive", base_dir / "archive"),
-                ("virtual_printer", base_dir / "virtual_printer"),
-                ("plate_calibration", app_settings.plate_calibration_dir),
-                ("icons", base_dir / "icons"),
-                ("projects", base_dir / "projects"),
-            ]
+@router.get("/backup")
+async def create_backup(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Create a complete backup (database + all files) as a ZIP download."""
+    from starlette.background import BackgroundTask
 
 
-            for name, src_dir in dirs_to_backup:
-                if src_dir.exists() and any(src_dir.iterdir()):
-                    try:
-                        shutil.copytree(src_dir, temp_path / name)
-                    except shutil.Error as e:
-                        # Some files may have restricted permissions (e.g., SSL keys)
-                        # Log the error but continue with partial backup
-                        logger.warning("Some files in %s could not be copied: %s", name, e)
-                    except PermissionError as e:
-                        logger.warning("Permission denied copying %s: %s", name, e)
-
-            # 4. Create ZIP
-            zip_buffer = io.BytesIO()
-            with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
-                for file_path in temp_path.rglob("*"):
-                    if file_path.is_file():
-                        arcname = file_path.relative_to(temp_path)
-                        zf.write(file_path, arcname)
-
-            zip_buffer.seek(0)
-            filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
-
-            return StreamingResponse(
-                zip_buffer,
-                media_type="application/zip",
-                headers={"Content-Disposition": f"attachment; filename={filename}"},
-            )
+    try:
+        zip_file, filename = await create_backup_zip()
+        return FileResponse(
+            path=zip_file,
+            filename=filename,
+            media_type="application/zip",
+            background=BackgroundTask(lambda: zip_file.unlink(missing_ok=True)),
+        )
     except Exception as e:
     except Exception as e:
         logger.error("Backup failed: %s", e, exc_info=True)
         logger.error("Backup failed: %s", e, exc_info=True)
         return JSONResponse(
         return JSONResponse(
@@ -524,7 +535,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
             if fks:
             if fks:
                 saved_fks[table.name] = fks
                 saved_fks[table.name] = fks
                 for fk in fks:
                 for fk in fks:
-                    table.constraints.remove(fk)
+                    table.constraints.discard(fk)
 
 
         async with pg_engine.begin() as conn:
         async with pg_engine.begin() as conn:
             await conn.run_sync(metadata.drop_all)
             await conn.run_sync(metadata.drop_all)

+ 7 - 0
backend/app/main.py

@@ -28,6 +28,7 @@ from backend.app.api.routes import (
     inventory,
     inventory,
     kprofiles,
     kprofiles,
     library,
     library,
+    local_backup,
     local_presets,
     local_presets,
     maintenance,
     maintenance,
     metrics,
     metrics,
@@ -63,6 +64,7 @@ from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_se
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.homeassistant import homeassistant_service
+from backend.app.services.local_backup import local_backup_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
 from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
@@ -3908,6 +3910,9 @@ async def lifespan(app: FastAPI):
     # Start the GitHub backup scheduler
     # Start the GitHub backup scheduler
     await github_backup_service.start_scheduler()
     await github_backup_service.start_scheduler()
 
 
+    # Start the local backup scheduler
+    await local_backup_service.start_scheduler()
+
     # Start AMS history recording
     # Start AMS history recording
     start_ams_history_recording()
     start_ams_history_recording()
 
 
@@ -3942,6 +3947,7 @@ async def lifespan(app: FastAPI):
     smart_plug_manager.stop_scheduler()
     smart_plug_manager.stop_scheduler()
     notification_service.stop_digest_scheduler()
     notification_service.stop_digest_scheduler()
     github_backup_service.stop_scheduler()
     github_backup_service.stop_scheduler()
+    local_backup_service.stop_scheduler()
     stop_ams_history_recording()
     stop_ams_history_recording()
     stop_runtime_tracking()
     stop_runtime_tracking()
     stop_spoolbuddy_watchdog()
     stop_spoolbuddy_watchdog()
@@ -4173,6 +4179,7 @@ app.include_router(discovery.router, prefix=app_settings.api_prefix)
 app.include_router(pending_uploads.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(firmware.router, prefix=app_settings.api_prefix)
 app.include_router(github_backup.router, prefix=app_settings.api_prefix)
 app.include_router(github_backup.router, prefix=app_settings.api_prefix)
+app.include_router(local_backup.router, prefix=app_settings.api_prefix)
 app.include_router(metrics.router, prefix=app_settings.api_prefix)
 app.include_router(metrics.router, prefix=app_settings.api_prefix)
 app.include_router(virtual_printers.router, prefix=app_settings.api_prefix)
 app.include_router(virtual_printers.router, prefix=app_settings.api_prefix)
 app.include_router(spoolbuddy.router, prefix=app_settings.api_prefix)
 app.include_router(spoolbuddy.router, prefix=app_settings.api_prefix)

+ 12 - 0
backend/app/schemas/settings.py

@@ -90,6 +90,13 @@ class AppSettings(BaseModel):
         description="JSON: per-model G-code injection snippets {model: {start_gcode, end_gcode}}",
         description="JSON: per-model G-code injection snippets {model: {start_gcode, end_gcode}}",
     )
     )
 
 
+    # Scheduled local backup (#884)
+    local_backup_enabled: bool = Field(default=False, description="Enable scheduled local backups")
+    local_backup_schedule: str = Field(default="daily", description="Backup frequency: hourly, daily, weekly")
+    local_backup_time: str = Field(default="03:00", description="Time of day for daily/weekly backups (HH:MM, 24h)")
+    local_backup_retention: int = Field(default=5, description="Number of backup files to keep (1-100)")
+    local_backup_path: str = Field(default="", description="Backup output directory (empty = DATA_DIR/backups)")
+
     # Print modal settings
     # Print modal settings
     per_printer_mapping_expanded: bool = Field(
     per_printer_mapping_expanded: bool = Field(
         default=False, description="Expand custom filament mapping by default in print modal"
         default=False, description="Expand custom filament mapping by default in print modal"
@@ -334,6 +341,11 @@ class AppSettingsUpdate(BaseModel):
     require_plate_clear: bool | None = None
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
     queue_shortest_first: bool | None = None
     gcode_snippets: str | None = None
     gcode_snippets: str | None = None
+    local_backup_enabled: bool | None = None
+    local_backup_schedule: str | None = None
+    local_backup_time: str | None = None
+    local_backup_retention: int | None = None
+    local_backup_path: str | None = None
     ldap_enabled: bool | None = None
     ldap_enabled: bool | None = None
     ldap_server_url: str | None = None
     ldap_server_url: str | None = None
     ldap_bind_dn: str | None = None
     ldap_bind_dn: str | None = None

+ 270 - 0
backend/app/services/local_backup.py

@@ -0,0 +1,270 @@
+"""Scheduled local backup service.
+
+Creates ZIP snapshots of the full Bambuddy data (database + data directories)
+on a configurable schedule with retention management.
+"""
+
+import asyncio
+import logging
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+from sqlalchemy import select
+
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import async_session
+from backend.app.models.settings import Settings
+
+logger = logging.getLogger(__name__)
+
+SCHEDULE_INTERVALS = {
+    "hourly": 3600,
+    "daily": 86400,
+    "weekly": 604800,
+}
+
+
+def _default_backup_dir() -> Path:
+    return app_settings.base_dir / "backups"
+
+
+class LocalBackupService:
+    """Manages scheduled local backup snapshots with retention."""
+
+    def __init__(self):
+        self._scheduler_task: asyncio.Task | None = None
+        self._check_interval = 60
+        self._running: bool = False
+        self._last_backup_at: str | None = None
+        self._last_status: str | None = None
+        self._last_message: str | None = None
+        self._next_run: datetime | None = None
+
+    async def start_scheduler(self):
+        """Start the background scheduler loop."""
+        if self._scheduler_task is not None:
+            return
+        logger.info("Starting local backup scheduler")
+        # Seed next_run from settings so the first check has a target
+        await self._seed_next_run()
+        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 local backup scheduler")
+
+    async def _scheduler_loop(self):
+        """Main scheduler loop — checks for due backups every minute."""
+        while True:
+            try:
+                await asyncio.sleep(self._check_interval)
+                await self._check_scheduled_backup()
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                logger.error("Error in local backup scheduler: %s", e)
+                await asyncio.sleep(60)
+
+    async def _seed_next_run(self):
+        """Load settings and calculate initial next_run."""
+        try:
+            settings = await self._load_settings()
+            if settings.get("enabled"):
+                self._next_run = self._calculate_next_run(
+                    settings.get("schedule", "daily"),
+                    settings.get("time", "03:00"),
+                )
+        except Exception as e:
+            logger.debug("Could not seed local backup next_run: %s", e)
+
+    async def _load_settings(self) -> dict:
+        """Read local backup settings from the DB."""
+        async with async_session() as db:
+            keys = [
+                "local_backup_enabled",
+                "local_backup_schedule",
+                "local_backup_time",
+                "local_backup_retention",
+                "local_backup_path",
+            ]
+            result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
+            rows = {r.key: r.value for r in result.scalars().all()}
+        return {
+            "enabled": rows.get("local_backup_enabled", "false").lower() == "true",
+            "schedule": rows.get("local_backup_schedule", "daily"),
+            "time": rows.get("local_backup_time", "03:00"),
+            "retention": int(rows.get("local_backup_retention", "5")),
+            "path": rows.get("local_backup_path", ""),
+        }
+
+    async def _check_scheduled_backup(self):
+        """Check if a scheduled backup is due and run it."""
+        settings = await self._load_settings()
+        if not settings["enabled"]:
+            self._next_run = None
+            return
+
+        now = datetime.now(timezone.utc)
+
+        # If no next_run set, schedule one
+        if self._next_run is None:
+            self._next_run = self._calculate_next_run(settings["schedule"], settings["time"])
+            return
+
+        if self._next_run <= now:
+            logger.info("Running scheduled local backup")
+            await self.run_backup(settings)
+            self._next_run = self._calculate_next_run(settings["schedule"], settings["time"])
+
+    def _calculate_next_run(self, schedule_type: str, time_str: str = "03:00") -> datetime:
+        """Calculate the next scheduled run time.
+
+        For hourly: next full hour.
+        For daily/weekly: next occurrence of the configured time (HH:MM).
+        """
+        now = datetime.now(timezone.utc)
+
+        if schedule_type == "hourly":
+            # Next full hour
+            next_run = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
+            return next_run
+
+        # Parse HH:MM time
+        try:
+            parts = time_str.strip().split(":")
+            hour = int(parts[0])
+            minute = int(parts[1]) if len(parts) > 1 else 0
+        except (ValueError, IndexError):
+            hour, minute = 3, 0
+
+        # Next occurrence of this time today or tomorrow
+        next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
+        if next_run <= now:
+            next_run += timedelta(days=1)
+
+        if schedule_type == "weekly":
+            next_run += timedelta(weeks=1)
+
+        return next_run
+
+    def _resolve_backup_dir(self, path_setting: str) -> Path:
+        """Resolve the backup output directory from settings."""
+        if path_setting.strip():
+            return Path(path_setting.strip())
+        return _default_backup_dir()
+
+    async def run_backup(self, settings: dict | None = None) -> dict:
+        """Run a backup now. Returns {success, message, filename}."""
+        if self._running:
+            return {"success": False, "message": "Backup already in progress"}
+
+        self._running = True
+        try:
+            if settings is None:
+                settings = await self._load_settings()
+
+            backup_dir = self._resolve_backup_dir(settings["path"])
+            backup_dir.mkdir(parents=True, exist_ok=True)
+
+            from backend.app.api.routes.settings import create_backup_zip
+
+            zip_path, filename = await create_backup_zip(output_path=backup_dir)
+
+            # Prune old backups
+            retention = max(1, settings["retention"])
+            self._prune_backups(backup_dir, retention)
+
+            self._last_backup_at = datetime.now(timezone.utc).isoformat()
+            self._last_status = "success"
+            self._last_message = filename
+            logger.info("Local backup created: %s", zip_path)
+            return {"success": True, "message": "Backup created", "filename": filename}
+
+        except Exception as e:
+            self._last_backup_at = datetime.now(timezone.utc).isoformat()
+            self._last_status = "failed"
+            self._last_message = str(e)
+            logger.error("Local backup failed: %s", e, exc_info=True)
+            return {"success": False, "message": f"Backup failed: {e}"}
+        finally:
+            self._running = False
+
+    def _prune_backups(self, backup_dir: Path, retention: int):
+        """Delete oldest backups exceeding the retention count."""
+        backups = sorted(
+            backup_dir.glob("bambuddy-backup-*.zip"),
+            key=lambda p: p.stat().st_mtime,
+            reverse=True,
+        )
+        for old_backup in backups[retention:]:
+            try:
+                old_backup.unlink()
+                logger.info("Pruned old backup: %s", old_backup.name)
+            except OSError as e:
+                logger.warning("Could not delete old backup %s: %s", old_backup.name, e)
+
+    def get_status(self) -> dict:
+        """Return current scheduler status."""
+        return {
+            "is_running": self._running,
+            "last_backup_at": self._last_backup_at,
+            "last_status": self._last_status,
+            "last_message": self._last_message,
+            "next_run": self._next_run.isoformat() if self._next_run else None,
+        }
+
+    def resolve_backup_file(self, path_setting: str, filename: str) -> Path | None:
+        """Resolve a backup filename to a full path, with safety checks."""
+        if "/" in filename or "\\" in filename or ".." in filename:
+            return None
+        if not filename.startswith("bambuddy-backup-") or not filename.endswith(".zip"):
+            return None
+        backup_dir = self._resolve_backup_dir(path_setting)
+        target = backup_dir / filename
+        if not target.exists():
+            return None
+        return target
+
+    def list_backups(self, path_setting: str) -> list[dict]:
+        """List backup ZIP files in the backup directory."""
+        backup_dir = self._resolve_backup_dir(path_setting)
+        if not backup_dir.exists():
+            return []
+
+        backups = []
+        for f in sorted(backup_dir.glob("bambuddy-backup-*.zip"), key=lambda p: p.stat().st_mtime, reverse=True):
+            stat = f.stat()
+            backups.append(
+                {
+                    "filename": f.name,
+                    "size": stat.st_size,
+                    "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
+                }
+            )
+        return backups
+
+    def delete_backup(self, path_setting: str, filename: str) -> dict:
+        """Delete a specific backup file. Returns {success, message}."""
+        # Path traversal protection
+        if "/" in filename or "\\" in filename or ".." in filename:
+            return {"success": False, "message": "Invalid filename"}
+
+        backup_dir = self._resolve_backup_dir(path_setting)
+        target = backup_dir / filename
+
+        if not target.exists():
+            return {"success": False, "message": "Backup not found"}
+        if not target.name.startswith("bambuddy-backup-") or not target.name.endswith(".zip"):
+            return {"success": False, "message": "Invalid backup file"}
+
+        try:
+            target.unlink()
+            return {"success": True, "message": "Backup deleted"}
+        except OSError as e:
+            return {"success": False, "message": f"Could not delete: {e}"}
+
+
+local_backup_service = LocalBackupService()

+ 234 - 0
backend/tests/unit/test_local_backup.py

@@ -0,0 +1,234 @@
+"""Unit tests for scheduled local backup service (#884)."""
+
+import tempfile
+import zipfile
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.local_backup import LocalBackupService
+
+
+class TestCalculateNextRun:
+    """Tests for _calculate_next_run scheduling logic."""
+
+    def test_hourly_returns_next_full_hour(self):
+        service = LocalBackupService()
+        now = datetime(2026, 4, 12, 14, 30, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("hourly", "03:00")
+        assert result.hour == 15
+        assert result.minute == 0
+
+    def test_daily_before_target_time_schedules_today(self):
+        service = LocalBackupService()
+        now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("daily", "03:00")
+        assert result.day == 12
+        assert result.hour == 3
+
+    def test_daily_after_target_time_schedules_tomorrow(self):
+        service = LocalBackupService()
+        now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("daily", "03:00")
+        assert result.day == 13
+        assert result.hour == 3
+
+    def test_weekly_adds_full_week(self):
+        service = LocalBackupService()
+        now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("weekly", "03:00")
+        expected = datetime(2026, 4, 19, 3, 0, 0, tzinfo=timezone.utc)
+        assert result == expected
+
+    def test_weekly_after_target_time_adds_full_week_from_tomorrow(self):
+        service = LocalBackupService()
+        now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("weekly", "03:00")
+        expected = datetime(2026, 4, 20, 3, 0, 0, tzinfo=timezone.utc)
+        assert result == expected
+
+    def test_invalid_time_defaults_to_0300(self):
+        service = LocalBackupService()
+        now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("daily", "invalid")
+        assert result.hour == 3
+        assert result.minute == 0
+
+    def test_unknown_schedule_type_defaults_to_daily(self):
+        service = LocalBackupService()
+        now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("every_5_min", "03:00")
+        # Should fall through to daily behavior (time-based)
+        assert result.hour == 3
+
+
+class TestPruneBackups:
+    """Tests for backup retention pruning."""
+
+    def test_prune_keeps_retention_count(self, tmp_path):
+        service = LocalBackupService()
+        # Create 5 backup files
+        for i in range(5):
+            f = tmp_path / f"bambuddy-backup-20260412-{i:06d}.zip"
+            f.write_text(f"backup{i}")
+        service._prune_backups(tmp_path, retention=3)
+        remaining = list(tmp_path.glob("bambuddy-backup-*.zip"))
+        assert len(remaining) == 3
+
+    def test_prune_noop_when_under_retention(self, tmp_path):
+        service = LocalBackupService()
+        for i in range(2):
+            f = tmp_path / f"bambuddy-backup-20260412-{i:06d}.zip"
+            f.write_text(f"backup{i}")
+        service._prune_backups(tmp_path, retention=5)
+        remaining = list(tmp_path.glob("bambuddy-backup-*.zip"))
+        assert len(remaining) == 2
+
+    def test_prune_only_touches_matching_files(self, tmp_path):
+        service = LocalBackupService()
+        # Create backup files and a non-backup file
+        for i in range(3):
+            f = tmp_path / f"bambuddy-backup-20260412-{i:06d}.zip"
+            f.write_text(f"backup{i}")
+        other = tmp_path / "other_file.txt"
+        other.write_text("keep me")
+        service._prune_backups(tmp_path, retention=1)
+        assert other.exists()
+        remaining = list(tmp_path.glob("bambuddy-backup-*.zip"))
+        assert len(remaining) == 1
+
+
+class TestResolveBackupFile:
+    """Tests for backup file resolution with path traversal protection."""
+
+    def test_valid_filename(self, tmp_path):
+        service = LocalBackupService()
+        f = tmp_path / "bambuddy-backup-20260412-120000.zip"
+        f.write_text("data")
+        result = service.resolve_backup_file(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
+        assert result == f
+
+    def test_path_traversal_blocked(self, tmp_path):
+        service = LocalBackupService()
+        result = service.resolve_backup_file(str(tmp_path), "../etc/passwd")
+        assert result is None
+
+    def test_backslash_blocked(self, tmp_path):
+        service = LocalBackupService()
+        result = service.resolve_backup_file(str(tmp_path), "..\\etc\\passwd")
+        assert result is None
+
+    def test_dotdot_blocked(self, tmp_path):
+        service = LocalBackupService()
+        result = service.resolve_backup_file(str(tmp_path), "..bambuddy-backup.zip")
+        assert result is None
+
+    def test_wrong_prefix_blocked(self, tmp_path):
+        service = LocalBackupService()
+        f = tmp_path / "evil-file.zip"
+        f.write_text("data")
+        result = service.resolve_backup_file(str(tmp_path), "evil-file.zip")
+        assert result is None
+
+    def test_nonexistent_file(self, tmp_path):
+        service = LocalBackupService()
+        result = service.resolve_backup_file(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
+        assert result is None
+
+
+class TestDeleteBackup:
+    """Tests for backup deletion."""
+
+    def test_delete_valid_backup(self, tmp_path):
+        service = LocalBackupService()
+        f = tmp_path / "bambuddy-backup-20260412-120000.zip"
+        f.write_text("data")
+        result = service.delete_backup(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
+        assert result["success"] is True
+        assert not f.exists()
+
+    def test_delete_nonexistent_backup(self, tmp_path):
+        service = LocalBackupService()
+        result = service.delete_backup(str(tmp_path), "bambuddy-backup-20260412-120000.zip")
+        assert result["success"] is False
+
+    def test_delete_path_traversal_blocked(self, tmp_path):
+        service = LocalBackupService()
+        result = service.delete_backup(str(tmp_path), "../important.zip")
+        assert result["success"] is False
+
+
+class TestListBackups:
+    """Tests for backup listing."""
+
+    def test_list_empty_dir(self, tmp_path):
+        service = LocalBackupService()
+        result = service.list_backups(str(tmp_path))
+        assert result == []
+
+    def test_list_nonexistent_dir(self):
+        service = LocalBackupService()
+        result = service.list_backups("/nonexistent/path/12345")
+        assert result == []
+
+    def test_list_only_matching_files(self, tmp_path):
+        service = LocalBackupService()
+        (tmp_path / "bambuddy-backup-20260412-120000.zip").write_text("a")
+        (tmp_path / "bambuddy-backup-20260412-130000.zip").write_text("bb")
+        (tmp_path / "other-file.txt").write_text("ccc")
+        result = service.list_backups(str(tmp_path))
+        assert len(result) == 2
+        assert all(r["filename"].startswith("bambuddy-backup-") for r in result)
+
+    def test_list_sorted_newest_first(self, tmp_path):
+        import time
+
+        service = LocalBackupService()
+        f1 = tmp_path / "bambuddy-backup-20260412-120000.zip"
+        f1.write_text("a")
+        time.sleep(0.05)
+        f2 = tmp_path / "bambuddy-backup-20260412-130000.zip"
+        f2.write_text("b")
+        result = service.list_backups(str(tmp_path))
+        assert result[0]["filename"] == "bambuddy-backup-20260412-130000.zip"
+
+    def test_list_includes_size(self, tmp_path):
+        service = LocalBackupService()
+        (tmp_path / "bambuddy-backup-20260412-120000.zip").write_bytes(b"x" * 1024)
+        result = service.list_backups(str(tmp_path))
+        assert result[0]["size"] == 1024
+
+
+class TestGetStatus:
+    """Tests for status reporting."""
+
+    def test_initial_status(self):
+        service = LocalBackupService()
+        status = service.get_status()
+        assert status["is_running"] is False
+        assert status["last_backup_at"] is None
+        assert status["last_status"] is None
+        assert status["next_run"] is None

+ 140 - 0
frontend/src/__tests__/components/GitHubBackupSettings.scheduled.test.tsx

@@ -0,0 +1,140 @@
+/**
+ * Tests for the Scheduled Local Backup UI in GitHubBackupSettings.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockLocalBackupStatus = {
+  enabled: true,
+  schedule: 'daily',
+  time: '03:00',
+  retention: 5,
+  path: '',
+  default_path: '/data/backups',
+  is_running: false,
+  last_backup_at: null,
+  last_status: null,
+  last_message: null,
+  next_run: '2026-04-13T03:00:00+00:00',
+};
+
+const mockLocalBackups = [
+  {
+    filename: 'bambuddy-backup-20260412-120000.zip',
+    size: 52428800,
+    created_at: '2026-04-12T12:00:00+00:00',
+  },
+];
+
+describe('GitHubBackupSettings - Scheduled Backups', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/local-backup/status', () =>
+        HttpResponse.json(mockLocalBackupStatus)
+      ),
+      http.get('/api/v1/local-backup/backups', () =>
+        HttpResponse.json(mockLocalBackups)
+      ),
+      http.get('/api/v1/github-backup/config', () =>
+        HttpResponse.json(null)
+      ),
+      http.get('/api/v1/github-backup/status', () =>
+        HttpResponse.json({ configured: false, enabled: false, is_running: false, progress: null, last_backup_at: null, last_backup_status: null, next_scheduled_run: null })
+      ),
+      http.get('/api/v1/github-backup/logs', () =>
+        HttpResponse.json([])
+      ),
+      http.get('/api/v1/cloud/status', () =>
+        HttpResponse.json({ is_authenticated: false })
+      ),
+      http.get('/api/v1/printers', () =>
+        HttpResponse.json([])
+      ),
+      http.put('/api/v1/settings/', () =>
+        HttpResponse.json({})
+      ),
+    );
+  });
+
+  it('renders Scheduled Backups card title', async () => {
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByText('Scheduled Backups')).toBeInTheDocument();
+    });
+  });
+
+  it('shows frequency dropdown when enabled', async () => {
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByText('Frequency')).toBeInTheDocument();
+    });
+  });
+
+  it('shows retention input when enabled', async () => {
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByText('Retention')).toBeInTheDocument();
+    });
+  });
+
+  it('shows backup file list', async () => {
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByText('bambuddy-backup-20260412-120000.zip')).toBeInTheDocument();
+    });
+  });
+
+  it('shows file size in MB', async () => {
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByText(/50\.0 MB/)).toBeInTheDocument();
+    });
+  });
+
+  it('shows Run Now button', async () => {
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByText('Run Now')).toBeInTheDocument();
+    });
+  });
+
+  it('shows default path when path is empty', async () => {
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByText('/data/backups')).toBeInTheDocument();
+    });
+  });
+
+  it('hides schedule controls when disabled', async () => {
+    server.use(
+      http.get('/api/v1/local-backup/status', () =>
+        HttpResponse.json({ ...mockLocalBackupStatus, enabled: false })
+      ),
+    );
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByText('Scheduled Backups')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('Frequency')).not.toBeInTheDocument();
+    expect(screen.queryByText('Run Now')).not.toBeInTheDocument();
+  });
+
+  it('hides time picker when hourly is selected', async () => {
+    server.use(
+      http.get('/api/v1/local-backup/status', () =>
+        HttpResponse.json({ ...mockLocalBackupStatus, schedule: 'hourly' })
+      ),
+    );
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByText('Frequency')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('Time')).not.toBeInTheDocument();
+  });
+});

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

@@ -836,6 +836,12 @@ export interface AppSettings {
   ambient_drying_enabled: boolean;  // Auto-dry idle printers based on humidity regardless of queue
   ambient_drying_enabled: boolean;  // Auto-dry idle printers based on humidity regardless of queue
   drying_presets: string;  // JSON blob of drying presets per filament type
   drying_presets: string;  // JSON blob of drying presets per filament type
   gcode_snippets: string;  // JSON: per-model G-code injection snippets
   gcode_snippets: string;  // JSON: per-model G-code injection snippets
+  // Scheduled local backup
+  local_backup_enabled: boolean;
+  local_backup_schedule: string;
+  local_backup_time: string;
+  local_backup_retention: number;
+  local_backup_path: string;
   // Print modal settings
   // Print modal settings
   per_printer_mapping_expanded: boolean;  // Whether custom mapping is expanded by default in print modal
   per_printer_mapping_expanded: boolean;  // Whether custom mapping is expanded by default in print modal
   // Date/time format settings
   // Date/time format settings
@@ -1805,6 +1811,26 @@ export interface GitHubBackupStatus {
   next_scheduled_run: string | null;
   next_scheduled_run: string | null;
 }
 }
 
 
+export interface LocalBackupStatus {
+  enabled: boolean;
+  schedule: string;
+  time: string;
+  retention: number;
+  path: string;
+  default_path: string;
+  is_running: boolean;
+  last_backup_at: string | null;
+  last_status: string | null;
+  last_message: string | null;
+  next_run: string | null;
+}
+
+export interface LocalBackupFile {
+  filename: string;
+  size: number;
+  created_at: string;
+}
+
 export interface GitHubTestConnectionResponse {
 export interface GitHubTestConnectionResponse {
   success: boolean;
   success: boolean;
   message: string;
   message: string;
@@ -4465,6 +4491,31 @@ export const api = {
   clearGitHubBackupLogs: (keepLast: number = 10) =>
   clearGitHubBackupLogs: (keepLast: number = 10) =>
     request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
     request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
 
 
+  // Scheduled local backups
+  getLocalBackupStatus: () =>
+    request<LocalBackupStatus>('/local-backup/status'),
+
+  triggerLocalBackup: () =>
+    request<{ success: boolean; message: string; filename?: string }>('/local-backup/run', { method: 'POST' }),
+
+  getLocalBackups: () =>
+    request<LocalBackupFile[]>('/local-backup/backups'),
+
+  downloadLocalBackup: async (filename: string): Promise<{ blob: Blob; filename: string }> => {
+    const response = await fetch(`${API_BASE}/local-backup/backups/${encodeURIComponent(filename)}/download`, {
+      headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
+    });
+    if (!response.ok) throw new Error('Download failed');
+    const blob = await response.blob();
+    return { blob, filename };
+  },
+
+  restoreLocalBackup: (filename: string) =>
+    request<{ success: boolean; message: string }>(`/local-backup/backups/${encodeURIComponent(filename)}/restore`, { method: 'POST' }),
+
+  deleteLocalBackup: (filename: string) =>
+    request<{ success: boolean; message: string }>(`/local-backup/backups/${encodeURIComponent(filename)}`, { method: 'DELETE' }),
+
   // Local Presets (OrcaSlicer imports)
   // Local Presets (OrcaSlicer imports)
   getLocalPresets: () =>
   getLocalPresets: () =>
     request<LocalPresetsResponse>('/local-presets/'),
     request<LocalPresetsResponse>('/local-presets/'),

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

@@ -18,6 +18,7 @@ import {
   AlertTriangle,
   AlertTriangle,
   Trash2,
   Trash2,
   RotateCcw,
   RotateCcw,
+  FolderArchive,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -26,6 +27,8 @@ import type {
   GitHubBackupLog,
   GitHubBackupLog,
   GitHubBackupStatus,
   GitHubBackupStatus,
   GitHubBackupTriggerResponse,
   GitHubBackupTriggerResponse,
+  LocalBackupFile,
+  LocalBackupStatus,
   ScheduleType,
   ScheduleType,
   CloudAuthStatus,
   CloudAuthStatus,
   Printer,
   Printer,
@@ -100,6 +103,80 @@ export function GitHubBackupSettings() {
   const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
   const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
 
+  // Scheduled local backup state
+  const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
+  const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
+  const [localBackupPath, setLocalBackupPath] = useState('');
+
+  const { data: localBackupStatus, refetch: refetchLocalStatus } = useQuery<LocalBackupStatus>({
+    queryKey: ['local-backup-status'],
+    queryFn: api.getLocalBackupStatus,
+    refetchInterval: (query) => query.state.data?.is_running ? 1000 : 10000,
+  });
+
+  const { data: localBackups, refetch: refetchLocalBackups } = useQuery<LocalBackupFile[]>({
+    queryKey: ['local-backup-files'],
+    queryFn: api.getLocalBackups,
+    refetchInterval: 30000,
+  });
+
+  // Sync local path state from server
+  useEffect(() => {
+    if (localBackupStatus?.path !== undefined) {
+      setLocalBackupPath(localBackupStatus.path);
+    }
+  }, [localBackupStatus?.path]);
+
+  const triggerLocalBackupMutation = useMutation({
+    mutationFn: api.triggerLocalBackup,
+    onSuccess: (data) => {
+      if (data.success) {
+        showToast(t('backup.scheduledBackupComplete'));
+      } else {
+        showToast(data.message, 'error');
+      }
+      refetchLocalStatus();
+      refetchLocalBackups();
+    },
+    onError: () => showToast(t('backup.scheduledBackupFailed'), 'error'),
+  });
+
+  const deleteLocalBackupMutation = useMutation({
+    mutationFn: (filename: string) => api.deleteLocalBackup(filename),
+    onSuccess: () => {
+      refetchLocalBackups();
+      setDeleteConfirmFile(null);
+    },
+  });
+
+  const restoreLocalBackupMutation = useMutation({
+    mutationFn: async (filename: string) => {
+      setRestoreConfirmFile(null);
+      setIsRestoring(true);
+      setRestoreResult(null);
+      setOperationStatus(t('backup.restoring'));
+      return api.restoreLocalBackup(filename);
+    },
+    onSuccess: (data) => {
+      setIsRestoring(false);
+      setOperationStatus('');
+      if (data.success) {
+        setRestoreResult({ success: true, message: data.message });
+        showToast(t('backup.backupRestoredRestart'), 'success');
+      } else {
+        setRestoreResult({ success: false, message: data.message });
+        showToast(data.message, 'error');
+      }
+    },
+    onError: (e) => {
+      setIsRestoring(false);
+      setOperationStatus('');
+      const msg = e instanceof Error ? e.message : t('backup.failedToRestore');
+      setRestoreResult({ success: false, message: msg });
+      showToast(msg, 'error');
+    },
+  });
+
   // Block navigation while backup/restore is in progress
   // Block navigation while backup/restore is in progress
   useEffect(() => {
   useEffect(() => {
     const isOperationInProgress = isExporting || isRestoring;
     const isOperationInProgress = isExporting || isRestoring;
@@ -845,8 +922,251 @@ export function GitHubBackupSettings() {
             </div>
             </div>
           </CardContent>
           </CardContent>
         </Card>
         </Card>
+
+        {/* Scheduled Local Backups */}
+        <Card>
+          <CardHeader>
+            <div className="flex items-center justify-between">
+              <div className="flex items-center gap-2">
+                <FolderArchive className="w-5 h-5 text-gray-400" />
+                <h2 className="text-lg font-semibold text-white">{t('backup.scheduledBackup')}</h2>
+              </div>
+              <Toggle
+                checked={localBackupStatus?.enabled ?? false}
+                onChange={async (checked) => {
+                  try {
+                    await api.updateSettings({ local_backup_enabled: checked });
+                    showToast(t('backup.settingsSaved'));
+                  } catch (e) {
+                    showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
+                  }
+                  refetchLocalStatus();
+                }}
+              />
+            </div>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <p className="text-sm text-bambu-gray">
+              {t('backup.scheduledBackupDescription')}
+            </p>
+
+            {localBackupStatus?.enabled && (
+              <>
+                {/* Schedule + Time + Retention */}
+                <div className="grid grid-cols-3 gap-4">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">{t('backup.frequency')}</label>
+                    <select
+                      value={localBackupStatus?.schedule ?? 'daily'}
+                      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"
+                      onChange={async (e) => {
+                        try {
+                          await api.updateSettings({ local_backup_schedule: e.target.value });
+                          showToast(t('backup.settingsSaved'));
+                        } catch (e) {
+                          showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
+                        }
+                        refetchLocalStatus();
+                      }}
+                    >
+                      <option value="hourly">{t('backup.hourly')}</option>
+                      <option value="daily">{t('backup.daily')}</option>
+                      <option value="weekly">{t('backup.weekly')}</option>
+                    </select>
+                  </div>
+                  {(localBackupStatus?.schedule ?? 'daily') !== 'hourly' && (
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">{t('backup.backupTime')}</label>
+                      <input
+                        type="time"
+                        value={localBackupStatus?.time ?? '03:00'}
+                        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 [color-scheme:dark]"
+                        onChange={async (e) => {
+                          try {
+                            await api.updateSettings({ local_backup_time: e.target.value });
+                            showToast(t('backup.settingsSaved'));
+                          } catch (err) {
+                            showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');
+                          }
+                          refetchLocalStatus();
+                        }}
+                      />
+                      <p className="text-xs text-bambu-gray-light mt-1">{t('backup.utc')}</p>
+                    </div>
+                  )}
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">{t('backup.retention')}</label>
+                    <input
+                      type="number"
+                      min={1}
+                      max={100}
+                      value={localBackupStatus?.retention ?? 5}
+                      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"
+                      onChange={async (e) => {
+                        const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 5));
+                        try {
+                          await api.updateSettings({ local_backup_retention: val });
+                          showToast(t('backup.settingsSaved'));
+                        } catch (e) {
+                          showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
+                        }
+                        refetchLocalStatus();
+                      }}
+                    />
+                    <p className="text-xs text-bambu-gray-light mt-1">{t('backup.retentionDescription')}</p>
+                  </div>
+                </div>
+
+                {/* Output Path */}
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">{t('backup.outputPath')}</label>
+                  <input
+                    type="text"
+                    value={localBackupPath}
+                    onChange={(e) => setLocalBackupPath(e.target.value)}
+                    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"
+                    onBlur={async () => {
+                      try {
+                        await api.updateSettings({ local_backup_path: localBackupPath });
+                        showToast(t('backup.settingsSaved'));
+                      } catch (err) {
+                        showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');
+                      }
+                      refetchLocalStatus();
+                      refetchLocalBackups();
+                    }}
+                    onKeyDown={(e) => {
+                      if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
+                    }}
+                  />
+                  <p className="text-xs text-bambu-gray-light mt-1">
+                    {localBackupPath
+                      ? t('backup.outputPathDescription')
+                      : <>{t('backup.defaultPathLabel')} <code className="text-bambu-gray">{localBackupStatus?.default_path || '...'}</code></>
+                    }
+                  </p>
+                </div>
+
+                {/* Status + Run Now */}
+                <div className="flex items-center justify-between py-3 border-t border-bambu-dark-tertiary">
+                  <div className="text-sm">
+                    {localBackupStatus?.last_backup_at && (
+                      <div className="flex items-center gap-2 text-bambu-gray">
+                        <span>{t('backup.lastBackup')}:</span>
+                        <StatusBadge status={localBackupStatus.last_status} />
+                        <span>{formatRelativeTime(localBackupStatus.last_backup_at)}</span>
+                      </div>
+                    )}
+                    {localBackupStatus?.next_run && (
+                      <div className="text-bambu-gray mt-1">
+                        <span>{t('backup.nextBackup')}: </span>
+                        <span>{formatDateTime(localBackupStatus.next_run)}</span>
+                      </div>
+                    )}
+                  </div>
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    disabled={localBackupStatus?.is_running || triggerLocalBackupMutation.isPending}
+                    onClick={() => triggerLocalBackupMutation.mutate()}
+                  >
+                    {localBackupStatus?.is_running || triggerLocalBackupMutation.isPending ? (
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                    ) : (
+                      <Play className="w-4 h-4" />
+                    )}
+                    {localBackupStatus?.is_running ? t('backup.backupRunning') : t('backup.runNow')}
+                  </Button>
+                </div>
+
+                {/* Backup Files List */}
+                {localBackups && localBackups.length > 0 && (
+                  <div className="border-t border-bambu-dark-tertiary pt-3">
+                    <h3 className="text-sm font-medium text-white mb-2">{t('backup.backupFiles')}</h3>
+                    <div className="space-y-1">
+                      {localBackups.map((file) => (
+                        <div key={file.filename} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-bambu-dark-tertiary/50 text-sm">
+                          <div className="flex-1 min-w-0">
+                            <span className="text-white truncate block">{file.filename}</span>
+                            <span className="text-bambu-gray text-xs">
+                              {(file.size / 1024 / 1024).toFixed(1)} MB &middot; {formatDateTime(file.created_at)}
+                            </span>
+                          </div>
+                          <div className="flex items-center gap-1 flex-shrink-0">
+                            <button
+                              className="text-bambu-gray hover:text-bambu-green p-1"
+                              title={t('backup.download')}
+                              onClick={async () => {
+                                try {
+                                  const { blob, filename: fname } = await api.downloadLocalBackup(file.filename);
+                                  const url = URL.createObjectURL(blob);
+                                  const a = document.createElement('a');
+                                  a.href = url;
+                                  a.download = fname;
+                                  a.click();
+                                  URL.revokeObjectURL(url);
+                                } catch {
+                                  showToast(t('backup.scheduledBackupFailed'), 'error');
+                                }
+                              }}
+                            >
+                              <Download className="w-3.5 h-3.5" />
+                            </button>
+                            <button
+                              className="text-bambu-gray hover:text-yellow-400 p-1"
+                              title={t('backup.restore')}
+                              onClick={() => setRestoreConfirmFile(file.filename)}
+                            >
+                              <RotateCcw className="w-3.5 h-3.5" />
+                            </button>
+                            <button
+                              className="text-bambu-gray hover:text-red-400 p-1"
+                              onClick={() => setDeleteConfirmFile(file.filename)}
+                              title={t('backup.deleteBackup')}
+                            >
+                              <Trash2 className="w-3.5 h-3.5" />
+                            </button>
+                          </div>
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                )}
+                {localBackups && localBackups.length === 0 && (
+                  <p className="text-sm text-bambu-gray text-center py-3 border-t border-bambu-dark-tertiary">
+                    {t('backup.noScheduledBackups')}
+                  </p>
+                )}
+              </>
+            )}
+          </CardContent>
+        </Card>
       </div>
       </div>
 
 
+      {/* Delete Backup Confirmation Modal */}
+      {deleteConfirmFile && (
+        <ConfirmModal
+          title={t('backup.deleteBackup')}
+          message={t('backup.deleteBackupConfirm')}
+          confirmText={t('backup.deleteBackup')}
+          variant="danger"
+          onConfirm={() => deleteLocalBackupMutation.mutate(deleteConfirmFile)}
+          onCancel={() => setDeleteConfirmFile(null)}
+        />
+      )}
+
+      {/* Restore from Scheduled Backup Confirmation Modal */}
+      {restoreConfirmFile && (
+        <ConfirmModal
+          title={t('backup.restoreConfirmTitle')}
+          message={t('backup.restoreConfirmMessage', { filename: restoreConfirmFile })}
+          confirmText={t('backup.restoreConfirmButton')}
+          variant="danger"
+          onConfirm={() => restoreLocalBackupMutation.mutate(restoreConfirmFile)}
+          onCancel={() => setRestoreConfirmFile(null)}
+        />
+      )}
+
       {/* Restore Confirmation Modal */}
       {/* Restore Confirmation Modal */}
       {showRestoreConfirm && restoreFile && (
       {showRestoreConfirm && restoreFile && (
         <ConfirmModal
         <ConfirmModal

+ 23 - 0
frontend/src/i18n/locales/de.ts

@@ -3450,6 +3450,29 @@ export default {
     noDataFound: 'In der Sicherungsdatei wurden keine Daten zur Wiederherstellung gefunden.',
     noDataFound: 'In der Sicherungsdatei wurden keine Daten zur Wiederherstellung gefunden.',
     close: 'Schließen',
     close: 'Schließen',
 
 
+    // Scheduled local backups (#884)
+    scheduledBackup: 'Scheduled Backups',
+    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',
+    frequency: 'Frequency',
+    backupTime: 'Time',
+    retention: 'Retention',
+    retentionDescription: 'Number of backups to keep',
+    outputPath: 'Output Path',
+    outputPathPlaceholder: 'Default: {{path}}',
+    outputPathDescription: 'Leave empty for default location',
+    runNow: 'Run Now',
+    backupFiles: 'Backup Files',
+    noScheduledBackups: 'No backups yet',
+    deleteBackup: 'Delete',
+    deleteBackupConfirm: 'Delete this backup file?',
+    backupRunning: 'Backup in progress...',
+    scheduledBackupComplete: 'Backup completed successfully',
+    scheduledBackupFailed: 'Backup failed',
+    nextBackup: 'Next backup',
+    backupSize: 'Size',
+    utc: 'UTC',
+    defaultPathLabel: 'Default:',
+
     // Category labels
     // Category labels
     categories: {
     categories: {
       settings: 'Einstellungen',
       settings: 'Einstellungen',

+ 23 - 0
frontend/src/i18n/locales/en.ts

@@ -3456,6 +3456,29 @@ export default {
     noDataFound: 'No data was found to restore in the backup file.',
     noDataFound: 'No data was found to restore in the backup file.',
     close: 'Close',
     close: 'Close',
 
 
+    // Scheduled local backups (#884)
+    scheduledBackup: 'Scheduled Backups',
+    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',
+    frequency: 'Frequency',
+    backupTime: 'Time',
+    retention: 'Retention',
+    retentionDescription: 'Number of backups to keep',
+    outputPath: 'Output Path',
+    outputPathPlaceholder: 'Default: {{path}}',
+    outputPathDescription: 'Leave empty for default location',
+    runNow: 'Run Now',
+    backupFiles: 'Backup Files',
+    noScheduledBackups: 'No backups yet',
+    deleteBackup: 'Delete',
+    deleteBackupConfirm: 'Delete this backup file?',
+    backupRunning: 'Backup in progress...',
+    scheduledBackupComplete: 'Backup completed successfully',
+    scheduledBackupFailed: 'Backup failed',
+    nextBackup: 'Next backup',
+    backupSize: 'Size',
+    utc: 'UTC',
+    defaultPathLabel: 'Default:',
+
     // Category labels
     // Category labels
     categories: {
     categories: {
       settings: 'Settings',
       settings: 'Settings',

+ 23 - 0
frontend/src/i18n/locales/fr.ts

@@ -3415,6 +3415,29 @@ export default {
     noDataFound: 'Aucune donnée à restaurer n\'a été trouvée dans le fichier de sauvegarde.',
     noDataFound: 'Aucune donnée à restaurer n\'a été trouvée dans le fichier de sauvegarde.',
     close: 'Fermer',
     close: 'Fermer',
 
 
+    // Scheduled local backups (#884)
+    scheduledBackup: 'Scheduled Backups',
+    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',
+    frequency: 'Frequency',
+    backupTime: 'Time',
+    retention: 'Retention',
+    retentionDescription: 'Number of backups to keep',
+    outputPath: 'Output Path',
+    outputPathPlaceholder: 'Default: {{path}}',
+    outputPathDescription: 'Leave empty for default location',
+    runNow: 'Run Now',
+    backupFiles: 'Backup Files',
+    noScheduledBackups: 'No backups yet',
+    deleteBackup: 'Delete',
+    deleteBackupConfirm: 'Delete this backup file?',
+    backupRunning: 'Backup in progress...',
+    scheduledBackupComplete: 'Backup completed successfully',
+    scheduledBackupFailed: 'Backup failed',
+    nextBackup: 'Next backup',
+    backupSize: 'Size',
+    utc: 'UTC',
+    defaultPathLabel: 'Default:',
+
     // Category labels
     // Category labels
     categories: {
     categories: {
       settings: 'Paramètres',
       settings: 'Paramètres',

+ 23 - 0
frontend/src/i18n/locales/it.ts

@@ -3414,6 +3414,29 @@ export default {
     noDataFound: 'Nessun dato da ripristinare trovato nel file di backup.',
     noDataFound: 'Nessun dato da ripristinare trovato nel file di backup.',
     close: 'Chiudi',
     close: 'Chiudi',
 
 
+    // Scheduled local backups (#884)
+    scheduledBackup: 'Scheduled Backups',
+    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',
+    frequency: 'Frequency',
+    backupTime: 'Time',
+    retention: 'Retention',
+    retentionDescription: 'Number of backups to keep',
+    outputPath: 'Output Path',
+    outputPathPlaceholder: 'Default: {{path}}',
+    outputPathDescription: 'Leave empty for default location',
+    runNow: 'Run Now',
+    backupFiles: 'Backup Files',
+    noScheduledBackups: 'No backups yet',
+    deleteBackup: 'Delete',
+    deleteBackupConfirm: 'Delete this backup file?',
+    backupRunning: 'Backup in progress...',
+    scheduledBackupComplete: 'Backup completed successfully',
+    scheduledBackupFailed: 'Backup failed',
+    nextBackup: 'Next backup',
+    backupSize: 'Size',
+    utc: 'UTC',
+    defaultPathLabel: 'Default:',
+
     // Category labels
     // Category labels
     categories: {
     categories: {
       settings: 'Impostazioni',
       settings: 'Impostazioni',

+ 23 - 0
frontend/src/i18n/locales/ja.ts

@@ -3453,6 +3453,29 @@ export default {
     noDataFound: 'バックアップファイルに復元するデータが見つかりませんでした。',
     noDataFound: 'バックアップファイルに復元するデータが見つかりませんでした。',
     close: '閉じる',
     close: '閉じる',
 
 
+    // Scheduled local backups (#884)
+    scheduledBackup: 'Scheduled Backups',
+    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',
+    frequency: 'Frequency',
+    backupTime: 'Time',
+    retention: 'Retention',
+    retentionDescription: 'Number of backups to keep',
+    outputPath: 'Output Path',
+    outputPathPlaceholder: 'Default: {{path}}',
+    outputPathDescription: 'Leave empty for default location',
+    runNow: 'Run Now',
+    backupFiles: 'Backup Files',
+    noScheduledBackups: 'No backups yet',
+    deleteBackup: 'Delete',
+    deleteBackupConfirm: 'Delete this backup file?',
+    backupRunning: 'Backup in progress...',
+    scheduledBackupComplete: 'Backup completed successfully',
+    scheduledBackupFailed: 'Backup failed',
+    nextBackup: 'Next backup',
+    backupSize: 'Size',
+    utc: 'UTC',
+    defaultPathLabel: 'Default:',
+
     // Category labels
     // Category labels
     categories: {
     categories: {
       settings: '設定',
       settings: '設定',

+ 23 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3414,6 +3414,29 @@ export default {
     noDataFound: 'Nenhum dado para restaurar foi encontrado no arquivo de backup.',
     noDataFound: 'Nenhum dado para restaurar foi encontrado no arquivo de backup.',
     close: 'Fechar',
     close: 'Fechar',
 
 
+    // Scheduled local backups (#884)
+    scheduledBackup: 'Scheduled Backups',
+    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',
+    frequency: 'Frequency',
+    backupTime: 'Time',
+    retention: 'Retention',
+    retentionDescription: 'Number of backups to keep',
+    outputPath: 'Output Path',
+    outputPathPlaceholder: 'Default: {{path}}',
+    outputPathDescription: 'Leave empty for default location',
+    runNow: 'Run Now',
+    backupFiles: 'Backup Files',
+    noScheduledBackups: 'No backups yet',
+    deleteBackup: 'Delete',
+    deleteBackupConfirm: 'Delete this backup file?',
+    backupRunning: 'Backup in progress...',
+    scheduledBackupComplete: 'Backup completed successfully',
+    scheduledBackupFailed: 'Backup failed',
+    nextBackup: 'Next backup',
+    backupSize: 'Size',
+    utc: 'UTC',
+    defaultPathLabel: 'Default:',
+
     // Category labels
     // Category labels
     categories: {
     categories: {
       settings: 'Configurações',
       settings: 'Configurações',

+ 23 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3414,6 +3414,29 @@ export default {
     noDataFound: '在备份文件中未找到可恢复的数据。',
     noDataFound: '在备份文件中未找到可恢复的数据。',
     close: '关闭',
     close: '关闭',
 
 
+    // Scheduled local backups (#884)
+    scheduledBackup: 'Scheduled Backups',
+    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',
+    frequency: 'Frequency',
+    backupTime: 'Time',
+    retention: 'Retention',
+    retentionDescription: 'Number of backups to keep',
+    outputPath: 'Output Path',
+    outputPathPlaceholder: 'Default: {{path}}',
+    outputPathDescription: 'Leave empty for default location',
+    runNow: 'Run Now',
+    backupFiles: 'Backup Files',
+    noScheduledBackups: 'No backups yet',
+    deleteBackup: 'Delete',
+    deleteBackupConfirm: 'Delete this backup file?',
+    backupRunning: 'Backup in progress...',
+    scheduledBackupComplete: 'Backup completed successfully',
+    scheduledBackupFailed: 'Backup failed',
+    nextBackup: 'Next backup',
+    backupSize: 'Size',
+    utc: 'UTC',
+    defaultPathLabel: 'Default:',
+
     // Category labels
     // Category labels
     categories: {
     categories: {
       settings: '设置',
       settings: '设置',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-elzVJagq.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DkQ9Ij-0.js"></script>
+    <script type="module" crossorigin src="/assets/index-elzVJagq.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Czpqfgna.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Czpqfgna.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff