feat: punkt 6 (backup) — ARQ cron job, backup config in settings, backup-now trigger, backup history, migration 0130

This commit is contained in:
Agent Zero
2026-08-20 13:47:35 +02:00
parent 9a20ae5528
commit 10b1f83fb3
11 changed files with 900 additions and 17 deletions
@@ -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")
+238
View File
@@ -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)
+6 -1
View File
@@ -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,
),
]
+3 -1
View File
@@ -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)
+112 -3
View File
@@ -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}
+3 -1
View File
@@ -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"
+9
View File
@@ -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:
+74 -1
View File
@@ -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<BackupConfig> {
return apiGet<BackupConfig>('/system-settings/backup-config');
}
export async function updateBackupConfig(config: Partial<BackupConfig>): Promise<BackupConfig> {
return apiPut<BackupConfig>('/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<BackupHistoryResponse> {
return apiGet<BackupHistoryResponse>('/system-settings/backup-history');
}
export function useBackupConfig() {
return useQuery<BackupConfig>({
queryKey: ['backup-config'],
queryFn: fetchBackupConfig,
});
}
export function useUpdateBackupConfig() {
const queryClient = useQueryClient();
return useMutation<BackupConfig, Error, Partial<BackupConfig>>({
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<BackupHistoryResponse>({
queryKey: ['backup-history'],
queryFn: fetchBackupHistory,
});
}
+62 -1
View File
@@ -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"
}
}
}
+62 -1
View File
@@ -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"
}
}
}
+306 -8
View File
@@ -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 }) {
<span
className={clsx(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium',
c.classes
c.classes,
)}
>
{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<BackupConfig | null>(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 (
<Card>
<div className="py-8 text-center">
<div className="inline-flex items-center gap-2 text-secondary-500">
<Settings className="h-5 w-5 animate-pulse" aria-hidden="true" />
{t('common.loading', 'Laden...')}
</div>
</div>
</Card>
);
}
return (
<Card>
<div className="p-4 space-y-4">
{/* Section header */}
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-secondary-400" aria-hidden="true" />
<div>
<h2 className="text-base font-semibold text-secondary-900">
{t('backup.configTitle', 'Backup-Automatisierung')}
</h2>
<p className="text-sm text-secondary-500">
{t('backup.configSubtitle', 'Konfigurieren Sie automatische Backups')}
</p>
</div>
</div>
{/* Enabled toggle */}
<div className="flex items-center justify-between rounded-md border border-secondary-200 p-3">
<div>
<p className="text-sm font-medium text-secondary-900">
{t('backup.configEnabled', 'Automatische Backups aktiviert')}
</p>
<p className="text-xs text-secondary-500">
{t('backup.configEnabledDesc', 'Aktiviert geplante Backups über den ARQ Worker')}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={localConfig.backup_enabled}
onClick={() => setLocalConfig({ ...localConfig, backup_enabled: !localConfig.backup_enabled })}
className={clsx(
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2',
localConfig.backup_enabled ? 'bg-primary-600' : 'bg-secondary-300',
)}
data-testid="backup-enabled-toggle"
>
<span
className={clsx(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition',
localConfig.backup_enabled ? 'translate-x-5' : 'translate-x-0',
)}
/>
</button>
</div>
{/* Interval select */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('backup.configInterval', 'Intervall')}
</label>
<p className="text-xs text-secondary-500 mb-2">
{t('backup.configIntervalDesc', 'Wie oft Backups erstellt werden')}
</p>
<select
value={localConfig.backup_interval}
onChange={(e) => setLocalConfig({ ...localConfig, backup_interval: e.target.value })}
className={clsx(
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'text-secondary-900',
)}
data-testid="backup-interval-select"
>
<option value="6h">{t('backup.interval6h', 'Alle 6 Stunden')}</option>
<option value="12h">{t('backup.interval12h', 'Alle 12 Stunden')}</option>
<option value="24h">{t('backup.interval24h', 'Täglich (24 Stunden)')}</option>
<option value="48h">{t('backup.interval48h', 'Alle 48 Stunden')}</option>
<option value="weekly">{t('backup.intervalWeekly', 'Wöchentlich')}</option>
</select>
</div>
{/* Retention select */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('backup.configRetention', 'Aufbewahrung (Tage)')}
</label>
<p className="text-xs text-secondary-500 mb-2">
{t('backup.configRetentionDesc', 'Anzahl der Tage, die Backups aufbewahrt werden')}
</p>
<select
value={String(localConfig.backup_retention_days)}
onChange={(e) => setLocalConfig({ ...localConfig, backup_retention_days: parseInt(e.target.value, 10) })}
className={clsx(
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'text-secondary-900',
)}
data-testid="backup-retention-select"
>
<option value="7">7</option>
<option value="14">14</option>
<option value="30">30</option>
<option value="90">90</option>
</select>
</div>
{/* Destination select */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('backup.configDestination', 'Ziel')}
</label>
<p className="text-xs text-secondary-500 mb-2">
{t('backup.configDestinationDesc', 'Wo Backups gespeichert werden')}
</p>
<select
value={localConfig.backup_destination}
onChange={(e) => setLocalConfig({ ...localConfig, backup_destination: e.target.value })}
className={clsx(
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'text-secondary-900',
)}
data-testid="backup-destination-select"
>
<option value="local">{t('backup.destLocal', 'Lokal')}</option>
<option value="s3">{t('backup.destS3', 'S3')}</option>
<option value="nextcloud">{t('backup.destNextcloud', 'Nextcloud')}</option>
</select>
</div>
{/* Save + Trigger buttons */}
<div className="flex items-center justify-between gap-2 pt-2">
<Button
variant="secondary"
icon={<Play className="h-4 w-4" />}
onClick={handleTriggerNow}
isLoading={triggerMutation.isPending}
disabled={triggerMutation.isPending}
data-testid="backup-now-btn"
>
{t('backup.backupNow', 'Backup jetzt ausführen')}
</Button>
<Button
variant="primary"
icon={<Save className="h-4 w-4" />}
onClick={handleSave}
isLoading={updateMutation.isPending}
disabled={updateMutation.isPending}
data-testid="backup-config-save-btn"
>
{t('backup.configSave', 'Konfiguration speichern')}
</Button>
</div>
{/* Success / Error messages */}
{updateMutation.isSuccess && (
<div className="rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-700">
{t('backup.configSaved', 'Konfiguration gespeichert')}
</div>
)}
{updateMutation.isError && (
<div className="rounded-md bg-danger-50 border border-danger-200 px-3 py-2 text-sm text-danger-700">
{t('backup.configSaveError', 'Fehler beim Speichern der Konfiguration')}
</div>
)}
{triggerMutation.isSuccess && (
<div className="rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-700">
{t('backup.backupNowSuccess', 'Backup-Auftrag gestartet')}
</div>
)}
{triggerMutation.isError && (
<div className="rounded-md bg-danger-50 border border-danger-200 px-3 py-2 text-sm text-danger-700">
{t('backup.backupNowError', 'Fehler beim Starten des Backups')}
</div>
)}
</div>
</Card>
);
}
// ─── Backup History Section ─────────────────────────────────────────────────
function BackupHistorySection() {
const { t } = useTranslation();
const { data, isLoading } = useBackupHistory();
const history = data?.history ?? [];
return (
<Card>
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<History className="h-5 w-5 text-secondary-400" aria-hidden="true" />
<h2 className="text-base font-semibold text-secondary-900">
{t('backup.historyTitle', 'Backup-Historie')}
</h2>
</div>
{isLoading ? (
<div className="py-6 text-center text-sm text-secondary-500">
{t('common.loading', 'Laden...')}
</div>
) : history.length === 0 ? (
<div className="py-6 text-center text-sm text-secondary-500">
{t('backup.historyEmpty', 'Noch keine Backup-Historie vorhanden.')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full" data-testid="backup-history-table">
<thead>
<tr className="border-b border-secondary-200">
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.historyDate', 'Datum')}
</th>
<th className="text-center text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.historyStatus', 'Status')}
</th>
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.historyDestination', 'Ziel')}
</th>
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.historyError', 'Fehler')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{history.map((entry: BackupHistoryEntry) => (
<tr key={entry.id} className="hover:bg-secondary-50 transition-colors">
<td className="px-3 py-2">
<span className="text-sm text-secondary-900">{formatDate(entry.timestamp)}</span>
</td>
<td className="px-3 py-2 text-center">
<StatusBadge status={entry.success ? 'completed' : 'failed'} />
</td>
<td className="px-3 py-2">
<span className="text-sm text-secondary-700">{entry.destination}</span>
</td>
<td className="px-3 py-2 max-w-xs">
<span className="text-sm text-secondary-500 truncate block">
{entry.error || '—'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Card>
);
}
// ─── SettingsBackup Page ─────────────────────────────────────────────────────
export function SettingsBackupPage() {
@@ -355,6 +647,9 @@ export function SettingsBackupPage() {
</div>
)}
{/* Backup Config Section */}
<BackupConfigSection />
{/* Backups list */}
<Card>
{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() {
)}
</Card>
{/* Backup History Section */}
<BackupHistorySection />
{/* Restore Modal */}
<RestoreModal
open={showRestoreModal}