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:
co-authored by
Claude Opus 4.8
parent
22d9864436
commit
8d19b09abd
@@ -16,6 +16,8 @@ import httpx
|
||||
|
||||
from config import settings
|
||||
from docker_client import DockerError, get_client, safe_call
|
||||
from models.setting import EVENT_UPDATE_AVAILABLE
|
||||
from services import notify_service, settings_service
|
||||
|
||||
logger = logging.getLogger("stackpilot.update")
|
||||
|
||||
@@ -45,6 +47,10 @@ class UpdateStatus:
|
||||
# image ref -> UpdateStatus
|
||||
_CACHE: dict[str, UpdateStatus] = {}
|
||||
|
||||
# images we've already sent an "update available" notification for, so the
|
||||
# background loop doesn't re-notify on every cycle.
|
||||
_NOTIFIED: set[str] = set()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Image reference parsing
|
||||
@@ -164,6 +170,18 @@ async def check_image(image: str) -> UpdateStatus:
|
||||
error=error,
|
||||
)
|
||||
_CACHE[image] = status
|
||||
if update_available and image not in _NOTIFIED:
|
||||
_NOTIFIED.add(image)
|
||||
try:
|
||||
await notify_service.notify(
|
||||
EVENT_UPDATE_AVAILABLE,
|
||||
"Image update available",
|
||||
f"A newer image is available for {image}.",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - notifications are best-effort
|
||||
logger.debug("update notify failed for %s: %s", image, exc)
|
||||
elif not update_available:
|
||||
_NOTIFIED.discard(image)
|
||||
return status
|
||||
|
||||
|
||||
@@ -192,7 +210,6 @@ def get_cache() -> dict[str, dict]:
|
||||
|
||||
|
||||
async def background_loop():
|
||||
interval = max(settings.UPDATE_CHECK_INTERVAL_MINUTES, 5) * 60
|
||||
# initial delay so startup isn't blocked
|
||||
await asyncio.sleep(30)
|
||||
while True:
|
||||
@@ -201,4 +218,6 @@ async def background_loop():
|
||||
logger.info("Image update check complete (%d images)", len(_CACHE))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Image update check failed: %s", exc)
|
||||
# Re-read the interval each cycle so Settings changes take effect.
|
||||
interval = max(settings_service.get_update_interval(), 5) * 60
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
Reference in New Issue
Block a user