From 10b1f83fb373e2918b63af496b0c831490029e8f Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 20 Aug 2026 13:47:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20punkt=206=20(backup)=20=E2=80=94=20ARQ?= =?UTF-8?q?=20cron=20job,=20backup=20config=20in=20settings,=20backup-now?= =?UTF-8?q?=20trigger,=20backup=20history,=20migration=200130?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic/versions/0130_add_backup_enabled.py | 25 ++ app/core/backup_job.py | 238 +++++++++++++++ app/core/worker.py | 7 +- app/models/system_settings.py | 4 +- app/routes/system_settings.py | 115 ++++++- app/schemas/system_settings.py | 4 +- app/services/system_settings_service.py | 9 + frontend/src/api/backups.ts | 75 ++++- frontend/src/i18n/locales/de.json | 63 +++- frontend/src/i18n/locales/en.json | 63 +++- frontend/src/pages/SettingsBackup.tsx | 314 +++++++++++++++++++- 11 files changed, 900 insertions(+), 17 deletions(-) create mode 100644 alembic/versions/0130_add_backup_enabled.py create mode 100644 app/core/backup_job.py diff --git a/alembic/versions/0130_add_backup_enabled.py b/alembic/versions/0130_add_backup_enabled.py new file mode 100644 index 0000000..4bb420d --- /dev/null +++ b/alembic/versions/0130_add_backup_enabled.py @@ -0,0 +1,25 @@ +"""Add backup_enabled column to system_settings table. + +Revision ID: 0130 +Revises: 0129 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0130" +down_revision = "0129" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "system_settings", + sa.Column("backup_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + + +def downgrade() -> None: + op.drop_column("system_settings", "backup_enabled") diff --git a/app/core/backup_job.py b/app/core/backup_job.py new file mode 100644 index 0000000..b522375 --- /dev/null +++ b/app/core/backup_job.py @@ -0,0 +1,238 @@ +"""ARQ backup job — scheduled database backup via scripts/backup.py. + +Reads backup configuration from system settings, executes backup.py as a +subprocess, logs the result to audit_log, and notifies admins on failure. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +import uuid +from typing import Any + +logger = logging.getLogger(__name__) + +# Path to the backup script relative to project root +_BACKUP_SCRIPT = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "scripts", + "backup.py", +) + + +async def _get_backup_config() -> dict[str, Any]: + """Read backup configuration from system settings for all tenants. + + Returns the first tenant's settings that have backup_enabled=True, + or defaults if no settings exist. + """ + from sqlalchemy import select as sa_select + + from app.core.db import get_worker_session_factory + from app.models.system_settings import SystemSettings + + factory = get_worker_session_factory() + async with factory() as db: + result = await db.execute( + sa_select(SystemSettings).where( + SystemSettings.backup_enabled.is_(True), + SystemSettings.deleted_at.is_(None), + ).limit(1) + ) + settings = result.scalar_one_or_none() + + if settings is None: + return { + "backup_enabled": False, + "backup_interval": "daily", + "backup_retention_days": 7, + "backup_destination": "local", + "tenant_id": None, + } + + return { + "backup_enabled": True, + "backup_interval": settings.backup_interval, + "backup_retention_days": settings.backup_retention_days, + "backup_destination": settings.backup_destination, + "tenant_id": settings.tenant_id, + } + + +async def run_backup_job(ctx: dict[str, Any]) -> dict[str, Any]: + """Execute a scheduled backup by calling scripts/backup.py as a subprocess. + + Reads backup configuration from system settings. If backup_enabled is + False, the job is silently skipped. + + Returns a dict with keys: success (bool), message (str), backup_id (str|None). + """ + config = await _get_backup_config() + + if not config["backup_enabled"]: + logger.debug("Backup job skipped — backup_enabled is False") + return {"success": False, "message": "Backup disabled", "backup_id": None} + + tenant_id = config.get("tenant_id") + retention_days = config.get("backup_retention_days", 7) + destination = config.get("backup_destination", "local") + + logger.info( + "Starting scheduled backup: destination=%s, retention=%dd", + destination, + retention_days, + ) + + # Build subprocess command + cmd = [ + sys.executable, + _BACKUP_SCRIPT, + "--destination", + destination, + "--retention-days", + str(retention_days), + ] + + # Pass environment with DATABASE_URL + env = os.environ.copy() + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + success = process.returncode == 0 + output = stdout.decode() if stdout else "" + error = stderr.decode() if stderr else "" + + if success: + logger.info("Scheduled backup completed successfully: %s", output[-500:] if output else "") + else: + logger.error("Scheduled backup failed (exit %d): %s", process.returncode, error) + + # Log to audit_log + await _log_backup_result( + tenant_id=tenant_id, + success=success, + output=output, + error=error, + destination=destination, + retention_days=retention_days, + ) + + # Notify admin on failure + if not success and tenant_id: + await _notify_admin_failure(tenant_id, error) + + return { + "success": success, + "message": "Backup completed" if success else f"Backup failed: {error[:200]}", + "backup_id": None, + } + + except Exception as exc: + logger.exception("Backup job encountered an exception") + if tenant_id: + await _log_backup_result( + tenant_id=tenant_id, + success=False, + output="", + error=str(exc), + destination=destination, + retention_days=retention_days, + ) + await _notify_admin_failure(tenant_id, str(exc)) + return {"success": False, "message": str(exc), "backup_id": None} + + +async def _log_backup_result( + tenant_id: uuid.UUID | None, + success: bool, + output: str, + error: str, + destination: str, + retention_days: int, +) -> None: + """Write backup result to audit_log.""" + from app.core.audit import log_audit + from app.core.db import get_worker_session_factory + + if tenant_id is None: + return + + factory = get_worker_session_factory() + async with factory() as db: + try: + await log_audit( + db, + tenant_id=tenant_id, + user_id=None, + action="backup_success" if success else "backup_failed", + entity_type="backup", + entity_id=None, + changes={ + "success": success, + "destination": destination, + "retention_days": retention_days, + "output": output[-1000:] if output else "", + "error": error[-1000:] if error else "", + }, + ) + await db.commit() + except Exception: + logger.exception("Failed to write backup audit log") + await db.rollback() + + +async def _notify_admin_failure(tenant_id: uuid.UUID, error: str) -> None: + """Send a notification to admin users about backup failure.""" + from sqlalchemy import select as sa_select + + from app.core.db import get_worker_session_factory + from app.core.notifications import post_system_message + from app.models.user import User + + factory = get_worker_session_factory() + async with factory() as db: + try: + # Find system admin users for this tenant + result = await db.execute( + sa_select(User).where( + User.tenant_id == tenant_id, + User.is_system_admin.is_(True), + User.deleted_at.is_(None), + ).limit(1) + ) + admin_user = result.scalar_one_or_none() + + if admin_user is None: + logger.warning("No admin user found to notify about backup failure") + return + + await post_system_message( + db, + tenant_id=tenant_id, + user_id=admin_user.id, + message_type="backup_failed", + title="Backup fehlgeschlagen", + body=f"Das geplante Backup ist fehlgeschlagen: {error[:500]}", + entity_type="backup", + severity="error", + ) + await db.commit() + except Exception: + logger.exception("Failed to notify admin about backup failure") + await db.rollback() + + +# Register with the job registry +from app.core.job_registry import register_job # noqa: E402 + +register_job("run_backup", run_backup_job) diff --git a/app/core/worker.py b/app/core/worker.py index 95e5146..041b92c 100644 --- a/app/core/worker.py +++ b/app/core/worker.py @@ -236,7 +236,7 @@ def _lazy_register_plugin_jobs() -> None: if not registry.list_discovered(): registry.discover_builtins() - job_modules: list[str] = ["app.core.jobs", "app.services.import_export_jobs"] + job_modules: list[str] = ["app.core.jobs", "app.core.backup_job", "app.services.import_export_jobs"] for plugin_name in registry.list_discovered(): plugin = registry.get_plugin(plugin_name) if plugin is None: @@ -371,4 +371,9 @@ class WorkerSettings: _wrap_cron_with_lock("cleanup_outbox", cleanup_outbox_job, ttl_seconds=300), minute=0, ), + # Scheduled backup — daily at 02:00 (guarded by distributed lock) + cron( + _wrap_cron_with_lock("run_backup", get_job("run_backup"), ttl_seconds=600), + hour=2, minute=0, + ), ] diff --git a/app/models/system_settings.py b/app/models/system_settings.py index 9b66434..5e1d2a7 100644 --- a/app/models/system_settings.py +++ b/app/models/system_settings.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid -from sqlalchemy import ForeignKey, Index, Integer, String +from sqlalchemy import Boolean, ForeignKey, Index, Integer, String from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column @@ -50,5 +50,7 @@ class SystemSettings(Base, TenantMixin): theme_accent_color: Mapped[str] = mapped_column(String(20), nullable=False, default="#d946ef") theme_font_family: Mapped[str] = mapped_column(String(100), nullable=False, default="Inter") theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem") + # Backup configuration + backup_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") # Automation plugin settings (JSONB) automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) diff --git a/app/routes/system_settings.py b/app/routes/system_settings.py index c98f263..f805b6c 100644 --- a/app/routes/system_settings.py +++ b/app/routes/system_settings.py @@ -1,14 +1,17 @@ -"""System settings routes — get and upsert (admin only).""" +"""System settings routes — get, upsert, backup config (admin only).""" from __future__ import annotations import uuid -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select as sa_select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db +from app.core.jobs import enqueue_job from app.deps import require_permission +from app.models.audit import AuditLog from app.schemas.system_settings import SystemSettingsResponse, SystemSettingsUpsert from app.services import system_settings_service @@ -51,6 +54,112 @@ async def upsert_system_settings( tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) - data = body.model_dump() return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data) + + +# ── Backup configuration endpoints ─────────────────────────────────────────── + + +@router.get("/backup-config") +async def get_backup_config( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("settings:read")), +): + """Get backup configuration for the current tenant.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + result = await system_settings_service.get_system_settings(db, tenant_id) + if result is None: + return { + "backup_enabled": False, + "backup_interval": "24h", + "backup_retention_days": 7, + "backup_destination": "local", + } + return { + "backup_enabled": result.get("backup_enabled", False), + "backup_interval": result.get("backup_interval", "24h"), + "backup_retention_days": result.get("backup_retention_days", 7), + "backup_destination": result.get("backup_destination", "local"), + } + + +@router.put("/backup-config") +async def update_backup_config( + body: dict, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("settings:write")), +): + """Update backup configuration. Admin only. + + Accepts: backup_enabled (bool), backup_interval (str), + backup_retention_days (int), backup_destination (str). + """ + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + + # Get existing settings + existing = await system_settings_service.get_system_settings(db, tenant_id) + if existing is None: + raise HTTPException(404, detail={"detail": "System settings not found. Please configure company settings first.", "code": "settings_not_found"}) + + # Merge backup fields into existing data + data = dict(existing) + if "backup_enabled" in body: + data["backup_enabled"] = bool(body["backup_enabled"]) + if "backup_interval" in body: + data["backup_interval"] = str(body["backup_interval"]) + if "backup_retention_days" in body: + data["backup_retention_days"] = int(body["backup_retention_days"]) + if "backup_destination" in body: + data["backup_destination"] = str(body["backup_destination"]) + + return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data) + + +@router.post("/backup-now") +async def trigger_backup_now( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("settings:write")), +): + """Trigger an immediate backup via ARQ job. Admin only.""" + job_id = await enqueue_job("run_backup") + if job_id is None: + raise HTTPException(503, detail={"detail": "Failed to enqueue backup job. Worker may not be running.", "code": "enqueue_failed"}) + return {"message": "Backup job enqueued", "job_id": job_id} + + +@router.get("/backup-history") +async def get_backup_history( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("settings:read")), +): + """Get last 10 backup results from audit log.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + + stmt = ( + sa_select(AuditLog) + .where( + AuditLog.tenant_id == tenant_id, + AuditLog.entity_type == "backup", + AuditLog.action.in_(["backup_success", "backup_failed"]), + ) + .order_by(AuditLog.timestamp.desc()) + .limit(10) + ) + result = await db.execute(stmt) + entries = result.scalars().all() + + history = [] + for entry in entries: + changes = entry.changes or {} + history.append({ + "id": str(entry.id), + "timestamp": entry.timestamp.isoformat() if entry.timestamp else None, + "action": entry.action, + "success": changes.get("success", entry.action == "backup_success"), + "destination": changes.get("destination", "local"), + "error": changes.get("error", ""), + }) + + return {"history": history} diff --git a/app/schemas/system_settings.py b/app/schemas/system_settings.py index 2b13a2e..67e97bd 100644 --- a/app/schemas/system_settings.py +++ b/app/schemas/system_settings.py @@ -30,7 +30,8 @@ class SystemSettingsUpsert(BaseModel): theme_font_family: str = Field("Inter", max_length=100) theme_border_radius: str = Field("0.5rem", max_length=20) # Backup configuration - backup_interval: str = Field("daily", max_length=20, description="Backup interval: hourly, daily, weekly, manual") + backup_enabled: bool = Field(False, description="Enable automated backups") + backup_interval: str = Field("daily", max_length=20, description="Backup interval: 6h, 12h, 24h, 48h, weekly") backup_retention_days: int = Field(7, ge=1, le=365, description="Days to keep backups") backup_destination: str = Field("local", max_length=20, description="Backup destination: local, s3, nextcloud") @@ -62,6 +63,7 @@ class SystemSettingsResponse(BaseModel): theme_font_family: str = "Inter" theme_border_radius: str = "0.5rem" # Backup configuration + backup_enabled: bool = False backup_interval: str = "daily" backup_retention_days: int = 7 backup_destination: str = "local" diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index aaf712f..c78bcea 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -38,6 +38,10 @@ def _settings_to_dict(s: SystemSettings) -> dict[str, Any]: "theme_accent_color": s.theme_accent_color, "theme_font_family": s.theme_font_family, "theme_border_radius": s.theme_border_radius, + "backup_enabled": s.backup_enabled, + "backup_interval": s.backup_interval, + "backup_retention_days": s.backup_retention_days, + "backup_destination": s.backup_destination, "created_at": s.created_at.isoformat() if s.created_at else None, "updated_at": s.updated_at.isoformat() if s.updated_at else None, } @@ -114,6 +118,10 @@ async def upsert_system_settings( theme_accent_color=data.get("theme_accent_color", "#d946ef"), theme_font_family=data.get("theme_font_family", "Inter"), theme_border_radius=data.get("theme_border_radius", "0.5rem"), + backup_enabled=data.get("backup_enabled", False), + backup_interval=data.get("backup_interval", "daily"), + backup_retention_days=data.get("backup_retention_days", 7), + backup_destination=data.get("backup_destination", "local"), ) db.add(settings) await db.flush() @@ -131,6 +139,7 @@ async def upsert_system_settings( "bic", "bank_name", "ceo", "trade_register", "invoice_prefix", "quote_prefix", "payment_terms_days", "theme_primary_color", "theme_accent_color", "theme_font_family", "theme_border_radius", + "backup_enabled", "backup_interval", "backup_retention_days", "backup_destination", ) for field in all_fields: if field in data and data[field] is not None: diff --git a/frontend/src/api/backups.ts b/frontend/src/api/backups.ts index b39f085..08321ef 100644 --- a/frontend/src/api/backups.ts +++ b/frontend/src/api/backups.ts @@ -8,7 +8,7 @@ * - Delete backup */ -import { apiGet, apiPost, apiDelete } from './client'; +import { apiGet, apiPost, apiPut, apiDelete } from './client'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -94,3 +94,76 @@ export function useDeleteBackup() { }, }); } + +// ─── Backup Configuration ──────────────────────────────────────────────────── + +export interface BackupConfig { + backup_enabled: boolean; + backup_interval: string; + backup_retention_days: number; + backup_destination: string; +} + +export interface BackupHistoryEntry { + id: string; + timestamp: string | null; + action: string; + success: boolean; + destination: string; + error: string; +} + +export interface BackupHistoryResponse { + history: BackupHistoryEntry[]; +} + +export async function fetchBackupConfig(): Promise { + return apiGet('/system-settings/backup-config'); +} + +export async function updateBackupConfig(config: Partial): Promise { + return apiPut('/system-settings/backup-config', config); +} + +export async function triggerBackupNow(): Promise<{ message: string; job_id: string }> { + return apiPost<{ message: string; job_id: string }>('/system-settings/backup-now'); +} + +export async function fetchBackupHistory(): Promise { + return apiGet('/system-settings/backup-history'); +} + +export function useBackupConfig() { + return useQuery({ + queryKey: ['backup-config'], + queryFn: fetchBackupConfig, + }); +} + +export function useUpdateBackupConfig() { + const queryClient = useQueryClient(); + return useMutation>({ + mutationFn: updateBackupConfig, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['backup-config'] }); + }, + }); +} + +export function useTriggerBackupNow() { + const queryClient = useQueryClient(); + return useMutation<{ message: string; job_id: string }, Error>({ + mutationFn: triggerBackupNow, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['backups'] }); + queryClient.invalidateQueries({ queryKey: ['backup-history'] }); + }, + }); +} + +export function useBackupHistory() { + return useQuery({ + queryKey: ['backup-history'], + queryFn: fetchBackupHistory, + }); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 78e228a..af2df29 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1328,5 +1328,66 @@ "preview": "Vorschau", "split": "Geteilt", "writeLabel": "Markdown-Inhalt" + }, + "backup": { + "title": "Backup & Restore", + "subtitle": "Erstellen und verwalten Sie Datenbank-Backups", + "createBtn": "Backup jetzt erstellen", + "createFirst": "Erstes Backup erstellen", + "loadError": "Fehler beim Laden der Backups.", + "createError": "Fehler beim Erstellen des Backups.", + "restoreError": "Fehler bei der Wiederherstellung.", + "empty": "Noch keine Backups vorhanden.", + "date": "Datum", + "filename": "Dateiname", + "size": "Größe", + "status": "Status", + "statusPending": "Wird erstellt...", + "statusCompleted": "Abgeschlossen", + "statusFailed": "Fehlgeschlagen", + "restoreTitle": "Backup wiederherstellen", + "restoreWarningTitle": "Achtung: Destruktiver Vorgang", + "restoreWarningText": "Die Wiederherstellung überschreibt die aktuelle Datenbank vollständig. Alle seit dem Backup vorgenommenen Änderungen gehen verloren. Dieser Vorgang kann nicht rückgängig gemacht werden.", + "restoreFile": "Backup-Datei:", + "restoreDate": "Erstellt am:", + "restoreSize": "Größe:", + "restoreConfirmLabel": "Geben Sie \"RESTORE\" ein, um zu bestätigen:", + "restoreBtn": "Wiederherstellen", + "restoreLabel": "Backup wiederherstellen", + "deleteTitle": "Backup löschen", + "deleteConfirm": "Möchten Sie das Backup", + "deleteConfirmEnd": "wirklich löschen?", + "deleteWarning": "Die Backup-Datei wird dauerhaft entfernt.", + "deleteLabel": "Backup löschen", + "configTitle": "Backup-Automatisierung", + "configSubtitle": "Konfigurieren Sie automatische Backups", + "configEnabled": "Automatische Backups aktiviert", + "configEnabledDesc": "Aktiviert geplante Backups über den ARQ Worker", + "configInterval": "Intervall", + "configIntervalDesc": "Wie oft Backups erstellt werden", + "configRetention": "Aufbewahrung (Tage)", + "configRetentionDesc": "Anzahl der Tage, die Backups aufbewahrt werden", + "configDestination": "Ziel", + "configDestinationDesc": "Wo Backups gespeichert werden", + "configSave": "Konfiguration speichern", + "configSaved": "Konfiguration gespeichert", + "configSaveError": "Fehler beim Speichern der Konfiguration", + "backupNow": "Backup jetzt ausführen", + "backupNowSuccess": "Backup-Auftrag gestartet", + "backupNowError": "Fehler beim Starten des Backups", + "historyTitle": "Backup-Historie", + "historyEmpty": "Noch keine Backup-Historie vorhanden.", + "historyDate": "Datum", + "historyStatus": "Status", + "historyDestination": "Ziel", + "historyError": "Fehler", + "interval6h": "Alle 6 Stunden", + "interval12h": "Alle 12 Stunden", + "interval24h": "Täglich (24 Stunden)", + "interval48h": "Alle 48 Stunden", + "intervalWeekly": "Wöchentlich", + "destLocal": "Lokal", + "destS3": "S3", + "destNextcloud": "Nextcloud" } -} \ No newline at end of file +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 88b0de3..5566430 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1328,5 +1328,66 @@ "preview": "Preview", "split": "Split", "writeLabel": "Markdown content" + }, + "backup": { + "title": "Backup & Restore", + "subtitle": "Create and manage database backups", + "createBtn": "Create Backup Now", + "createFirst": "Create First Backup", + "loadError": "Failed to load backups.", + "createError": "Failed to create backup.", + "restoreError": "Failed to restore backup.", + "empty": "No backups yet.", + "date": "Date", + "filename": "Filename", + "size": "Size", + "status": "Status", + "statusPending": "In progress...", + "statusCompleted": "Completed", + "statusFailed": "Failed", + "restoreTitle": "Restore Backup", + "restoreWarningTitle": "Warning: Destructive Operation", + "restoreWarningText": "Restoring will completely overwrite the current database. All changes made since the backup will be lost. This operation cannot be undone.", + "restoreFile": "Backup file:", + "restoreDate": "Created on:", + "restoreSize": "Size:", + "restoreConfirmLabel": "Type \"RESTORE\" to confirm:", + "restoreBtn": "Restore", + "restoreLabel": "Restore backup", + "deleteTitle": "Delete Backup", + "deleteConfirm": "Do you want to delete backup", + "deleteConfirmEnd": "?", + "deleteWarning": "The backup file will be permanently removed.", + "deleteLabel": "Delete backup", + "configTitle": "Backup Automation", + "configSubtitle": "Configure automatic backups", + "configEnabled": "Automatic backups enabled", + "configEnabledDesc": "Enables scheduled backups via ARQ Worker", + "configInterval": "Interval", + "configIntervalDesc": "How often backups are created", + "configRetention": "Retention (days)", + "configRetentionDesc": "Number of days to keep backups", + "configDestination": "Destination", + "configDestinationDesc": "Where backups are stored", + "configSave": "Save Configuration", + "configSaved": "Configuration saved", + "configSaveError": "Failed to save configuration", + "backupNow": "Run Backup Now", + "backupNowSuccess": "Backup job started", + "backupNowError": "Failed to start backup", + "historyTitle": "Backup History", + "historyEmpty": "No backup history yet.", + "historyDate": "Date", + "historyStatus": "Status", + "historyDestination": "Destination", + "historyError": "Error", + "interval6h": "Every 6 hours", + "interval12h": "Every 12 hours", + "interval24h": "Daily (24 hours)", + "interval48h": "Every 48 hours", + "intervalWeekly": "Weekly", + "destLocal": "Local", + "destS3": "S3", + "destNextcloud": "Nextcloud" } -} \ No newline at end of file +} diff --git a/frontend/src/pages/SettingsBackup.tsx b/frontend/src/pages/SettingsBackup.tsx index bb6c7e8..8a73ac3 100644 --- a/frontend/src/pages/SettingsBackup.tsx +++ b/frontend/src/pages/SettingsBackup.tsx @@ -1,18 +1,27 @@ /** - * SettingsBackup page — Backup & Restore management. + * SettingsBackup page — Backup & Restore management with automation config. * * Features: - * - "Backup jetzt erstellen" button (POST) + * - Backup automation config (enabled, interval, retention, destination) + * - "Backup jetzt" button (POST /backup-now) * - List of backups: date, size, status badge, restore/delete buttons + * - Backup history (last 10 backup results from audit log) * - Restore dialog with warning text + type "RESTORE" to confirm * - Auto-refresh when backup is pending */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; -import { Plus, Download, Trash2, AlertTriangle, HardDrive, CheckCircle, XCircle, Clock } from 'lucide-react'; -import { useBackups, useCreateBackup, useRestoreBackup, useDeleteBackup, type Backup } from '@/api/backups'; +import { + Plus, Download, Trash2, AlertTriangle, HardDrive, CheckCircle, XCircle, Clock, + Settings, Play, History, Save, +} from 'lucide-react'; +import { + useBackups, useCreateBackup, useRestoreBackup, useDeleteBackup, + useBackupConfig, useUpdateBackupConfig, useTriggerBackupNow, useBackupHistory, + type Backup, type BackupConfig, type BackupHistoryEntry, +} from '@/api/backups'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; @@ -71,7 +80,7 @@ function StatusBadge({ status }: { status: string }) { {c.icon} @@ -242,6 +251,289 @@ function DeleteModal({ open, backup, onConfirm, onCancel, isDeleting }: DeleteMo ); } +// ─── Backup Config Section ────────────────────────────────────────────────── + +function BackupConfigSection() { + const { t } = useTranslation(); + const { data: config, isLoading } = useBackupConfig(); + const updateMutation = useUpdateBackupConfig(); + const triggerMutation = useTriggerBackupNow(); + + const [localConfig, setLocalConfig] = useState(null); + + useEffect(() => { + if (config) { + setLocalConfig(config); + } + }, [config]); + + const handleSave = useCallback(() => { + if (localConfig) { + updateMutation.mutate(localConfig); + } + }, [localConfig, updateMutation]); + + const handleTriggerNow = useCallback(() => { + triggerMutation.mutate(); + }, [triggerMutation]); + + if (isLoading || !localConfig) { + return ( + +
+
+
+
+
+ ); + } + + return ( + +
+ {/* Section header */} +
+
+ + {/* Enabled toggle */} +
+
+

+ {t('backup.configEnabled', 'Automatische Backups aktiviert')} +

+

+ {t('backup.configEnabledDesc', 'Aktiviert geplante Backups über den ARQ Worker')} +

+
+ +
+ + {/* Interval select */} +
+ +

+ {t('backup.configIntervalDesc', 'Wie oft Backups erstellt werden')} +

+ +
+ + {/* Retention select */} +
+ +

+ {t('backup.configRetentionDesc', 'Anzahl der Tage, die Backups aufbewahrt werden')} +

+ +
+ + {/* Destination select */} +
+ +

+ {t('backup.configDestinationDesc', 'Wo Backups gespeichert werden')} +

+ +
+ + {/* Save + Trigger buttons */} +
+ + +
+ + {/* Success / Error messages */} + {updateMutation.isSuccess && ( +
+ {t('backup.configSaved', 'Konfiguration gespeichert')} +
+ )} + {updateMutation.isError && ( +
+ {t('backup.configSaveError', 'Fehler beim Speichern der Konfiguration')} +
+ )} + {triggerMutation.isSuccess && ( +
+ {t('backup.backupNowSuccess', 'Backup-Auftrag gestartet')} +
+ )} + {triggerMutation.isError && ( +
+ {t('backup.backupNowError', 'Fehler beim Starten des Backups')} +
+ )} +
+
+ ); +} + +// ─── Backup History Section ───────────────────────────────────────────────── + +function BackupHistorySection() { + const { t } = useTranslation(); + const { data, isLoading } = useBackupHistory(); + const history = data?.history ?? []; + + return ( + +
+
+
+ + {isLoading ? ( +
+ {t('common.loading', 'Laden...')} +
+ ) : history.length === 0 ? ( +
+ {t('backup.historyEmpty', 'Noch keine Backup-Historie vorhanden.')} +
+ ) : ( +
+ + + + + + + + + + + {history.map((entry: BackupHistoryEntry) => ( + + + + + + + ))} + +
+ {t('backup.historyDate', 'Datum')} + + {t('backup.historyStatus', 'Status')} + + {t('backup.historyDestination', 'Ziel')} + + {t('backup.historyError', 'Fehler')} +
+ {formatDate(entry.timestamp)} + + + + {entry.destination} + + + {entry.error || '—'} + +
+
+ )} +
+
+ ); +} + // ─── SettingsBackup Page ───────────────────────────────────────────────────── export function SettingsBackupPage() { @@ -355,6 +647,9 @@ export function SettingsBackupPage() { )} + {/* Backup Config Section */} + + {/* Backups list */} {isLoading ? ( @@ -446,7 +741,7 @@ export function SettingsBackupPage() { 'min-h-touch min-w-touch', backup.status === 'completed' ? 'text-secondary-400 hover:text-primary-600 hover:bg-primary-50' - : 'text-secondary-300 cursor-not-allowed' + : 'text-secondary-300 cursor-not-allowed', )} aria-label={t('backup.restoreLabel', 'Backup wiederherstellen')} data-testid={`backup-restore-btn-${backup.id}`} @@ -460,7 +755,7 @@ export function SettingsBackupPage() { 'inline-flex items-center justify-center rounded-md p-1.5', 'text-secondary-400 hover:text-danger-600 hover:bg-danger-50', 'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-500', - 'min-h-touch min-w-touch' + 'min-h-touch min-w-touch', )} aria-label={t('backup.deleteLabel', 'Backup löschen')} data-testid={`backup-delete-btn-${backup.id}`} @@ -477,6 +772,9 @@ export function SettingsBackupPage() { )} + {/* Backup History Section */} + + {/* Restore Modal */}