Phase 4: backups w/ volumes, notifications, settings & users, audit page (0.4.0)

- Backup/restore: per-stack tar.gz incl. named-volume snapshots (helper
  container), upload restore with rename/overwrite/conflict detection.
- Notifications: ntfy/Discord/Slack/Gotify/generic webhooks, per-event
  subscriptions; wired into the update checker and stack lifecycle.
- Settings page: update-check interval, webhook CRUD + test, user management
  (with last-admin safeguards).
- Audit log page (searchable, paginated).
- Mobile-responsive sidebar/layout.

Multi-host agents and remote backup destinations (SFTP/S3) deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 20:58:05 +00:00
co-authored by Claude Opus 4.8
parent 22d9864436
commit 8d19b09abd
30 changed files with 2034 additions and 71 deletions
+41 -3
View File
@@ -19,8 +19,14 @@ from models.stack import (
StackCreate,
StackUpdate,
)
from models.setting import (
EVENT_PULL_FAILED,
EVENT_STACK_ERROR,
EVENT_STACK_START,
EVENT_STACK_STOP,
)
from models.user import User
from services import audit_service, compose_service
from services import audit_service, compose_service, notify_service
from services.convert_service import convert_docker_run
router = APIRouter(prefix="/api/stacks", tags=["stacks"])
@@ -220,6 +226,35 @@ def clone_stack(
# --------------------------------------------------------------------------- #
# which lifecycle actions emit a notification on success
_START_ACTIONS = {"start", "restart", "update"}
_STOP_ACTIONS = {"stop", "down"}
async def _notify_lifecycle(action_name: str, stack_id: str, ok: bool, detail: str, session) -> None:
try:
if not ok:
event = EVENT_PULL_FAILED if action_name in ("pull", "update") else EVENT_STACK_ERROR
await notify_service.notify(
event,
f"Stack '{stack_id}' {action_name} failed",
detail or f"compose {action_name} returned a non-zero exit code.",
session,
)
elif action_name in _START_ACTIONS:
await notify_service.notify(
EVENT_STACK_START, f"Stack '{stack_id}' started",
f"compose {action_name} completed successfully.", session,
)
elif action_name in _STOP_ACTIONS:
await notify_service.notify(
EVENT_STACK_STOP, f"Stack '{stack_id}' stopped",
f"compose {action_name} completed successfully.", session,
)
except Exception: # noqa: BLE001 - notifications are best-effort
pass
async def _lifecycle(action_fn, action_name, stack_id, request, session, user):
_get_stack_or_404(session, stack_id)
result = await action_fn(stack_id)
@@ -227,12 +262,15 @@ async def _lifecycle(action_fn, action_name, stack_id, request, session, user):
session, user=user.username, action=f"stack.{action_name}", target=stack_id,
detail=f"rc={result.get('returncode')}", ip=_client_ip(request),
)
if result.get("returncode") not in (0, None):
ok = result.get("returncode") in (0, None)
stderr = result.get("stderr", "").strip()[-2000:]
await _notify_lifecycle(action_name, stack_id, ok, stderr, session)
if not ok:
raise HTTPException(
status_code=500,
detail={
"error": f"compose {action_name} failed",
"detail": result.get("stderr", "").strip()[-2000:],
"detail": stderr,
},
)
return result