feat: punkt 6 (backup) — ARQ cron job, backup config in settings, backup-now trigger, backup history, migration 0130
This commit is contained in:
@@ -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")
|
||||||
@@ -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
@@ -236,7 +236,7 @@ def _lazy_register_plugin_jobs() -> None:
|
|||||||
if not registry.list_discovered():
|
if not registry.list_discovered():
|
||||||
registry.discover_builtins()
|
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():
|
for plugin_name in registry.list_discovered():
|
||||||
plugin = registry.get_plugin(plugin_name)
|
plugin = registry.get_plugin(plugin_name)
|
||||||
if plugin is None:
|
if plugin is None:
|
||||||
@@ -371,4 +371,9 @@ class WorkerSettings:
|
|||||||
_wrap_cron_with_lock("cleanup_outbox", cleanup_outbox_job, ttl_seconds=300),
|
_wrap_cron_with_lock("cleanup_outbox", cleanup_outbox_job, ttl_seconds=300),
|
||||||
minute=0,
|
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,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
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 JSONB
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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_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_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")
|
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 plugin settings (JSONB)
|
||||||
automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
from app.core.jobs import enqueue_job
|
||||||
from app.deps import require_permission
|
from app.deps import require_permission
|
||||||
|
from app.models.audit import AuditLog
|
||||||
from app.schemas.system_settings import SystemSettingsResponse, SystemSettingsUpsert
|
from app.schemas.system_settings import SystemSettingsResponse, SystemSettingsUpsert
|
||||||
from app.services import system_settings_service
|
from app.services import system_settings_service
|
||||||
|
|
||||||
@@ -51,6 +54,112 @@ async def upsert_system_settings(
|
|||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
|
||||||
data = body.model_dump()
|
data = body.model_dump()
|
||||||
return await system_settings_service.upsert_system_settings(db, tenant_id, user_id, data)
|
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}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class SystemSettingsUpsert(BaseModel):
|
|||||||
theme_font_family: str = Field("Inter", max_length=100)
|
theme_font_family: str = Field("Inter", max_length=100)
|
||||||
theme_border_radius: str = Field("0.5rem", max_length=20)
|
theme_border_radius: str = Field("0.5rem", max_length=20)
|
||||||
# Backup configuration
|
# 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_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")
|
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_font_family: str = "Inter"
|
||||||
theme_border_radius: str = "0.5rem"
|
theme_border_radius: str = "0.5rem"
|
||||||
# Backup configuration
|
# Backup configuration
|
||||||
|
backup_enabled: bool = False
|
||||||
backup_interval: str = "daily"
|
backup_interval: str = "daily"
|
||||||
backup_retention_days: int = 7
|
backup_retention_days: int = 7
|
||||||
backup_destination: str = "local"
|
backup_destination: str = "local"
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ def _settings_to_dict(s: SystemSettings) -> dict[str, Any]:
|
|||||||
"theme_accent_color": s.theme_accent_color,
|
"theme_accent_color": s.theme_accent_color,
|
||||||
"theme_font_family": s.theme_font_family,
|
"theme_font_family": s.theme_font_family,
|
||||||
"theme_border_radius": s.theme_border_radius,
|
"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,
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||||
"updated_at": s.updated_at.isoformat() if s.updated_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_accent_color=data.get("theme_accent_color", "#d946ef"),
|
||||||
theme_font_family=data.get("theme_font_family", "Inter"),
|
theme_font_family=data.get("theme_font_family", "Inter"),
|
||||||
theme_border_radius=data.get("theme_border_radius", "0.5rem"),
|
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)
|
db.add(settings)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -131,6 +139,7 @@ async def upsert_system_settings(
|
|||||||
"bic", "bank_name", "ceo", "trade_register",
|
"bic", "bank_name", "ceo", "trade_register",
|
||||||
"invoice_prefix", "quote_prefix", "payment_terms_days",
|
"invoice_prefix", "quote_prefix", "payment_terms_days",
|
||||||
"theme_primary_color", "theme_accent_color", "theme_font_family", "theme_border_radius",
|
"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:
|
for field in all_fields:
|
||||||
if field in data and data[field] is not None:
|
if field in data and data[field] is not None:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* - Delete backup
|
* - Delete backup
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiGet, apiPost, apiDelete } from './client';
|
import { apiGet, apiPost, apiPut, apiDelete } from './client';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1328,5 +1328,66 @@
|
|||||||
"preview": "Vorschau",
|
"preview": "Vorschau",
|
||||||
"split": "Geteilt",
|
"split": "Geteilt",
|
||||||
"writeLabel": "Markdown-Inhalt"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1328,5 +1328,66 @@
|
|||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"split": "Split",
|
"split": "Split",
|
||||||
"writeLabel": "Markdown content"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* SettingsBackup page — Backup & Restore management.
|
* SettingsBackup page — Backup & Restore management with automation config.
|
||||||
*
|
*
|
||||||
* Features:
|
* 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
|
* - 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
|
* - Restore dialog with warning text + type "RESTORE" to confirm
|
||||||
* - Auto-refresh when backup is pending
|
* - 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 { useTranslation } from 'react-i18next';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Plus, Download, Trash2, AlertTriangle, HardDrive, CheckCircle, XCircle, Clock } from 'lucide-react';
|
import {
|
||||||
import { useBackups, useCreateBackup, useRestoreBackup, useDeleteBackup, type Backup } from '@/api/backups';
|
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 { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
@@ -71,7 +80,7 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium',
|
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium',
|
||||||
c.classes
|
c.classes,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{c.icon}
|
{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 ─────────────────────────────────────────────────────
|
// ─── SettingsBackup Page ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SettingsBackupPage() {
|
export function SettingsBackupPage() {
|
||||||
@@ -355,6 +647,9 @@ export function SettingsBackupPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Backup Config Section */}
|
||||||
|
<BackupConfigSection />
|
||||||
|
|
||||||
{/* Backups list */}
|
{/* Backups list */}
|
||||||
<Card>
|
<Card>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -446,7 +741,7 @@ export function SettingsBackupPage() {
|
|||||||
'min-h-touch min-w-touch',
|
'min-h-touch min-w-touch',
|
||||||
backup.status === 'completed'
|
backup.status === 'completed'
|
||||||
? 'text-secondary-400 hover:text-primary-600 hover:bg-primary-50'
|
? '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')}
|
aria-label={t('backup.restoreLabel', 'Backup wiederherstellen')}
|
||||||
data-testid={`backup-restore-btn-${backup.id}`}
|
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',
|
'inline-flex items-center justify-center rounded-md p-1.5',
|
||||||
'text-secondary-400 hover:text-danger-600 hover:bg-danger-50',
|
'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',
|
'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')}
|
aria-label={t('backup.deleteLabel', 'Backup löschen')}
|
||||||
data-testid={`backup-delete-btn-${backup.id}`}
|
data-testid={`backup-delete-btn-${backup.id}`}
|
||||||
@@ -477,6 +772,9 @@ export function SettingsBackupPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Backup History Section */}
|
||||||
|
<BackupHistorySection />
|
||||||
|
|
||||||
{/* Restore Modal */}
|
{/* Restore Modal */}
|
||||||
<RestoreModal
|
<RestoreModal
|
||||||
open={showRestoreModal}
|
open={showRestoreModal}
|
||||||
|
|||||||
Reference in New Issue
Block a user