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
+8
View File
@@ -9,3 +9,11 @@ STACKS_HOST_DIR=./data/stacks
# Allowed CORS origin(s) for the API (comma separated). The bundled frontend # Allowed CORS origin(s) for the API (comma separated). The bundled frontend
# proxies /api, so this only matters if you call the API from another origin. # proxies /api, so this only matters if you call the API from another origin.
CORS_ORIGINS=http://localhost:5009 CORS_ORIGINS=http://localhost:5009
# Optional: comma-separated generic JSON webhook URLs that receive every event.
# Richer per-destination webhooks (ntfy/Discord/Slack/Gotify, per-event) are
# managed from Settings → Notifications in the UI.
NOTIFY_WEBHOOKS=
# Throwaway image used to read/write named-volume contents during backups.
BACKUP_HELPER_IMAGE=alpine:latest
+32 -1
View File
@@ -4,7 +4,8 @@ A self-hosted Docker Compose manager for power users and homelab enthusiasts —
as intuitive as Dockge, as capable as Portainer for Compose workflows. as intuitive as Dockge, as capable as Portainer for Compose workflows.
> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of > **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of
> Life) complete. Multi-host agents, backups and notifications land in Phase 4. > Life) + Phase 4 (Operations) complete. Multi-host agents are planned for a
> later phase.
## What works today (Phase 1) ## What works today (Phase 1)
@@ -55,6 +56,24 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
Paperless-NGX, Gitea) with `{{VARIABLE}}` forms; save any stack as a custom template. Paperless-NGX, Gitea) with `{{VARIABLE}}` forms; save any stack as a custom template.
- **Healthcheck status** surfaced per container in the stack overview. - **Healthcheck status** surfaced per container in the stack overview.
### Phase 4 — Operations
- **Backup & restore**: per-stack `.tar.gz` backups including named-volume contents
(snapshotted via a throwaway helper container); restore via upload with optional
rename, volume restore, and overwrite/conflict detection.
- **Notification webhooks**: ntfy, Discord, Slack, Gotify, or generic JSON, each
subscribed to chosen events (image update available, stack start/stop/error,
pull failed). Managed in **Settings → Notifications**; env `NOTIFY_WEBHOOKS`
still supported for generic endpoints.
- **Settings page**: tune the update-check interval, manage webhooks, and manage
users (create/disable/delete, promote/demote, with last-admin safeguards).
- **Audit log page**: searchable, paginated view of all recorded actions.
- **Mobile-responsive layout**: off-canvas sidebar + adaptive spacing.
> **Not yet:** multi-host agents (a second deployable agent app + remote proxying)
> and remote backup destinations (SFTP/S3) are intentionally deferred to a future
> phase — backups currently download to / upload from the browser.
## Architecture ## Architecture
``` ```
@@ -143,6 +162,18 @@ GET /api/templates | /{id} POST /api/templates/{id}/insta
POST /api/templates DELETE /api/templates/custom/{slug} POST /api/templates DELETE /api/templates/custom/{slug}
``` ```
### Phase 4 endpoints
```
GET /api/stacks/{id}/backup?include_volumes=&stop_first= POST /api/stacks/restore
GET /api/settings PUT /api/settings
GET /api/settings/webhooks POST /api/settings/webhooks
PUT /api/settings/webhooks/{id} DELETE /api/settings/webhooks/{id}
POST /api/settings/webhooks/{id}/test
GET /api/auth/users POST /api/auth/users
PATCH /api/auth/users/{id} DELETE /api/auth/users/{id}
```
## Security notes ## Security notes
- The Docker socket is only ever touched by the backend process; it is never - The Docker socket is only ever touched by the backend process; it is never
+3
View File
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
DOCKER_SOCKET: str = "/var/run/docker.sock" DOCKER_SOCKET: str = "/var/run/docker.sock"
HOST_PROC_PATH: str = "/host_proc" HOST_PROC_PATH: str = "/host_proc"
# Throwaway image used to read/write named-volume contents during backup.
BACKUP_HELPER_IMAGE: str = "alpine:latest"
# Host browser sandbox roots # Host browser sandbox roots
ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [ ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [
"/", "/mnt", "/media", "/srv", "/opt", "/", "/mnt", "/media", "/srv", "/opt",
+5 -1
View File
@@ -16,9 +16,11 @@ from docker_client import DockerError
from routers import ( from routers import (
audit, audit,
auth, auth,
backups,
editor, editor,
images, images,
ports, ports,
settings as settings_router,
stacks, stacks,
system, system,
templates, templates,
@@ -46,7 +48,7 @@ async def lifespan(app: FastAPI):
update_task.cancel() update_task.cancel()
app = FastAPI(title="StackPilot", version="0.3.0", lifespan=lifespan) app = FastAPI(title="StackPilot", version="0.4.0", lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -74,6 +76,8 @@ app.include_router(images.router)
app.include_router(ports.router) app.include_router(ports.router)
app.include_router(templates.router) app.include_router(templates.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(settings_router.router)
app.include_router(backups.router)
app.include_router(ws.router) app.include_router(ws.router)
+2 -1
View File
@@ -1,7 +1,8 @@
"""SQLModel table models. Importing this package registers all tables.""" """SQLModel table models. Importing this package registers all tables."""
from models.audit import AuditLog from models.audit import AuditLog
from models.setting import Setting, Webhook
from models.stack import Stack from models.stack import Stack
from models.template import Template from models.template import Template
from models.user import User from models.user import User
__all__ = ["User", "Stack", "AuditLog", "Template"] __all__ = ["User", "Stack", "AuditLog", "Template", "Setting", "Webhook"]
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
def _now() -> datetime:
return datetime.now(timezone.utc)
# Notification event names ----------------------------------------------------
EVENT_UPDATE_AVAILABLE = "update_available"
EVENT_STACK_START = "stack_start"
EVENT_STACK_STOP = "stack_stop"
EVENT_STACK_ERROR = "stack_error"
EVENT_PULL_FAILED = "pull_failed"
ALL_EVENTS = [
EVENT_UPDATE_AVAILABLE,
EVENT_STACK_START,
EVENT_STACK_STOP,
EVENT_STACK_ERROR,
EVENT_PULL_FAILED,
]
WEBHOOK_TYPES = ["ntfy", "discord", "slack", "gotify", "generic"]
class Setting(SQLModel, table=True):
"""Simple key/value store for runtime-tunable settings (JSON-encoded value)."""
key: str = Field(primary_key=True)
value: str # JSON-encoded
class Webhook(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
url: str
type: str = "generic" # one of WEBHOOK_TYPES
events: str = ",".join(ALL_EVENTS) # comma-separated subscribed events
enabled: bool = True
created_at: datetime = Field(default_factory=_now)
# --- API schemas ---
class WebhookCreate(SQLModel):
name: str
url: str
type: str = "generic"
events: list[str] = ALL_EVENTS
enabled: bool = True
class WebhookUpdate(SQLModel):
name: Optional[str] = None
url: Optional[str] = None
type: Optional[str] = None
events: Optional[list[str]] = None
enabled: Optional[bool] = None
class WebhookRead(SQLModel):
id: int
name: str
url: str
type: str
events: list[str]
enabled: bool
created_at: datetime
class SettingsRead(SQLModel):
update_check_interval_minutes: int
env_webhook_count: int
available_events: list[str]
webhook_types: list[str]
class SettingsUpdate(SQLModel):
update_check_interval_minutes: Optional[int] = None
+6
View File
@@ -35,6 +35,12 @@ class UserCreate(SQLModel):
role: str = "admin" role: str = "admin"
class UserUpdate(SQLModel):
password: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
class LoginRequest(SQLModel): class LoginRequest(SQLModel):
username: str username: str
password: str password: str
+111 -1
View File
@@ -5,7 +5,7 @@ import time
from collections import defaultdict, deque from collections import defaultdict, deque
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlmodel import Session from sqlmodel import Session, select
import auth as auth_mod import auth as auth_mod
from database import get_session from database import get_session
@@ -16,6 +16,7 @@ from models.user import (
User, User,
UserCreate, UserCreate,
UserRead, UserRead,
UserUpdate,
) )
from services import audit_service from services import audit_service
@@ -109,3 +110,112 @@ def refresh(
@router.get("/me", response_model=UserRead) @router.get("/me", response_model=UserRead)
def me(user: User = Depends(auth_mod.get_current_user)) -> User: def me(user: User = Depends(auth_mod.get_current_user)) -> User:
return user return user
# --------------------------------------------------------------------------- #
# User management (admin only)
# --------------------------------------------------------------------------- #
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
@router.get("/users", response_model=list[UserRead])
def list_users(
session: Session = Depends(get_session),
_admin: User = Depends(auth_mod.require_admin),
) -> list[User]:
return session.exec(select(User).order_by(User.id)).all()
@router.post("/users", response_model=UserRead, status_code=201)
def create_user(
body: UserCreate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(auth_mod.require_admin),
) -> User:
if not body.username.strip() or not body.password:
raise HTTPException(status_code=400, detail="Username and password required")
if auth_mod.get_user(session, body.username):
raise HTTPException(status_code=409, detail="Username already exists")
role = body.role if body.role in ("admin", "user") else "user"
user = User(
username=body.username,
hashed_password=auth_mod.hash_password(body.password),
role=role,
)
session.add(user)
session.commit()
session.refresh(user)
audit_service.record(
session, user=admin.username, action="user.create", target=user.username,
detail=f"role={role}", ip=_ip(request),
)
return user
@router.patch("/users/{user_id}", response_model=UserRead)
def update_user(
user_id: int,
body: UserUpdate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(auth_mod.require_admin),
) -> User:
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Guard against locking yourself out / demoting the last admin.
demoting = (body.role is not None and body.role != "admin") or body.is_active is False
if user.role == "admin" and demoting:
other_admins = session.exec(
select(User).where(User.role == "admin", User.is_active == True, User.id != user_id) # noqa: E712
).first()
if not other_admins:
raise HTTPException(status_code=400, detail="Cannot demote or disable the last active admin")
if body.password:
user.hashed_password = auth_mod.hash_password(body.password)
if body.role is not None:
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Invalid role")
user.role = body.role
if body.is_active is not None:
user.is_active = body.is_active
session.add(user)
session.commit()
session.refresh(user)
audit_service.record(
session, user=admin.username, action="user.update", target=user.username,
ip=_ip(request),
)
return user
@router.delete("/users/{user_id}")
def delete_user(
user_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(auth_mod.require_admin),
) -> dict:
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account")
if user.role == "admin":
other_admins = session.exec(
select(User).where(User.role == "admin", User.is_active == True, User.id != user_id) # noqa: E712
).first()
if not other_admins:
raise HTTPException(status_code=400, detail="Cannot delete the last active admin")
username = user.username
session.delete(user)
session.commit()
audit_service.record(
session, user=admin.username, action="user.delete", target=username,
ip=_ip(request),
)
return {"ok": True}
+96
View File
@@ -0,0 +1,96 @@
"""Stack backup (incl. volumes) and restore."""
from __future__ import annotations
import os
import tempfile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session
from auth import require_admin
from database import get_session
from models.stack import Stack
from models.user import User
from services import audit_service, backup_service, compose_service
router = APIRouter(prefix="/api/stacks", tags=["backups"])
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
@router.get("/{stack_id}/backup")
async def backup_stack(
stack_id: str,
request: Request,
include_volumes: bool = Query(True),
stop_first: bool = Query(True),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
):
stack = session.get(Stack, stack_id)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
try:
path = await backup_service.create_backup(
stack_id, stack.name, include_volumes=include_volumes, stop_first=stop_first,
)
except backup_service.BackupError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
audit_service.record(
session, user=user.username, action="stack.backup", target=stack_id,
detail=f"volumes={include_volumes}", ip=_ip(request),
)
date = compose_service.now().strftime("%Y%m%d-%H%M%S")
suffix = "full" if include_volumes else "config"
return FileResponse(
path,
media_type="application/gzip",
filename=f"backup-{stack_id}-{suffix}-{date}.tar.gz",
)
@router.post("/restore")
async def restore_stack(
request: Request,
file: UploadFile = File(...),
target_id: str | None = Form(None),
overwrite: bool = Form(False),
restore_volumes: bool = Form(True),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
try:
while chunk := await file.read(1024 * 1024):
tmp.write(chunk)
tmp.close()
target = compose_service.slugify(target_id) if target_id else None
try:
result = backup_service.restore_backup(
tmp.name,
target_id=target,
overwrite=overwrite,
restore_volumes=restore_volumes,
)
except backup_service.BackupError as exc:
# 409 for the "already exists" conflict, 400 for malformed backups.
code = 409 if "already exists" in str(exc) else 400
raise HTTPException(status_code=code, detail=str(exc)) from exc
stack_id = result["stack_id"]
stack = session.get(Stack, stack_id)
if not stack:
session.add(Stack(id=stack_id, name=result.get("name", stack_id)))
session.commit()
audit_service.record(
session, user=user.username, action="stack.restore", target=stack_id,
detail=f"volumes={result['volumes_restored']}", ip=_ip(request),
)
return result
finally:
if os.path.exists(tmp.name):
os.unlink(tmp.name)
+192
View File
@@ -0,0 +1,192 @@
"""Application settings: update interval + notification webhooks."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
from auth import get_current_user, require_admin
from config import settings as env_settings
from database import get_session
from models.setting import (
ALL_EVENTS,
WEBHOOK_TYPES,
SettingsRead,
SettingsUpdate,
Webhook,
WebhookCreate,
WebhookRead,
WebhookUpdate,
)
from models.user import User
from services import audit_service, notify_service, settings_service
router = APIRouter(prefix="/api/settings", tags=["settings"])
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _to_read(wh: Webhook) -> WebhookRead:
return WebhookRead(
id=wh.id,
name=wh.name,
url=wh.url,
type=wh.type,
events=[e.strip() for e in (wh.events or "").split(",") if e.strip()],
enabled=wh.enabled,
created_at=wh.created_at,
)
# --------------------------------------------------------------------------- #
# General settings
# --------------------------------------------------------------------------- #
@router.get("", response_model=SettingsRead)
def get_settings(
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> SettingsRead:
return SettingsRead(
update_check_interval_minutes=settings_service.get_update_interval(session),
env_webhook_count=len(env_settings.NOTIFY_WEBHOOKS),
available_events=ALL_EVENTS,
webhook_types=WEBHOOK_TYPES,
)
@router.put("", response_model=SettingsRead)
def update_settings(
body: SettingsUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> SettingsRead:
if body.update_check_interval_minutes is not None:
if body.update_check_interval_minutes < 5:
raise HTTPException(status_code=400, detail="Interval must be at least 5 minutes")
settings_service.set_value(
session,
settings_service.KEY_UPDATE_INTERVAL,
body.update_check_interval_minutes,
)
audit_service.record(
session, user=user.username, action="settings.update",
target="update_interval", detail=str(body.update_check_interval_minutes),
ip=_ip(request),
)
return get_settings(session, user)
# --------------------------------------------------------------------------- #
# Webhooks
# --------------------------------------------------------------------------- #
def _validate(wtype: str, events: list[str]) -> None:
if wtype not in WEBHOOK_TYPES:
raise HTTPException(status_code=400, detail=f"Unknown webhook type '{wtype}'")
bad = [e for e in events if e not in ALL_EVENTS]
if bad:
raise HTTPException(status_code=400, detail=f"Unknown event(s): {', '.join(bad)}")
@router.get("/webhooks", response_model=list[WebhookRead])
def list_webhooks(
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> list[WebhookRead]:
rows = session.exec(select(Webhook).order_by(Webhook.id)).all()
return [_to_read(w) for w in rows]
@router.post("/webhooks", response_model=WebhookRead, status_code=201)
def create_webhook(
body: WebhookCreate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> WebhookRead:
_validate(body.type, body.events)
wh = Webhook(
name=body.name,
url=body.url,
type=body.type,
events=",".join(body.events),
enabled=body.enabled,
)
session.add(wh)
session.commit()
session.refresh(wh)
audit_service.record(
session, user=user.username, action="webhook.create", target=str(wh.id),
detail=body.name, ip=_ip(request),
)
return _to_read(wh)
@router.put("/webhooks/{webhook_id}", response_model=WebhookRead)
def update_webhook(
webhook_id: int,
body: WebhookUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> WebhookRead:
wh = session.get(Webhook, webhook_id)
if not wh:
raise HTTPException(status_code=404, detail="Webhook not found")
if body.type is not None or body.events is not None:
_validate(body.type or wh.type, body.events if body.events is not None else _to_read(wh).events)
if body.name is not None:
wh.name = body.name
if body.url is not None:
wh.url = body.url
if body.type is not None:
wh.type = body.type
if body.events is not None:
wh.events = ",".join(body.events)
if body.enabled is not None:
wh.enabled = body.enabled
session.add(wh)
session.commit()
session.refresh(wh)
audit_service.record(
session, user=user.username, action="webhook.update", target=str(wh.id),
ip=_ip(request),
)
return _to_read(wh)
@router.delete("/webhooks/{webhook_id}")
def delete_webhook(
webhook_id: int,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
wh = session.get(Webhook, webhook_id)
if not wh:
raise HTTPException(status_code=404, detail="Webhook not found")
session.delete(wh)
session.commit()
audit_service.record(
session, user=user.username, action="webhook.delete", target=str(webhook_id),
ip=_ip(request),
)
return {"ok": True}
@router.post("/webhooks/{webhook_id}/test")
async def test_webhook(
webhook_id: int,
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> dict:
wh = session.get(Webhook, webhook_id)
if not wh:
raise HTTPException(status_code=404, detail="Webhook not found")
ok = await notify_service.test_webhook(wh.type, wh.url)
return {"ok": ok}
+41 -3
View File
@@ -19,8 +19,14 @@ from models.stack import (
StackCreate, StackCreate,
StackUpdate, StackUpdate,
) )
from models.setting import (
EVENT_PULL_FAILED,
EVENT_STACK_ERROR,
EVENT_STACK_START,
EVENT_STACK_STOP,
)
from models.user import User 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 from services.convert_service import convert_docker_run
router = APIRouter(prefix="/api/stacks", tags=["stacks"]) 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): async def _lifecycle(action_fn, action_name, stack_id, request, session, user):
_get_stack_or_404(session, stack_id) _get_stack_or_404(session, stack_id)
result = await action_fn(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, session, user=user.username, action=f"stack.{action_name}", target=stack_id,
detail=f"rc={result.get('returncode')}", ip=_client_ip(request), 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( raise HTTPException(
status_code=500, status_code=500,
detail={ detail={
"error": f"compose {action_name} failed", "error": f"compose {action_name} failed",
"detail": result.get("stderr", "").strip()[-2000:], "detail": stderr,
}, },
) )
return result return result
+268
View File
@@ -0,0 +1,268 @@
"""Stack backup & restore, including named-volume contents.
A backup is a single ``.tar.gz`` with this layout::
manifest.json metadata + volume/bind inventory
compose/... the full stack directory (compose file, .env, ...)
volumes/<full>.tar raw contents of each compose-managed named volume
Named-volume contents are read/written through a throwaway helper container
(``BACKUP_HELPER_IMAGE``) with the volume bind-mounted — this is the portable
way to snapshot a volume regardless of its driver/mountpoint.
"""
from __future__ import annotations
import asyncio
import io
import json
import logging
import os
import shutil
import tarfile
import tempfile
from typing import Optional
from config import settings
from docker_client import DockerError, get_client, safe_call
from services import compose_service
logger = logging.getLogger("stackpilot.backup")
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
COMPOSE_VOLUME_LABEL = "com.docker.compose.volume"
MANIFEST_NAME = "manifest.json"
BACKUP_FORMAT_VERSION = 1
class BackupError(Exception):
pass
# --------------------------------------------------------------------------- #
# Helper container for volume I/O
# --------------------------------------------------------------------------- #
def _ensure_helper_image(client) -> None:
image = settings.BACKUP_HELPER_IMAGE
try:
safe_call(client.images.get, image)
except DockerError:
logger.info("Pulling backup helper image %s", image)
safe_call(client.images.pull, image)
def _export_volume(full_name: str) -> bytes:
client = get_client()
_ensure_helper_image(client)
container = safe_call(
client.containers.create,
settings.BACKUP_HELPER_IMAGE,
command="true",
volumes={full_name: {"bind": "/v", "mode": "ro"}},
)
try:
# "/v/." copies the *contents* of the volume (no leading "v/" prefix),
# so restore can extract straight back into the volume root.
bits, _ = container.get_archive("/v/.")
buf = io.BytesIO()
for chunk in bits:
buf.write(chunk)
return buf.getvalue()
finally:
try:
container.remove(force=True)
except Exception: # noqa: BLE001
pass
def _restore_volume(full_name: str, labels: dict, tar_bytes: bytes) -> None:
client = get_client()
_ensure_helper_image(client)
try:
safe_call(client.volumes.get, full_name)
except DockerError:
safe_call(client.volumes.create, name=full_name, labels=labels or {})
container = safe_call(
client.containers.create,
settings.BACKUP_HELPER_IMAGE,
command="true",
volumes={full_name: {"bind": "/v", "mode": "rw"}},
)
try:
container.put_archive("/v", tar_bytes)
finally:
try:
container.remove(force=True)
except Exception: # noqa: BLE001
pass
def _compose_volumes(stack_id: str) -> list[dict]:
"""Return [{full, short, labels}] for compose-managed named volumes."""
try:
client = get_client()
vols = safe_call(
client.volumes.list,
filters={"label": f"{COMPOSE_PROJECT_LABEL}={stack_id}"},
)
except DockerError:
return []
out = []
for v in vols:
labels = v.attrs.get("Labels") or {}
out.append(
{
"full": v.name,
"short": labels.get(COMPOSE_VOLUME_LABEL, v.name),
"labels": labels,
}
)
return out
# --------------------------------------------------------------------------- #
# Backup
# --------------------------------------------------------------------------- #
async def create_backup(
stack_id: str,
name: str,
include_volumes: bool = True,
stop_first: bool = True,
) -> str:
"""Create a backup tar.gz and return its path on disk."""
directory = compose_service.stack_dir(stack_id)
if not os.path.isdir(directory):
raise BackupError("Stack directory missing")
volumes = _compose_volumes(stack_id) if include_volumes else []
# For a consistent volume snapshot, stop the stack first.
stopped = False
if include_volumes and stop_first and volumes:
try:
await compose_service.stop(stack_id)
stopped = True
except Exception as exc: # noqa: BLE001
logger.warning("Could not stop %s before backup: %s", stack_id, exc)
try:
manifest = {
"format_version": BACKUP_FORMAT_VERSION,
"stack_id": stack_id,
"name": name,
"created_at": compose_service.now().isoformat(),
"include_volumes": include_volumes,
"volumes": [{"full": v["full"], "short": v["short"], "labels": v["labels"]} for v in volumes],
}
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
tmp.close()
with tarfile.open(tmp.name, "w:gz") as tar:
# manifest
data = json.dumps(manifest, indent=2).encode("utf-8")
info = tarfile.TarInfo(MANIFEST_NAME)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
# stack directory
tar.add(directory, arcname="compose")
# volume contents
for v in volumes:
vbytes = await asyncio.to_thread(_export_volume, v["full"])
info = tarfile.TarInfo(f"volumes/{v['full']}.tar")
info.size = len(vbytes)
tar.addfile(info, io.BytesIO(vbytes))
return tmp.name
finally:
if stopped:
try:
await compose_service.up(stack_id)
except Exception as exc: # noqa: BLE001
logger.warning("Could not restart %s after backup: %s", stack_id, exc)
# --------------------------------------------------------------------------- #
# Restore
# --------------------------------------------------------------------------- #
def read_manifest(tar_path: str) -> dict:
with tarfile.open(tar_path, "r:gz") as tar:
member = tar.getmember(MANIFEST_NAME)
fh = tar.extractfile(member)
if fh is None:
raise BackupError("Backup is missing its manifest")
return json.loads(fh.read().decode("utf-8"))
def _safe_extract_compose(tar: tarfile.TarFile, dest_dir: str) -> None:
"""Extract the ``compose/`` subtree into dest_dir, guarding path traversal."""
os.makedirs(dest_dir, exist_ok=True)
for member in tar.getmembers():
if not member.name.startswith("compose/"):
continue
rel = member.name[len("compose/") :]
if not rel:
continue
target = os.path.normpath(os.path.join(dest_dir, rel))
if not target.startswith(os.path.abspath(dest_dir) + os.sep) and target != os.path.abspath(dest_dir):
raise BackupError(f"Refusing unsafe path in backup: {member.name}")
if member.isdir():
os.makedirs(target, exist_ok=True)
elif member.isreg():
os.makedirs(os.path.dirname(target), exist_ok=True)
src = tar.extractfile(member)
if src is not None:
with open(target, "wb") as out:
shutil.copyfileobj(src, out)
def restore_backup(
tar_path: str,
target_id: Optional[str] = None,
overwrite: bool = False,
restore_volumes: bool = True,
) -> dict:
"""Restore a backup. Returns {stack_id, name, volumes_restored}."""
manifest = read_manifest(tar_path)
stack_id = target_id or manifest.get("stack_id")
if not stack_id:
raise BackupError("Backup manifest has no stack id")
directory = compose_service.stack_dir(stack_id)
exists = os.path.isdir(directory)
if exists and not overwrite:
raise BackupError(f"Stack '{stack_id}' already exists")
with tarfile.open(tar_path, "r:gz") as tar:
if exists:
shutil.rmtree(directory)
_safe_extract_compose(tar, directory)
volumes_restored = 0
if restore_volumes:
for v in manifest.get("volumes", []):
member_name = f"volumes/{v['full']}.tar"
try:
member = tar.getmember(member_name)
except KeyError:
continue
fh = tar.extractfile(member)
if fh is None:
continue
# Re-target volume labels to the (possibly new) stack id.
labels = dict(v.get("labels") or {})
labels[COMPOSE_PROJECT_LABEL] = stack_id
full = v["full"]
if target_id and manifest.get("stack_id") and full.startswith(manifest["stack_id"] + "_"):
full = stack_id + full[len(manifest["stack_id"]):]
_restore_volume(full, labels, fh.read())
volumes_restored += 1
return {
"stack_id": stack_id,
"name": manifest.get("name", stack_id),
"volumes_restored": volumes_restored,
}
+133
View File
@@ -0,0 +1,133 @@
"""Outbound notification webhooks.
Webhooks are configured two ways:
* DB-managed (the ``Webhook`` table) — per-webhook type + event subscriptions,
editable from the Settings page.
* Env ``NOTIFY_WEBHOOKS`` — a comma-separated list of generic JSON endpoints
that receive every event (kept for backward compatibility / GitOps setups).
Supported types: ntfy, discord, slack, gotify, generic (JSON POST).
All delivery is best-effort: failures are logged, never raised to the caller.
"""
from __future__ import annotations
import logging
from typing import Optional
import httpx
from sqlmodel import Session, select
from config import settings as env_settings
from database import engine
from models.setting import Webhook
logger = logging.getLogger("stackpilot.notify")
_TIMEOUT = 10.0
# --------------------------------------------------------------------------- #
# Payload formatting per webhook type
# --------------------------------------------------------------------------- #
def _build_request(wtype: str, url: str, event: str, title: str, message: str):
"""Return (method-kwargs) for httpx.post for the given webhook type."""
if wtype == "ntfy":
return {
"url": url,
"content": message.encode("utf-8"),
"headers": {"Title": title, "Tags": _ntfy_tag(event)},
}
if wtype == "discord":
return {"url": url, "json": {"content": f"**{title}**\n{message}"}}
if wtype == "slack":
return {"url": url, "json": {"text": f"*{title}*\n{message}"}}
if wtype == "gotify":
priority = 8 if event in ("stack_error", "pull_failed") else 5
return {
"url": url,
"json": {"title": title, "message": message, "priority": priority},
}
# generic
return {
"url": url,
"json": {"event": event, "title": title, "message": message},
}
def _ntfy_tag(event: str) -> str:
return {
"update_available": "arrow_up",
"stack_start": "white_check_mark",
"stack_stop": "stop_button",
"stack_error": "rotating_light",
"pull_failed": "warning",
}.get(event, "bell")
async def _deliver(client: httpx.AsyncClient, wtype: str, url: str, event: str, title: str, message: str) -> bool:
kwargs = _build_request(wtype, url, event, title, message)
target = kwargs.pop("url")
try:
resp = await client.post(target, timeout=_TIMEOUT, **kwargs)
resp.raise_for_status()
return True
except httpx.HTTPError as exc:
logger.warning("Webhook delivery failed (%s): %s", wtype, exc)
return False
# --------------------------------------------------------------------------- #
# Public API
# --------------------------------------------------------------------------- #
def _targets_for_event(session: Session, event: str) -> list[tuple[str, str]]:
"""Return [(type, url)] of all destinations subscribed to ``event``."""
targets: list[tuple[str, str]] = []
for wh in session.exec(select(Webhook)).all():
if not wh.enabled:
continue
subscribed = [e.strip() for e in (wh.events or "").split(",") if e.strip()]
if event in subscribed:
targets.append((wh.type, wh.url))
# Env-configured generic endpoints receive everything.
for url in env_settings.NOTIFY_WEBHOOKS:
targets.append(("generic", url))
return targets
async def notify(
event: str,
title: str,
message: str,
session: Optional[Session] = None,
) -> int:
"""Fan out ``event`` to all subscribed webhooks. Returns delivered count."""
if session is None:
with Session(engine) as own:
return await notify(event, title, message, own)
targets = _targets_for_event(session, event)
if not targets:
return 0
delivered = 0
async with httpx.AsyncClient(follow_redirects=True) as client:
for wtype, url in targets:
if await _deliver(client, wtype, url, event, title, message):
delivered += 1
return delivered
async def test_webhook(wtype: str, url: str) -> bool:
"""Send a one-off test notification to a single destination."""
async with httpx.AsyncClient(follow_redirects=True) as client:
return await _deliver(
client,
wtype,
url,
"update_available",
"StackPilot test notification",
"If you can read this, your webhook is configured correctly. 🚀",
)
+45
View File
@@ -0,0 +1,45 @@
"""Runtime settings stored in the DB (key/value), with env fallbacks."""
from __future__ import annotations
import json
from typing import Any, Optional
from sqlmodel import Session, select
from config import settings as env_settings
from database import engine
from models.setting import Setting
KEY_UPDATE_INTERVAL = "update_check_interval_minutes"
def get(session: Session, key: str, default: Any = None) -> Any:
row = session.get(Setting, key)
if row is None:
return default
try:
return json.loads(row.value)
except json.JSONDecodeError:
return default
def set_value(session: Session, key: str, value: Any) -> None:
row = session.get(Setting, key)
encoded = json.dumps(value)
if row is None:
session.add(Setting(key=key, value=encoded))
else:
row.value = encoded
session.add(row)
session.commit()
def get_update_interval(session: Optional[Session] = None) -> int:
"""Effective update-check interval in minutes (DB override or env default)."""
if session is None:
with Session(engine) as own:
return get_update_interval(own)
val = get(session, KEY_UPDATE_INTERVAL)
if isinstance(val, int) and val > 0:
return val
return env_settings.UPDATE_CHECK_INTERVAL_MINUTES
+20 -1
View File
@@ -16,6 +16,8 @@ import httpx
from config import settings from config import settings
from docker_client import DockerError, get_client, safe_call 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") logger = logging.getLogger("stackpilot.update")
@@ -45,6 +47,10 @@ class UpdateStatus:
# image ref -> UpdateStatus # image ref -> UpdateStatus
_CACHE: dict[str, 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 # Image reference parsing
@@ -164,6 +170,18 @@ async def check_image(image: str) -> UpdateStatus:
error=error, error=error,
) )
_CACHE[image] = status _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 return status
@@ -192,7 +210,6 @@ def get_cache() -> dict[str, dict]:
async def background_loop(): async def background_loop():
interval = max(settings.UPDATE_CHECK_INTERVAL_MINUTES, 5) * 60
# initial delay so startup isn't blocked # initial delay so startup isn't blocked
await asyncio.sleep(30) await asyncio.sleep(30)
while True: while True:
@@ -201,4 +218,6 @@ async def background_loop():
logger.info("Image update check complete (%d images)", len(_CACHE)) logger.info("Image update check complete (%d images)", len(_CACHE))
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("Image update check failed: %s", exc) 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) await asyncio.sleep(interval)
+5
View File
@@ -9,6 +9,11 @@ services:
- DATA_DIR=/data - DATA_DIR=/data
- HOST_PROC_PATH=/host_proc - HOST_PROC_PATH=/host_proc
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5009} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5009}
# Optional: comma-separated generic JSON webhook URLs (every event).
# Per-destination webhooks (ntfy/Discord/Slack/Gotify) are managed in the UI.
- NOTIFY_WEBHOOKS=${NOTIFY_WEBHOOKS:-}
# Throwaway image used to snapshot named-volume contents during backups.
- BACKUP_HELPER_IMAGE=${BACKUP_HELPER_IMAGE:-alpine:latest}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./data:/data - ./data:/data
+3
View File
@@ -16,6 +16,9 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 600s; proxy_read_timeout 600s;
# Stack backups (incl. volume data) can be large in both directions.
client_max_body_size 0;
proxy_request_buffering off;
} }
# WebSocket proxy # WebSocket proxy
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+4 -1
View File
@@ -8,7 +8,9 @@ import { StackDetail } from "@/pages/StackDetail";
import { StackEditor } from "@/pages/StackEditor"; import { StackEditor } from "@/pages/StackEditor";
import { Images } from "@/pages/Images"; import { Images } from "@/pages/Images";
import { Templates } from "@/pages/Templates"; import { Templates } from "@/pages/Templates";
import { Networks, Settings } from "@/pages/Placeholder"; import { Settings } from "@/pages/Settings";
import { Audit } from "@/pages/Audit";
import { Networks } from "@/pages/Placeholder";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme"; import { useThemeStore } from "@/store/theme";
@@ -44,6 +46,7 @@ export default function App() {
<Route path="/networks" element={<Networks />} /> <Route path="/networks" element={<Networks />} />
<Route path="/images" element={<Images />} /> <Route path="/images" element={<Images />} />
<Route path="/templates" element={<Templates />} /> <Route path="/templates" element={<Templates />} />
<Route path="/audit" element={<Audit />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
</Route> </Route>
</Route> </Route>
+45
View File
@@ -0,0 +1,45 @@
import api from "./client";
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export const backupsApi = {
download: async (
stackId: string,
opts: { includeVolumes: boolean; stopFirst: boolean }
) => {
const res = await api.get(`/api/stacks/${stackId}/backup`, {
params: { include_volumes: opts.includeVolumes, stop_first: opts.stopFirst },
responseType: "blob",
});
const cd = res.headers["content-disposition"] as string | undefined;
const match = cd?.match(/filename="?([^"]+)"?/);
const name = match?.[1] ?? `backup-${stackId}.tar.gz`;
triggerDownload(res.data as Blob, name);
},
restore: async (
file: File,
opts: { targetId?: string; overwrite: boolean; restoreVolumes: boolean }
) => {
const form = new FormData();
form.append("file", file);
if (opts.targetId) form.append("target_id", opts.targetId);
form.append("overwrite", String(opts.overwrite));
form.append("restore_volumes", String(opts.restoreVolumes));
const res = await api.post<{
stack_id: string;
name: string;
volumes_restored: number;
}>("/api/stacks/restore", form);
return res.data;
},
};
+56
View File
@@ -0,0 +1,56 @@
import api from "./client";
import type { User } from "@/types";
export interface AppSettings {
update_check_interval_minutes: number;
env_webhook_count: number;
available_events: string[];
webhook_types: string[];
}
export interface Webhook {
id: number;
name: string;
url: string;
type: string;
events: string[];
enabled: boolean;
created_at: string;
}
export interface WebhookInput {
name: string;
url: string;
type: string;
events: string[];
enabled: boolean;
}
export const settingsApi = {
get: () => api.get<AppSettings>("/api/settings").then((r) => r.data),
update: (body: { update_check_interval_minutes?: number }) =>
api.put<AppSettings>("/api/settings", body).then((r) => r.data),
listWebhooks: () =>
api.get<Webhook[]>("/api/settings/webhooks").then((r) => r.data),
createWebhook: (body: WebhookInput) =>
api.post<Webhook>("/api/settings/webhooks", body).then((r) => r.data),
updateWebhook: (id: number, body: Partial<WebhookInput>) =>
api.put<Webhook>(`/api/settings/webhooks/${id}`, body).then((r) => r.data),
deleteWebhook: (id: number) =>
api.delete(`/api/settings/webhooks/${id}`).then((r) => r.data),
testWebhook: (id: number) =>
api.post<{ ok: boolean }>(`/api/settings/webhooks/${id}/test`).then((r) => r.data),
};
export const usersApi = {
list: () => api.get<User[]>("/api/auth/users").then((r) => r.data),
create: (body: { username: string; password: string; role: string }) =>
api.post<User>("/api/auth/users", body).then((r) => r.data),
update: (
id: number,
body: { password?: string; role?: string; is_active?: boolean }
) => api.patch<User>(`/api/auth/users/${id}`, body).then((r) => r.data),
remove: (id: number) =>
api.delete(`/api/auth/users/${id}`).then((r) => r.data),
};
+6 -3
View File
@@ -1,14 +1,17 @@
import { useState } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar"; import { Topbar } from "./Topbar";
export function Layout() { export function Layout() {
const [mobileOpen, setMobileOpen] = useState(false);
return ( return (
<div className="flex h-screen bg-bg text-slate-900 dark:bg-bg-dark dark:text-slate-100"> <div className="flex h-screen bg-bg text-slate-900 dark:bg-bg-dark dark:text-slate-100">
<Sidebar /> <Sidebar mobileOpen={mobileOpen} onClose={() => setMobileOpen(false)} />
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<Topbar /> <Topbar onMenu={() => setMobileOpen(true)} />
<main className="flex-1 overflow-y-auto p-6"> <main className="flex-1 overflow-y-auto p-4 sm:p-6">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
+39 -3
View File
@@ -5,11 +5,13 @@ import {
Network, Network,
Image, Image,
LayoutTemplate, LayoutTemplate,
ScrollText,
Settings, Settings,
Moon, Moon,
Sun, Sun,
LogOut, LogOut,
Ship, Ship,
X,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
@@ -21,20 +23,52 @@ const nav = [
{ to: "/networks", label: "Networks", icon: Network }, { to: "/networks", label: "Networks", icon: Network },
{ to: "/images", label: "Images", icon: Image }, { to: "/images", label: "Images", icon: Image },
{ to: "/templates", label: "Templates", icon: LayoutTemplate }, { to: "/templates", label: "Templates", icon: LayoutTemplate },
{ to: "/audit", label: "Audit log", icon: ScrollText },
{ to: "/settings", label: "Settings", icon: Settings }, { to: "/settings", label: "Settings", icon: Settings },
]; ];
export function Sidebar() { export function Sidebar({
mobileOpen = false,
onClose,
}: {
mobileOpen?: boolean;
onClose?: () => void;
}) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const { theme, toggle } = useThemeStore(); const { theme, toggle } = useThemeStore();
return ( return (
<aside className="flex w-60 flex-col border-r border-slate-200 bg-card dark:border-slate-700 dark:bg-card-dark"> <>
<div className="flex items-center gap-2 px-5 py-5"> {/* Mobile backdrop */}
{mobileOpen && (
<div
className="fixed inset-0 z-30 bg-black/50 md:hidden"
onClick={onClose}
/>
)}
<aside
className={cn(
"z-40 flex w-60 flex-col border-r border-slate-200 bg-card dark:border-slate-700 dark:bg-card-dark",
// Off-canvas on mobile, static on desktop.
"fixed inset-y-0 left-0 transform transition-transform md:static md:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex items-center justify-between px-5 py-5">
<div className="flex items-center gap-2">
<Ship className="h-7 w-7 text-accent dark:text-accent-dark" /> <Ship className="h-7 w-7 text-accent dark:text-accent-dark" />
<span className="text-lg font-bold">StackPilot</span> <span className="text-lg font-bold">StackPilot</span>
</div> </div>
<button
onClick={onClose}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 md:hidden"
title="Close menu"
>
<X className="h-5 w-5" />
</button>
</div>
<nav className="flex-1 space-y-1 px-3"> <nav className="flex-1 space-y-1 px-3">
{nav.map(({ to, label, icon: Icon, end }) => ( {nav.map(({ to, label, icon: Icon, end }) => (
@@ -42,6 +76,7 @@ export function Sidebar() {
key={to} key={to}
to={to} to={to}
end={end} end={end}
onClick={onClose}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
@@ -88,5 +123,6 @@ export function Sidebar() {
</button> </button>
</div> </div>
</aside> </aside>
</>
); );
} }
+11 -2
View File
@@ -1,4 +1,5 @@
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { Menu } from "lucide-react";
const titles: Record<string, string> = { const titles: Record<string, string> = {
"": "Dashboard", "": "Dashboard",
@@ -6,16 +7,24 @@ const titles: Record<string, string> = {
networks: "Networks", networks: "Networks",
images: "Images", images: "Images",
templates: "Templates", templates: "Templates",
audit: "Audit log",
settings: "Settings", settings: "Settings",
}; };
export function Topbar() { export function Topbar({ onMenu }: { onMenu?: () => void }) {
const { pathname } = useLocation(); const { pathname } = useLocation();
const segment = pathname.split("/")[1] ?? ""; const segment = pathname.split("/")[1] ?? "";
const title = titles[segment] ?? "StackPilot"; const title = titles[segment] ?? "StackPilot";
return ( return (
<header className="flex h-14 items-center justify-between border-b border-slate-200 bg-card px-6 dark:border-slate-700 dark:bg-card-dark"> <header className="flex h-14 items-center gap-3 border-b border-slate-200 bg-card px-4 dark:border-slate-700 dark:bg-card-dark sm:px-6">
<button
onClick={onMenu}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 md:hidden"
title="Open menu"
>
<Menu className="h-5 w-5" />
</button>
<h1 className="text-base font-semibold">{title}</h1> <h1 className="text-base font-semibold">{title}</h1>
</header> </header>
); );
@@ -0,0 +1,192 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Archive, Upload } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui";
import { backupsApi } from "@/api/backups";
import { apiErrorMessage } from "@/api/client";
function Checkbox({
checked,
onChange,
label,
hint,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
hint?: string;
}) {
return (
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-accent"
/>
<span>
{label}
{hint && <span className="block text-xs text-slate-500">{hint}</span>}
</span>
</label>
);
}
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div
className="w-full max-w-md rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}
export function BackupButton({ stackId }: { stackId: string }) {
const [open, setOpen] = useState(false);
const [includeVolumes, setIncludeVolumes] = useState(true);
const [stopFirst, setStopFirst] = useState(true);
const [busy, setBusy] = useState(false);
const run = async () => {
setBusy(true);
const tid = toast.loading("Creating backup…");
try {
await backupsApi.download(stackId, { includeVolumes, stopFirst });
toast.success("Backup downloaded", { id: tid });
setOpen(false);
} catch (e) {
toast.error(apiErrorMessage(e), { id: tid });
} finally {
setBusy(false);
}
};
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<Archive className="h-4 w-4" /> Backup
</Button>
{open && (
<Modal onClose={() => !busy && setOpen(false)}>
<h2 className="mb-3 text-lg font-semibold">Back up {stackId}</h2>
<div className="space-y-3">
<Checkbox
checked={includeVolumes}
onChange={setIncludeVolumes}
label="Include named volume data"
hint="Snapshots each compose-managed volume into the archive."
/>
<Checkbox
checked={stopFirst}
onChange={setStopFirst}
label="Stop the stack during backup"
hint="Recommended for a consistent volume snapshot; the stack is restarted afterwards."
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
Cancel
</Button>
<Button onClick={run} loading={busy}>
Download backup
</Button>
</div>
</Modal>
)}
</>
);
}
export function RestoreButton() {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [targetId, setTargetId] = useState("");
const [overwrite, setOverwrite] = useState(false);
const [restoreVolumes, setRestoreVolumes] = useState(true);
const [busy, setBusy] = useState(false);
const run = async () => {
if (!file) {
toast.error("Select a backup file");
return;
}
setBusy(true);
const tid = toast.loading("Restoring…");
try {
const res = await backupsApi.restore(file, {
targetId: targetId.trim() || undefined,
overwrite,
restoreVolumes,
});
toast.success(
`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`,
{ id: tid }
);
qc.invalidateQueries({ queryKey: ["stacks"] });
setOpen(false);
setFile(null);
setTargetId("");
} catch (e) {
toast.error(apiErrorMessage(e), { id: tid });
} finally {
setBusy(false);
}
};
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<Upload className="h-4 w-4" /> Restore
</Button>
{open && (
<Modal onClose={() => !busy && setOpen(false)}>
<h2 className="mb-3 text-lg font-semibold">Restore from backup</h2>
<div className="space-y-3">
<input
type="file"
accept=".tar.gz,.tgz,application/gzip"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-accent file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-accent-dark dark:file:text-slate-900"
/>
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">
Restore as (optional leave blank to use the original name)
</span>
<input
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
placeholder="new-stack-name"
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
/>
</label>
<Checkbox
checked={restoreVolumes}
onChange={setRestoreVolumes}
label="Restore volume data"
/>
<Checkbox
checked={overwrite}
onChange={setOverwrite}
label="Overwrite if a stack with this id already exists"
hint="Replaces the existing stack files and volume contents."
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
Cancel
</Button>
<Button onClick={run} loading={busy} disabled={!file}>
Restore
</Button>
</div>
</Modal>
)}
</>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ScrollText } from "lucide-react";
import { Card, Input, Spinner } from "@/components/ui";
import api from "@/api/client";
import type { AuditEntry } from "@/types";
import { relativeTime } from "@/lib/utils";
const PAGE = 100;
export function Audit() {
const [offset, setOffset] = useState(0);
const [filter, setFilter] = useState("");
const { data, isLoading, isFetching } = useQuery({
queryKey: ["audit-log", offset],
queryFn: () =>
api
.get<AuditEntry[]>(`/api/audit?limit=${PAGE}&offset=${offset}`)
.then((r) => r.data),
refetchInterval: 15000,
});
const rows = (data ?? []).filter((a) => {
if (!filter.trim()) return true;
const q = filter.toLowerCase();
return (
a.user.toLowerCase().includes(q) ||
a.action.toLowerCase().includes(q) ||
a.target.toLowerCase().includes(q)
);
});
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<ScrollText className="h-4 w-4" /> Audit log
</h2>
<Input
placeholder="Filter by user, action, or target…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="max-w-xs"
/>
</div>
<Card className="overflow-x-auto p-0">
{isLoading ? (
<Spinner />
) : (
<table className="w-full text-left text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
<tr>
<th className="px-4 py-2">When</th>
<th className="px-4 py-2">User</th>
<th className="px-4 py-2">Action</th>
<th className="px-4 py-2">Target</th>
<th className="px-4 py-2">Detail</th>
<th className="px-4 py-2">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{rows.map((a) => (
<tr key={a.id}>
<td className="whitespace-nowrap px-4 py-2 text-xs text-slate-400">
{relativeTime(a.timestamp)}
</td>
<td className="px-4 py-2 font-medium">{a.user}</td>
<td className="px-4 py-2 text-slate-500">{a.action}</td>
<td className="px-4 py-2 font-mono text-xs text-accent dark:text-accent-dark">
{a.target}
</td>
<td className="px-4 py-2 text-xs text-slate-500">{a.detail}</td>
<td className="px-4 py-2 font-mono text-xs text-slate-400">{a.ip}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-slate-500">
No matching activity.
</td>
</tr>
)}
</tbody>
</table>
)}
</Card>
<div className="flex items-center justify-between text-sm">
<button
className="rounded-lg px-3 py-1.5 text-slate-600 enabled:hover:bg-slate-100 disabled:opacity-40 dark:text-slate-300 dark:enabled:hover:bg-slate-700"
disabled={offset === 0 || isFetching}
onClick={() => setOffset((o) => Math.max(0, o - PAGE))}
>
Newer
</button>
<span className="text-xs text-slate-400">
Showing {offset + 1}{offset + (data?.length ?? 0)}
</span>
<button
className="rounded-lg px-3 py-1.5 text-slate-600 enabled:hover:bg-slate-100 disabled:opacity-40 dark:text-slate-300 dark:enabled:hover:bg-slate-700"
disabled={(data?.length ?? 0) < PAGE || isFetching}
onClick={() => setOffset((o) => o + PAGE)}
>
Older
</button>
</div>
</div>
);
}
+1 -2
View File
@@ -14,5 +14,4 @@ export function Placeholder({ title, phase }: { title: string; phase: string })
); );
} }
export const Networks = () => <Placeholder title="Networks" phase="Phase 4" />; export const Networks = () => <Placeholder title="Networks" phase="a future phase" />;
export const Settings = () => <Placeholder title="Settings" phase="Phase 4" />;
+458
View File
@@ -0,0 +1,458 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Bell,
Clock,
Plus,
Send,
Trash2,
Users as UsersIcon,
ShieldCheck,
Power,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import {
settingsApi,
usersApi,
type Webhook,
type WebhookInput,
} from "@/api/settings";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import type { User } from "@/types";
export function Settings() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
if (!isAdmin) {
return (
<Card className="flex flex-col items-center gap-3 py-16 text-center">
<ShieldCheck className="h-10 w-10 text-slate-400" />
<h2 className="text-lg font-semibold">Admin only</h2>
<p className="max-w-md text-sm text-slate-500">
Settings are available to administrators only.
</p>
</Card>
);
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<GeneralSection />
<NotificationsSection />
<UsersSection />
</div>
);
}
/* -------------------------------------------------------------------------- */
/* General */
/* -------------------------------------------------------------------------- */
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
{icon}
{children}
</h2>
);
}
function GeneralSection() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["settings"], queryFn: settingsApi.get });
const [interval, setInterval] = useState("");
useEffect(() => {
if (data) setInterval(String(data.update_check_interval_minutes));
}, [data]);
const save = useMutation({
mutationFn: () =>
settingsApi.update({ update_check_interval_minutes: Number(interval) }),
onSuccess: () => {
toast.success("Settings saved");
qc.invalidateQueries({ queryKey: ["settings"] });
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<section>
<SectionTitle icon={<Clock className="h-4 w-4" />}>General</SectionTitle>
<Card className="space-y-4">
{isLoading ? (
<Spinner />
) : (
<>
<label className="block space-y-1">
<span className="text-sm font-medium">Image update check interval (minutes)</span>
<div className="flex gap-2">
<Input
type="number"
min={5}
value={interval}
onChange={(e) => setInterval(e.target.value)}
className="max-w-[140px]"
/>
<Button onClick={() => save.mutate()} loading={save.isPending}>
Save
</Button>
</div>
<span className="text-xs text-slate-500">
Minimum 5 minutes. Applies on the next check cycle.
</span>
</label>
{data && data.env_webhook_count > 0 && (
<p className="text-xs text-slate-500">
{data.env_webhook_count} generic webhook(s) configured via the{" "}
<code>NOTIFY_WEBHOOKS</code> environment variable receive every event.
</p>
)}
</>
)}
</Card>
</section>
);
}
/* -------------------------------------------------------------------------- */
/* Notifications */
/* -------------------------------------------------------------------------- */
const EVENT_LABELS: Record<string, string> = {
update_available: "Image update available",
stack_start: "Stack started",
stack_stop: "Stack stopped",
stack_error: "Stack error",
pull_failed: "Pull/update failed",
};
function NotificationsSection() {
const qc = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: settingsApi.get });
const webhooks = useQuery({ queryKey: ["webhooks"], queryFn: settingsApi.listWebhooks });
const [adding, setAdding] = useState(false);
return (
<section>
<SectionTitle icon={<Bell className="h-4 w-4" />}>Notifications</SectionTitle>
<div className="space-y-3">
{webhooks.isLoading ? (
<Spinner />
) : (
webhooks.data?.map((w) => <WebhookRow key={w.id} webhook={w} />)
)}
{webhooks.data?.length === 0 && !adding && (
<Card>
<p className="text-sm text-slate-500">
No webhooks yet. Add ntfy, Discord, Slack, Gotify, or a generic JSON endpoint.
</p>
</Card>
)}
{adding && settings.data && (
<WebhookForm
types={settings.data.webhook_types}
events={settings.data.available_events}
onDone={() => {
setAdding(false);
qc.invalidateQueries({ queryKey: ["webhooks"] });
}}
onCancel={() => setAdding(false)}
/>
)}
{!adding && (
<Button variant="outline" onClick={() => setAdding(true)}>
<Plus className="h-4 w-4" /> Add webhook
</Button>
)}
</div>
</section>
);
}
function WebhookRow({ webhook }: { webhook: Webhook }) {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ["webhooks"] });
const toggle = useMutation({
mutationFn: () => settingsApi.updateWebhook(webhook.id, { enabled: !webhook.enabled }),
onSuccess: invalidate,
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: () => settingsApi.deleteWebhook(webhook.id),
onSuccess: () => {
toast.success("Webhook deleted");
invalidate();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const test = useMutation({
mutationFn: () => settingsApi.testWebhook(webhook.id),
onSuccess: (r) =>
r.ok ? toast.success("Test sent") : toast.error("Delivery failed — check the URL"),
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<Card className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-medium">{webhook.name}</span>
<Badge>{webhook.type}</Badge>
{!webhook.enabled && <span className="text-xs text-slate-400">disabled</span>}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => test.mutate()} loading={test.isPending}>
<Send className="h-4 w-4" /> Test
</Button>
<Button variant="ghost" onClick={() => toggle.mutate()} loading={toggle.isPending}>
<Power className="h-4 w-4" /> {webhook.enabled ? "Disable" : "Enable"}
</Button>
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
<p className="break-all font-mono text-xs text-slate-500">{webhook.url}</p>
<div className="flex flex-wrap gap-1">
{webhook.events.map((e) => (
<span
key={e}
className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300"
>
{EVENT_LABELS[e] ?? e}
</span>
))}
</div>
</Card>
);
}
function WebhookForm({
types,
events,
onDone,
onCancel,
}: {
types: string[];
events: string[];
onDone: () => void;
onCancel: () => void;
}) {
const [form, setForm] = useState<WebhookInput>({
name: "",
url: "",
type: types[0] ?? "generic",
events: [...events],
enabled: true,
});
const create = useMutation({
mutationFn: () => settingsApi.createWebhook(form),
onSuccess: () => {
toast.success("Webhook added");
onDone();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const toggleEvent = (e: string) =>
setForm((f) => ({
...f,
events: f.events.includes(e) ? f.events.filter((x) => x !== e) : [...f.events, e],
}));
return (
<Card className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Name</span>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Type</span>
<select
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
value={form.type}
onChange={(e) => setForm({ ...form, type: e.target.value })}
>
{types.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
</div>
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">URL</span>
<Input
placeholder="https://ntfy.sh/my-topic"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
/>
</label>
<div className="space-y-1">
<span className="text-xs font-medium text-slate-500">Events</span>
<div className="flex flex-wrap gap-2">
{events.map((e) => (
<button
key={e}
type="button"
onClick={() => toggleEvent(e)}
className={
form.events.includes(e)
? "rounded-full bg-accent px-2 py-0.5 text-xs text-white dark:bg-accent-dark dark:text-slate-900"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300"
}
>
{EVENT_LABELS[e] ?? e}
</button>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button
onClick={() => create.mutate()}
loading={create.isPending}
disabled={!form.name.trim() || !form.url.trim() || form.events.length === 0}
>
Add
</Button>
</div>
</Card>
);
}
/* -------------------------------------------------------------------------- */
/* Users */
/* -------------------------------------------------------------------------- */
function UsersSection() {
const qc = useQueryClient();
const me = useAuthStore((s) => s.user);
const { data, isLoading } = useQuery({ queryKey: ["users"], queryFn: usersApi.list });
const [adding, setAdding] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ["users"] });
return (
<section>
<SectionTitle icon={<UsersIcon className="h-4 w-4" />}>Users</SectionTitle>
<Card className="space-y-2">
{isLoading ? (
<Spinner />
) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-700">
{data?.map((u) => (
<UserRow key={u.id} user={u} isSelf={u.id === me?.id} onChange={invalidate} />
))}
</ul>
)}
{adding ? (
<AddUserForm onDone={() => { setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
) : (
<Button variant="outline" className="mt-2" onClick={() => setAdding(true)}>
<Plus className="h-4 w-4" /> Add user
</Button>
)}
</Card>
</section>
);
}
function UserRow({ user, isSelf, onChange }: { user: User; isSelf: boolean; onChange: () => void }) {
const update = useMutation({
mutationFn: (body: { role?: string; is_active?: boolean }) => usersApi.update(user.id, body),
onSuccess: onChange,
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: () => usersApi.remove(user.id),
onSuccess: () => { toast.success("User removed"); onChange(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<li className="flex flex-wrap items-center justify-between gap-2 py-2">
<div className="flex items-center gap-2">
<span className="font-medium">{user.username}</span>
<Badge>{user.role}</Badge>
{!user.is_active && <span className="text-xs text-red-500">inactive</span>}
{isSelf && <span className="text-xs text-slate-400">you</span>}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={() => update.mutate({ role: user.role === "admin" ? "user" : "admin" })}
loading={update.isPending}
>
{user.role === "admin" ? "Make user" : "Make admin"}
</Button>
<Button
variant="ghost"
onClick={() => update.mutate({ is_active: !user.is_active })}
loading={update.isPending}
>
{user.is_active ? "Disable" : "Enable"}
</Button>
{!isSelf && (
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
</li>
);
}
function AddUserForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const create = useMutation({
mutationFn: () => usersApi.create({ username, password, role }),
onSuccess: () => { toast.success("User created"); onDone(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<div className="mt-2 space-y-2 rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="grid gap-2 sm:grid-cols-3">
<Input placeholder="username" value={username} onChange={(e) => setUsername(e.target.value)} />
<Input
type="password"
placeholder="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<select
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
value={role}
onChange={(e) => setRole(e.target.value)}
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button
onClick={() => create.mutate()}
loading={create.isPending}
disabled={!username.trim() || !password}
>
Create
</Button>
</div>
</div>
);
}
+2
View File
@@ -12,6 +12,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui"; import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { LogViewer } from "@/components/stacks/LogViewer"; import { LogViewer } from "@/components/stacks/LogViewer";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks"; import { stacksApi } from "@/api/stacks";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions"; import { useStackActions } from "@/hooks/useStackActions";
@@ -67,6 +68,7 @@ export function StackDetail() {
<Button variant="outline" onClick={() => actions.down(id)} loading={busy}> <Button variant="outline" onClick={() => actions.down(id)} loading={busy}>
<Power className="h-4 w-4" /> Down <Power className="h-4 w-4" /> Down
</Button> </Button>
<BackupButton stackId={id} />
<Link to={`/stacks/${id}/edit`}> <Link to={`/stacks/${id}/edit`}>
<Button> <Button>
<Pencil className="h-4 w-4" /> Edit <Pencil className="h-4 w-4" /> Edit
+2
View File
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react"; import { Plus, Search } from "lucide-react";
import { Button, Input, Spinner, Card } from "@/components/ui"; import { Button, Input, Spinner, Card } from "@/components/ui";
import { StackCard } from "@/components/stacks/StackCard"; import { StackCard } from "@/components/stacks/StackCard";
import { RestoreButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks"; import { stacksApi } from "@/api/stacks";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions"; import { useStackActions } from "@/hooks/useStackActions";
@@ -57,6 +58,7 @@ export function Stacks() {
<option value="status">Sort: Status</option> <option value="status">Sort: Status</option>
<option value="updated">Sort: Last updated</option> <option value="updated">Sort: Last updated</option>
</select> </select>
{isAdmin && <RestoreButton />}
{isAdmin && ( {isAdmin && (
<Link to="/stacks/new"> <Link to="/stacks/new">
<Button> <Button>