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
@@ -9,3 +9,11 @@ STACKS_HOST_DIR=./data/stacks
|
||||
# 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.
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
> **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)
|
||||
|
||||
@@ -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.
|
||||
- **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
|
||||
|
||||
```
|
||||
@@ -143,6 +162,18 @@ GET /api/templates | /{id} POST /api/templates/{id}/insta
|
||||
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
|
||||
|
||||
- The Docker socket is only ever touched by the backend process; it is never
|
||||
|
||||
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
|
||||
DOCKER_SOCKET: str = "/var/run/docker.sock"
|
||||
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
|
||||
ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [
|
||||
"/", "/mnt", "/media", "/srv", "/opt",
|
||||
|
||||
+5
-1
@@ -16,9 +16,11 @@ from docker_client import DockerError
|
||||
from routers import (
|
||||
audit,
|
||||
auth,
|
||||
backups,
|
||||
editor,
|
||||
images,
|
||||
ports,
|
||||
settings as settings_router,
|
||||
stacks,
|
||||
system,
|
||||
templates,
|
||||
@@ -46,7 +48,7 @@ async def lifespan(app: FastAPI):
|
||||
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(
|
||||
CORSMiddleware,
|
||||
@@ -74,6 +76,8 @@ app.include_router(images.router)
|
||||
app.include_router(ports.router)
|
||||
app.include_router(templates.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(settings_router.router)
|
||||
app.include_router(backups.router)
|
||||
app.include_router(ws.router)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""SQLModel table models. Importing this package registers all tables."""
|
||||
from models.audit import AuditLog
|
||||
from models.setting import Setting, Webhook
|
||||
from models.stack import Stack
|
||||
from models.template import Template
|
||||
from models.user import User
|
||||
|
||||
__all__ = ["User", "Stack", "AuditLog", "Template"]
|
||||
__all__ = ["User", "Stack", "AuditLog", "Template", "Setting", "Webhook"]
|
||||
|
||||
@@ -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
|
||||
@@ -35,6 +35,12 @@ class UserCreate(SQLModel):
|
||||
role: str = "admin"
|
||||
|
||||
|
||||
class UserUpdate(SQLModel):
|
||||
password: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class LoginRequest(SQLModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
+111
-1
@@ -5,7 +5,7 @@ import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlmodel import Session
|
||||
from sqlmodel import Session, select
|
||||
|
||||
import auth as auth_mod
|
||||
from database import get_session
|
||||
@@ -16,6 +16,7 @@ from models.user import (
|
||||
User,
|
||||
UserCreate,
|
||||
UserRead,
|
||||
UserUpdate,
|
||||
)
|
||||
from services import audit_service
|
||||
|
||||
@@ -109,3 +110,112 @@ def refresh(
|
||||
@router.get("/me", response_model=UserRead)
|
||||
def me(user: User = Depends(auth_mod.get_current_user)) -> 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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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. 🚀",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -9,6 +9,11 @@ services:
|
||||
- DATA_DIR=/data
|
||||
- HOST_PROC_PATH=/host_proc
|
||||
- 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:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data:/data
|
||||
|
||||
@@ -16,6 +16,9 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -8,7 +8,9 @@ import { StackDetail } from "@/pages/StackDetail";
|
||||
import { StackEditor } from "@/pages/StackEditor";
|
||||
import { Images } from "@/pages/Images";
|
||||
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 { useThemeStore } from "@/store/theme";
|
||||
|
||||
@@ -44,6 +46,7 @@ export default function App() {
|
||||
<Route path="/networks" element={<Networks />} />
|
||||
<Route path="/images" element={<Images />} />
|
||||
<Route path="/templates" element={<Templates />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Topbar } from "./Topbar";
|
||||
|
||||
export function Layout() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Topbar onMenu={() => setMobileOpen(true)} />
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
Network,
|
||||
Image,
|
||||
LayoutTemplate,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Moon,
|
||||
Sun,
|
||||
LogOut,
|
||||
Ship,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
@@ -21,72 +23,106 @@ const nav = [
|
||||
{ to: "/networks", label: "Networks", icon: Network },
|
||||
{ to: "/images", label: "Images", icon: Image },
|
||||
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
|
||||
{ to: "/audit", label: "Audit log", icon: ScrollText },
|
||||
{ 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 logout = useAuthStore((s) => s.logout);
|
||||
const { theme, toggle } = useThemeStore();
|
||||
|
||||
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">
|
||||
<Ship className="h-7 w-7 text-accent dark:text-accent-dark" />
|
||||
<span className="text-lg font-bold">StackPilot</span>
|
||||
</div>
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/50 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{nav.map(({ to, label, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent/10 text-accent dark:bg-accent-dark/10 dark:text-accent-dark"
|
||||
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-200 p-3 dark:border-slate-700">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{user?.username ?? "—"}
|
||||
{user?.role === "admin" && (
|
||||
<span className="ml-1 text-xs text-accent dark:text-accent-dark">
|
||||
admin
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<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" />
|
||||
<span className="text-lg font-bold">StackPilot</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
title="Toggle theme"
|
||||
onClick={onClose}
|
||||
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 md:hidden"
|
||||
title="Close menu"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{nav.map(({ to, label, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={onClose}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent/10 text-accent dark:bg-accent-dark/10 dark:text-accent-dark"
|
||||
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-200 p-3 dark:border-slate-700">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{user?.username ?? "—"}
|
||||
{user?.role === "admin" && (
|
||||
<span className="ml-1 text-xs text-accent dark:text-accent-dark">
|
||||
admin
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Menu } from "lucide-react";
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
"": "Dashboard",
|
||||
@@ -6,16 +7,24 @@ const titles: Record<string, string> = {
|
||||
networks: "Networks",
|
||||
images: "Images",
|
||||
templates: "Templates",
|
||||
audit: "Audit log",
|
||||
settings: "Settings",
|
||||
};
|
||||
|
||||
export function Topbar() {
|
||||
export function Topbar({ onMenu }: { onMenu?: () => void }) {
|
||||
const { pathname } = useLocation();
|
||||
const segment = pathname.split("/")[1] ?? "";
|
||||
const title = titles[segment] ?? "StackPilot";
|
||||
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,5 +14,4 @@ export function Placeholder({ title, phase }: { title: string; phase: string })
|
||||
);
|
||||
}
|
||||
|
||||
export const Networks = () => <Placeholder title="Networks" phase="Phase 4" />;
|
||||
export const Settings = () => <Placeholder title="Settings" phase="Phase 4" />;
|
||||
export const Networks = () => <Placeholder title="Networks" phase="a future phase" />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useStackActions } from "@/hooks/useStackActions";
|
||||
@@ -67,6 +68,7 @@ export function StackDetail() {
|
||||
<Button variant="outline" onClick={() => actions.down(id)} loading={busy}>
|
||||
<Power className="h-4 w-4" /> Down
|
||||
</Button>
|
||||
<BackupButton stackId={id} />
|
||||
<Link to={`/stacks/${id}/edit`}>
|
||||
<Button>
|
||||
<Pencil className="h-4 w-4" /> Edit
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { Button, Input, Spinner, Card } from "@/components/ui";
|
||||
import { StackCard } from "@/components/stacks/StackCard";
|
||||
import { RestoreButton } from "@/components/stacks/BackupRestore";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useStackActions } from "@/hooks/useStackActions";
|
||||
@@ -57,6 +58,7 @@ export function Stacks() {
|
||||
<option value="status">Sort: Status</option>
|
||||
<option value="updated">Sort: Last updated</option>
|
||||
</select>
|
||||
{isAdmin && <RestoreButton />}
|
||||
{isAdmin && (
|
||||
<Link to="/stacks/new">
|
||||
<Button>
|
||||
|
||||
Reference in New Issue
Block a user