Phase 2: Volume Wizard, GPU & device passthrough (0.2.0)

Backend:
- gpu_service: detect NVIDIA (nvidia-smi) + AMD/Intel (/dev/dri, sysfs);
  inject helpers (nvidia deploy.reservations, /dev/dri + groups + LIBVA)
- volume_service: list/orphaned/prune volumes; NFS/SMB/named/bind/tmpfs
  YAML generation (generate-yaml)
- device_service: USB/TTY/DRI detection + sandboxed host path browser
- compose_edit_service: server-side merge of volume/gpu/device fragments
- routers: volumes (+host paths), editor (services/add-volume/set-gpu/
  add-device/remove-device/set-privileged), system gpus+devices
- compose: bind-mount /dev:ro for detection

Frontend:
- split-pane StackEditor with helper panel (service picker + tabs)
- VolumeWizard (bind/named/nfs/smb/tmpfs) + HostPathBrowser
- GPUSelector, DevicePanel; api clients for volumes/editor/system

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 16:35:22 +00:00
co-authored by Claude Opus 4.8
parent 7775128c07
commit b553c1b861
20 changed files with 1841 additions and 26 deletions
+28 -3
View File
@@ -3,8 +3,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) complete. Volume/GPU wizards, image-update checks,
> templates, multi-host agents and backups land in later phases.
> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) complete. Image-update
> checks, templates, multi-host agents and backups land in later phases.
## What works today (Phase 1)
@@ -25,6 +25,22 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
dir containing a compose file) are picked up automatically.
- **Dark / light theme.**
### Phase 2 — Volumes & GPU
- **Volume Wizard** in the editor (right-hand helper panel): Bind / Named / NFS /
SMB-CIFS / tmpfs, with a sandboxed **host path browser** for bind mounts and a
live YAML preview. NFS/SMB `driver_opts` are generated for you.
- **GPU assignment** per service: auto-detects NVIDIA (`nvidia-smi`) and AMD/Intel
(`/dev/dri` + sysfs), injects the right YAML (NVIDIA `deploy.reservations`, or
`/dev/dri` passthrough + render/video groups + `LIBVA_DRIVER_NAME=iHD` for Intel).
- **Device passthrough**: lists host USB / serial-TTY / DRI nodes, add per service,
plus a guarded `privileged` toggle.
- All wizard edits are merged into the compose YAML **server-side** (robust,
validated) and returned to the editor for review before saving.
> GPU/device detection needs host visibility. The bundled compose bind-mounts
> `/dev:/dev:ro`; NVIDIA additionally requires the NVIDIA container runtime on the host.
## Architecture
```
@@ -91,10 +107,19 @@ GET /api/stacks/{id} PUT /api/stacks/{id} DELETE /
POST /api/stacks/{id}/{start|stop|restart|pull|update|down|clone}
GET /api/stacks/{id}/logs GET /api/stacks/{id}/export
POST /api/stacks/convert (docker run → compose)
GET /api/system/info GET /api/audit
GET /api/system/info | gpus | devices GET /api/audit
WS /ws/logs/{stack_id}[/{service}] WS /ws/events
```
### Phase 2 endpoints
```
GET /api/volumes | /orphaned DELETE /api/volumes/{name}
POST /api/volumes/prune POST /api/volumes/generate-yaml
GET /api/host/paths?path=&show_hidden= (sandboxed browser)
POST /api/editor/services | add-volume | set-gpu | add-device | remove-device | set-privileged
```
## Security notes
- The Docker socket is only ever touched by the backend process; it is never
+4 -2
View File
@@ -12,7 +12,7 @@ from sqlmodel import Session
from config import settings
from database import engine, init_db
from docker_client import DockerError
from routers import audit, auth, stacks, system, ws
from routers import audit, auth, editor, stacks, system, volumes, ws
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("stackpilot")
@@ -31,7 +31,7 @@ async def lifespan(app: FastAPI):
yield
app = FastAPI(title="StackPilot", version="0.1.1", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.2.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -53,6 +53,8 @@ async def docker_error_handler(_request: Request, exc: DockerError):
app.include_router(auth.router)
app.include_router(stacks.router)
app.include_router(system.router)
app.include_router(volumes.router)
app.include_router(editor.router)
app.include_router(audit.router)
app.include_router(ws.router)
+76
View File
@@ -0,0 +1,76 @@
"""Editor helper endpoints — merge wizard fragments into compose YAML."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from auth import get_current_user
from models.user import User
from services import compose_edit_service as edit
router = APIRouter(prefix="/api/editor", tags=["editor"])
class AddVolumeBody(BaseModel):
yaml: str
service: str
spec: dict
class SetGpuBody(BaseModel):
yaml: str
service: str
config: dict
class DeviceBody(BaseModel):
yaml: str
service: str
host_path: str
target: str | None = None
class PrivilegedBody(BaseModel):
yaml: str
service: str
value: bool
def _run(fn, *args) -> dict:
try:
return {"yaml": fn(*args)}
except edit.EditError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/services")
def services(body: dict, _user: User = Depends(get_current_user)) -> dict:
try:
return {"services": edit.list_services(body.get("yaml", ""))}
except edit.EditError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/add-volume")
def add_volume(body: AddVolumeBody, _user: User = Depends(get_current_user)) -> dict:
return _run(edit.add_volume, body.yaml, body.service, body.spec)
@router.post("/set-gpu")
def set_gpu(body: SetGpuBody, _user: User = Depends(get_current_user)) -> dict:
return _run(edit.set_gpu, body.yaml, body.service, body.config)
@router.post("/add-device")
def add_device(body: DeviceBody, _user: User = Depends(get_current_user)) -> dict:
return _run(edit.add_device, body.yaml, body.service, body.host_path, body.target)
@router.post("/remove-device")
def remove_device(body: DeviceBody, _user: User = Depends(get_current_user)) -> dict:
return _run(edit.remove_device, body.yaml, body.service, body.host_path)
@router.post("/set-privileged")
def set_privileged(body: PrivilegedBody, _user: User = Depends(get_current_user)) -> dict:
return _run(edit.set_privileged, body.yaml, body.service, body.value)
+14 -1
View File
@@ -10,6 +10,7 @@ from auth import get_current_user
from config import settings
from docker_client import DockerError, get_client, safe_call
from models.user import User
from services import device_service, gpu_service
router = APIRouter(prefix="/api/system", tags=["system"])
@@ -88,5 +89,17 @@ def system_info(_user: User = Depends(get_current_user)) -> dict:
"uptime_seconds": _uptime(),
"containers_running": containers_running,
"containers_total": containers_total,
"gpus": [], # populated in Phase 2
"gpus": [g.to_dict() for g in gpu_service.detect_gpus()],
}
@router.get("/gpus")
def gpus(_user: User = Depends(get_current_user)) -> list[dict]:
"""Re-detect GPUs live."""
return [g.to_dict() for g in gpu_service.detect_gpus()]
@router.get("/devices")
def devices(_user: User = Depends(get_current_user)) -> dict:
"""List host USB / serial / DRI devices for passthrough."""
return device_service.detect_devices()
+97
View File
@@ -0,0 +1,97 @@
"""Volume management, NFS/SMB YAML generation, and host path browser."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlmodel import Session
from auth import get_current_user, require_admin
from database import get_session
from models.user import User
from services import audit_service, device_service, volume_service
router = APIRouter(tags=["volumes"])
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
# --------------------------------------------------------------------------- #
# Volumes
# --------------------------------------------------------------------------- #
@router.get("/api/volumes")
def list_volumes(_user: User = Depends(get_current_user)) -> list[dict]:
return volume_service.list_volumes()
@router.get("/api/volumes/orphaned")
def orphaned(_user: User = Depends(get_current_user)) -> list[dict]:
return volume_service.orphaned_volumes()
@router.delete("/api/volumes/{name}")
def delete_volume(
name: str,
request: Request,
force: bool = Query(False),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
vols = {v["name"]: v for v in volume_service.list_volumes()}
if name in vols and vols[name]["in_use"] and not force:
raise HTTPException(
status_code=409,
detail={
"error": "volume_in_use",
"detail": f"Volume '{name}' is used by: {', '.join(vols[name]['used_by'])}",
},
)
volume_service.remove_volume(name, force=force)
audit_service.record(
session, user=user.username, action="volume.delete", target=name, ip=_ip(request)
)
return {"ok": True}
@router.post("/api/volumes/prune")
def prune(
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
result = volume_service.prune_volumes()
audit_service.record(
session, user=user.username, action="volume.prune", target="*",
detail=str(result.get("VolumesDeleted")), ip=_ip(request),
)
return result
@router.post("/api/volumes/generate-yaml")
def generate_yaml(
spec: dict,
_user: User = Depends(get_current_user),
) -> dict:
try:
return {"yaml": volume_service.generate_yaml(spec)}
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=400, detail=f"Invalid volume spec: {exc}") from exc
# --------------------------------------------------------------------------- #
# Host path browser
# --------------------------------------------------------------------------- #
@router.get("/api/host/paths")
def host_paths(
path: str = Query("/"),
show_hidden: bool = Query(False),
_user: User = Depends(get_current_user),
) -> dict:
try:
return device_service.browse(path, show_hidden)
except device_service.BrowseError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+149
View File
@@ -0,0 +1,149 @@
"""Server-side merging of wizard fragments into a compose YAML string.
PyYAML round-trips lose comments; that is acceptable here because these helpers
are invoked from the editor where the returned YAML replaces the buffer and the
user reviews before saving.
"""
from __future__ import annotations
import yaml
from services import gpu_service, volume_service
class EditError(Exception):
pass
def _load(yaml_str: str) -> dict:
try:
data = yaml.safe_load(yaml_str) or {}
except yaml.YAMLError as exc:
raise EditError(f"Invalid YAML: {exc}") from exc
if not isinstance(data, dict):
raise EditError("Top-level compose document must be a mapping")
data.setdefault("services", {})
if not isinstance(data["services"], dict):
raise EditError("`services` must be a mapping")
return data
def _dump(data: dict) -> str:
# Drop empty top-level keys we may have created.
if not data.get("services"):
data.pop("services", None)
if "volumes" in data and not data["volumes"]:
data.pop("volumes", None)
return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
def _get_service(data: dict, service: str) -> dict:
svc = data["services"].get(service)
if svc is None:
svc = {}
data["services"][service] = svc
if not isinstance(svc, dict):
raise EditError(f"Service '{service}' is not a mapping")
return svc
# --------------------------------------------------------------------------- #
# Volumes
# --------------------------------------------------------------------------- #
def add_volume(yaml_str: str, service: str, spec: dict) -> str:
data = _load(yaml_str)
svc = _get_service(data, service)
# Top-level volume declaration (named/nfs/smb).
definition = volume_service.build_volume_definition(spec)
if definition:
top = data.setdefault("volumes", {})
if not isinstance(top, dict):
raise EditError("`volumes` must be a mapping")
top.update(definition)
mount = volume_service.build_service_mount(spec)
vols = svc.setdefault("volumes", [])
if not isinstance(vols, list):
raise EditError(f"Service '{service}' volumes must be a list")
if mount not in vols:
vols.append(mount)
return _dump(data)
# --------------------------------------------------------------------------- #
# GPU
# --------------------------------------------------------------------------- #
def set_gpu(yaml_str: str, service: str, config: dict) -> str:
"""config = { mode: 'none'|'nvidia'|'dri', ... }"""
data = _load(yaml_str)
svc = _get_service(data, service)
mode = config.get("mode", "none")
gpu_service.remove_gpu(svc)
if mode == "nvidia":
gpu_service.inject_nvidia(
svc,
device_ids=config.get("device_ids") or None,
count=config.get("count"),
capabilities=config.get("capabilities") or ["gpu"],
)
elif mode == "dri":
gpu_service.inject_dri(
svc,
vendor=config.get("vendor", "intel"),
add_render_group=config.get("add_render_group", True),
add_video_group=config.get("add_video_group", False),
set_libva=config.get("set_libva", False),
)
return _dump(data)
# --------------------------------------------------------------------------- #
# Devices / privileged
# --------------------------------------------------------------------------- #
def add_device(yaml_str: str, service: str, host_path: str, target: str | None = None) -> str:
data = _load(yaml_str)
svc = _get_service(data, service)
entry = f"{host_path}:{target or host_path}"
devices = svc.setdefault("devices", [])
if not isinstance(devices, list):
raise EditError(f"Service '{service}' devices must be a list")
if entry not in devices:
devices.append(entry)
return _dump(data)
def remove_device(yaml_str: str, service: str, host_path: str) -> str:
data = _load(yaml_str)
svc = _get_service(data, service)
devices = svc.get("devices", [])
if isinstance(devices, list):
svc["devices"] = [
d for d in devices if not str(d).startswith(host_path + ":") and d != host_path
]
if not svc["devices"]:
svc.pop("devices", None)
return _dump(data)
def set_privileged(yaml_str: str, service: str, value: bool) -> str:
data = _load(yaml_str)
svc = _get_service(data, service)
if value:
svc["privileged"] = True
else:
svc.pop("privileged", None)
return _dump(data)
def list_services(yaml_str: str) -> list[str]:
data = _load(yaml_str)
return list(data["services"].keys())
+154
View File
@@ -0,0 +1,154 @@
"""Host device detection (USB, serial/TTY, DRI) and host filesystem browser."""
from __future__ import annotations
import glob
import os
from dataclasses import asdict, dataclass
from typing import Optional
from config import settings
@dataclass
class HostDevice:
path: str
kind: str # "usb" | "tty" | "dri" | "other"
name: str = ""
def to_dict(self) -> dict:
return asdict(self)
def _read(path: str) -> str:
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
return fh.read().strip()
except OSError:
return ""
def _usb_devices() -> list[HostDevice]:
devices: list[HostDevice] = []
# /dev/bus/usb/<bus>/<dev>
for path in sorted(glob.glob("/dev/bus/usb/*/*")):
name = ""
# Best-effort name lookup via sysfs is non-trivial to map; leave generic.
devices.append(HostDevice(path=path, kind="usb", name=name or "USB device"))
return devices
def _usb_names_from_sysfs() -> list[HostDevice]:
"""Richer USB list from sysfs with product/manufacturer strings."""
out: list[HostDevice] = []
for dev in sorted(glob.glob("/sys/bus/usb/devices/*")):
busnum = _read(os.path.join(dev, "busnum"))
devnum = _read(os.path.join(dev, "devnum"))
if not busnum or not devnum:
continue
product = _read(os.path.join(dev, "product"))
manufacturer = _read(os.path.join(dev, "manufacturer"))
label = " ".join(p for p in (manufacturer, product) if p) or "USB device"
path = f"/dev/bus/usb/{int(busnum):03d}/{int(devnum):03d}"
out.append(HostDevice(path=path, kind="usb", name=label))
return out
def _tty_devices() -> list[HostDevice]:
devices: list[HostDevice] = []
patterns = ["/dev/ttyUSB*", "/dev/ttyACM*", "/dev/ttyAMA*", "/dev/serial/by-id/*"]
for pattern in patterns:
for path in sorted(glob.glob(pattern)):
base = os.path.basename(path)
driver = _read(f"/sys/class/tty/{base}/device/driver/module/name") or ""
devices.append(
HostDevice(path=path, kind="tty", name=driver or "Serial device")
)
return devices
def _dri_devices() -> list[HostDevice]:
return [
HostDevice(path=p, kind="dri", name="GPU render node")
for p in sorted(glob.glob("/dev/dri/*"))
]
def detect_devices() -> dict:
usb = _usb_names_from_sysfs() or _usb_devices()
return {
"usb": [d.to_dict() for d in usb],
"tty": [d.to_dict() for d in _tty_devices()],
"dri": [d.to_dict() for d in _dri_devices()],
}
# --------------------------------------------------------------------------- #
# Host filesystem browser (sandboxed)
# --------------------------------------------------------------------------- #
def _real_root(path: str) -> str:
"""Map a logical host path into the container view (HOST_ROOT_PREFIX)."""
prefix = settings.HOST_ROOT_PREFIX.rstrip("/")
if prefix:
return prefix + path
return path
def _is_allowed(path: str) -> bool:
norm = os.path.normpath(path)
for root in settings.ALLOWED_BROWSE_ROOTS:
root = os.path.normpath(root)
if root == "/" or norm == root or norm.startswith(root + os.sep):
return True
return False
class BrowseError(Exception):
pass
def browse(path: str = "/", show_hidden: bool = False) -> dict:
path = os.path.normpath(path or "/")
if not path.startswith("/"):
raise BrowseError("Path must be absolute")
if not _is_allowed(path):
raise BrowseError("Path is outside the allowed browse roots")
real = _real_root(path)
if not os.path.isdir(real):
raise BrowseError(f"Not a directory: {path}")
entries = []
try:
names = os.listdir(real)
except PermissionError as exc:
raise BrowseError(f"Permission denied: {path}") from exc
for name in sorted(names):
if not show_hidden and name.startswith("."):
continue
full_real = os.path.join(real, name)
try:
st = os.lstat(full_real)
is_dir = os.path.isdir(full_real)
entries.append(
{
"name": name,
"type": "dir" if is_dir else "file",
"size": st.st_size if not is_dir else None,
"permissions": oct(st.st_mode & 0o777),
}
)
except OSError:
continue
# Sort: dirs first, then files, both alphabetical.
entries.sort(key=lambda e: (e["type"] != "dir", e["name"].lower()))
parent = os.path.dirname(path) if path != "/" else None
return {
"path": path,
"parent": parent,
"roots": settings.ALLOWED_BROWSE_ROOTS,
"entries": entries,
}
+231
View File
@@ -0,0 +1,231 @@
"""GPU detection and Compose YAML injection helpers.
Detection sources:
* NVIDIA — `nvidia-smi` query (when the toolkit is present)
* AMD/Intel — scan /dev/dri/renderD* + /sys/class/drm/*/device/vendor
"""
from __future__ import annotations
import glob
import os
import subprocess
from dataclasses import asdict, dataclass
from typing import Literal, Optional
Vendor = Literal["nvidia", "amd", "intel"]
# PCI vendor IDs found in /sys/class/drm/*/device/vendor
_PCI_VENDORS = {
"0x10de": "nvidia",
"0x1002": "amd",
"0x8086": "intel",
}
@dataclass
class GPUInfo:
vendor: Vendor
index: int
name: str
uuid: Optional[str] = None
device_path: Optional[str] = None # e.g. /dev/dri/renderD128
driver: str = ""
vram_mb: Optional[int] = None
def to_dict(self) -> dict:
return asdict(self)
def _detect_nvidia() -> list[GPUInfo]:
gpus: list[GPUInfo] = []
try:
out = subprocess.run(
[
"nvidia-smi",
"--query-gpu=index,name,uuid,memory.total,driver_version",
"--format=csv,noheader,nounits",
],
capture_output=True,
text=True,
timeout=8,
)
except (FileNotFoundError, subprocess.SubprocessError):
return gpus
if out.returncode != 0:
return gpus
for line in out.stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 5:
continue
idx, name, uuid, mem, driver = parts[:5]
try:
index = int(idx)
except ValueError:
index = 0
try:
vram = int(float(mem))
except ValueError:
vram = None
gpus.append(
GPUInfo(
vendor="nvidia",
index=index,
name=name,
uuid=uuid,
driver=driver,
vram_mb=vram,
)
)
return gpus
def _read(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as fh:
return fh.read().strip()
except OSError:
return ""
def _detect_dri() -> list[GPUInfo]:
"""Detect AMD/Intel render nodes via /dev/dri + sysfs."""
gpus: list[GPUInfo] = []
render_nodes = sorted(glob.glob("/dev/dri/renderD*"))
for i, node in enumerate(render_nodes):
base = os.path.basename(node) # renderD128
vendor_id = _read(f"/sys/class/drm/{base}/device/vendor").lower()
vendor = _PCI_VENDORS.get(vendor_id)
if vendor == "nvidia":
# NVIDIA is reported via nvidia-smi; skip its DRI node here.
continue
if not vendor:
continue
# Try to read a human-ish name.
device_id = _read(f"/sys/class/drm/{base}/device/device")
name = {
"amd": "AMD GPU",
"intel": "Intel iGPU",
}.get(vendor, "GPU")
if device_id:
name = f"{name} ({device_id})"
gpus.append(
GPUInfo(
vendor=vendor, # type: ignore[arg-type]
index=i,
name=name,
device_path=node,
driver=vendor,
)
)
return gpus
def detect_gpus() -> list[GPUInfo]:
return _detect_nvidia() + _detect_dri()
# --------------------------------------------------------------------------- #
# Injection helpers — mutate a single service dict in place
# --------------------------------------------------------------------------- #
def inject_nvidia(
service: dict,
*,
device_ids: Optional[list[str]] = None,
count: Optional[int] = None,
capabilities: Optional[list[str]] = None,
) -> dict:
"""Add an NVIDIA device reservation under deploy.resources."""
caps = capabilities or ["gpu"]
reservation: dict = {"driver": "nvidia", "capabilities": caps}
if device_ids:
reservation["device_ids"] = device_ids
elif count is not None:
reservation["count"] = count
else:
reservation["count"] = "all"
deploy = service.setdefault("deploy", {})
resources = deploy.setdefault("resources", {})
reservations = resources.setdefault("reservations", {})
devices = reservations.setdefault("devices", [])
# Replace any existing nvidia reservation.
devices[:] = [d for d in devices if d.get("driver") != "nvidia"]
devices.append(reservation)
return service
def inject_dri(
service: dict,
*,
vendor: Vendor,
add_render_group: bool = True,
add_video_group: bool = False,
set_libva: bool = False,
) -> dict:
"""Pass through /dev/dri and (optionally) add render/video groups.
For Intel QSV, set LIBVA_DRIVER_NAME=iHD.
"""
devices = service.setdefault("devices", [])
if "/dev/dri:/dev/dri" not in devices:
devices.append("/dev/dri:/dev/dri")
groups = service.setdefault("group_add", [])
if add_render_group and "render" not in groups:
groups.append("render")
if add_video_group and "video" not in groups:
groups.append("video")
if set_libva and vendor == "intel":
_ensure_env(service, "LIBVA_DRIVER_NAME", "iHD")
return service
def remove_gpu(service: dict) -> dict:
"""Strip GPU-related config from a service."""
deploy = service.get("deploy", {})
resources = deploy.get("resources", {})
reservations = resources.get("reservations", {})
if "devices" in reservations:
reservations["devices"] = [
d for d in reservations["devices"] if d.get("driver") != "nvidia"
]
if not reservations["devices"]:
reservations.pop("devices", None)
if not reservations:
resources.pop("reservations", None)
if not resources:
deploy.pop("resources", None)
if not deploy:
service.pop("deploy", None)
# DRI passthrough
if "devices" in service:
service["devices"] = [
d for d in service["devices"] if d != "/dev/dri:/dev/dri"
]
if not service["devices"]:
service.pop("devices", None)
if "group_add" in service:
service["group_add"] = [
g for g in service["group_add"] if g not in ("render", "video")
]
if not service["group_add"]:
service.pop("group_add", None)
return service
def _ensure_env(service: dict, key: str, value: str) -> None:
env = service.get("environment")
if env is None:
service["environment"] = [f"{key}={value}"]
return
if isinstance(env, dict):
env[key] = value
return
# list form
prefix = f"{key}="
env = [e for e in env if not (isinstance(e, str) and e.startswith(prefix))]
env.append(f"{key}={value}")
service["environment"] = env
+198
View File
@@ -0,0 +1,198 @@
"""Docker volume management + Compose volume YAML generation."""
from __future__ import annotations
from typing import Literal, Optional
import yaml
from docker_client import get_client, safe_call
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
# --------------------------------------------------------------------------- #
# Listing / pruning
# --------------------------------------------------------------------------- #
def list_volumes() -> list[dict]:
client = get_client()
volumes = safe_call(client.volumes.list)
# Map volume name -> set of containers using it.
containers = safe_call(client.containers.list, all=True)
usage: dict[str, list[str]] = {}
for c in containers:
for mount in c.attrs.get("Mounts", []) or []:
if mount.get("Type") == "volume" and mount.get("Name"):
usage.setdefault(mount["Name"], []).append(c.name)
result = []
for v in volumes:
attrs = v.attrs
name = v.name
result.append(
{
"name": name,
"driver": attrs.get("Driver"),
"mountpoint": attrs.get("Mountpoint"),
"created_at": attrs.get("CreatedAt"),
"labels": attrs.get("Labels") or {},
"scope": attrs.get("Scope"),
"stack": (attrs.get("Labels") or {}).get(COMPOSE_PROJECT_LABEL),
"used_by": usage.get(name, []),
"in_use": bool(usage.get(name)),
}
)
return result
def orphaned_volumes() -> list[dict]:
return [v for v in list_volumes() if not v["in_use"]]
def remove_volume(name: str, force: bool = False) -> None:
client = get_client()
volume = safe_call(client.volumes.get, name)
safe_call(volume.remove, force=force)
def prune_volumes() -> dict:
client = get_client()
return safe_call(client.volumes.prune)
# --------------------------------------------------------------------------- #
# YAML generation
# --------------------------------------------------------------------------- #
VolumeType = Literal["bind", "named", "nfs", "smb", "tmpfs"]
def _nfs_options(server: str, opts: dict) -> str:
"""Build the `o:` option string for an NFS driver_opts block."""
parts = [f"addr={server}"]
# rw / ro
if opts.get("rw", True):
parts.append("rw")
else:
parts.append("ro")
for flag in ("soft", "hard", "nolock", "noatime", "noacl", "nocto", "bg"):
if opts.get(flag):
parts.append(flag)
version = opts.get("nfsvers") or opts.get("version")
if version:
parts.append(f"nfsvers={version}")
if opts.get("timeo") is not None:
parts.append(f"timeo={opts['timeo']}")
if opts.get("retrans") is not None:
parts.append(f"retrans={opts['retrans']}")
extra = (opts.get("extra") or "").strip()
if extra:
parts.append(extra.lstrip(","))
return ",".join(parts)
def _smb_options(opts: dict) -> str:
parts = []
if opts.get("username"):
parts.append(f"username={opts['username']}")
if opts.get("password") is not None:
parts.append(f"password={opts['password']}")
if opts.get("uid") is not None:
parts.append(f"uid={opts['uid']}")
if opts.get("gid") is not None:
parts.append(f"gid={opts['gid']}")
version = opts.get("vers")
if version:
parts.append(f"vers={version}")
if opts.get("noperm"):
parts.append("noperm")
if opts.get("file_mode"):
parts.append(f"file_mode={opts['file_mode']}")
if opts.get("dir_mode"):
parts.append(f"dir_mode={opts['dir_mode']}")
extra = (opts.get("extra") or "").strip()
if extra:
parts.append(extra.lstrip(","))
return ",".join(parts)
def build_volume_definition(spec: dict) -> dict:
"""Return the top-level `volumes:` declaration for a named/nfs/smb volume.
Returns {} for bind/tmpfs (those live entirely on the service).
"""
vtype: VolumeType = spec["type"]
name = spec.get("volume_name") or spec.get("name")
if vtype == "named":
decl: dict = {"driver": spec.get("driver", "local")}
if spec.get("driver_opts"):
decl["driver_opts"] = spec["driver_opts"]
if not decl.get("driver_opts") and decl["driver"] == "local":
# A plain named volume can be declared as null.
return {name: None}
return {name: decl}
if vtype == "nfs":
o = _nfs_options(spec["nfs_server"], spec.get("options", {}))
device = spec["nfs_path"]
if not device.startswith(":"):
device = ":" + device
return {
name: {
"driver": "local",
"driver_opts": {"type": "nfs", "o": o, "device": device},
}
}
if vtype == "smb":
o = _smb_options(spec.get("options", {}))
device = spec["smb_share"] # //server/share
return {
name: {
"driver": "local",
"driver_opts": {"type": "cifs", "o": o, "device": device},
}
}
return {}
def build_service_mount(spec: dict) -> str | dict:
"""Return the entry to add to a service's `volumes:` list."""
vtype: VolumeType = spec["type"]
if vtype == "bind":
mode = "ro" if not spec.get("options", {}).get("rw", True) else "rw"
return f"{spec['host_path']}:{spec['container_path']}:{mode}"
if vtype == "tmpfs":
return {
"type": "tmpfs",
"target": spec["container_path"],
"tmpfs": {
k: v
for k, v in {
"size": spec.get("options", {}).get("size"),
"mode": spec.get("options", {}).get("mode"),
}.items()
if v is not None
},
}
# named / nfs / smb
name = spec.get("volume_name") or spec.get("name")
return f"{name}:{spec['container_path']}"
def generate_yaml(spec: dict) -> str:
"""Produce a ready-to-insert YAML fragment for the given volume spec.
Includes the top-level `volumes:` block (if any) and a `services:` example
showing the mount, mirroring the wizard preview.
"""
doc: dict = {}
definition = build_volume_definition(spec)
if definition:
doc["volumes"] = definition
service_name = spec.get("service", "service")
doc["services"] = {service_name: {"volumes": [build_service_mount(spec)]}}
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False)
+3
View File
@@ -14,6 +14,9 @@ services:
- ./data:/data
- ${STACKS_HOST_DIR:-./data/stacks}:/opt/stacks
- /proc:/host_proc:ro
# Host devices for GPU/device detection + passthrough (USB/TTY/DRI).
# Read-only; remove if you don't need GPU/device features.
- /dev:/dev:ro
expose:
- "5008"
# Uncomment to expose the API directly (normally proxied by the frontend):
+33
View File
@@ -0,0 +1,33 @@
import api from "./client";
export const editorApi = {
services: (yaml: string) =>
api
.post<{ services: string[] }>("/api/editor/services", { yaml })
.then((r) => r.data.services),
addVolume: (yaml: string, service: string, spec: Record<string, unknown>) =>
api
.post<{ yaml: string }>("/api/editor/add-volume", { yaml, service, spec })
.then((r) => r.data.yaml),
setGpu: (yaml: string, service: string, config: Record<string, unknown>) =>
api
.post<{ yaml: string }>("/api/editor/set-gpu", { yaml, service, config })
.then((r) => r.data.yaml),
addDevice: (yaml: string, service: string, host_path: string, target?: string) =>
api
.post<{ yaml: string }>("/api/editor/add-device", {
yaml,
service,
host_path,
target,
})
.then((r) => r.data.yaml),
setPrivileged: (yaml: string, service: string, value: boolean) =>
api
.post<{ yaml: string }>("/api/editor/set-privileged", {
yaml,
service,
value,
})
.then((r) => r.data.yaml),
};
+3 -1
View File
@@ -1,8 +1,10 @@
import api from "./client";
import type { AuditEntry, SystemInfo } from "@/types";
import type { AuditEntry, DeviceList, GPUInfo, SystemInfo } from "@/types";
export const systemApi = {
info: () => api.get<SystemInfo>("/api/system/info").then((r) => r.data),
audit: (limit = 10) =>
api.get<AuditEntry[]>(`/api/audit?limit=${limit}`).then((r) => r.data),
gpus: () => api.get<GPUInfo[]>("/api/system/gpus").then((r) => r.data),
devices: () => api.get<DeviceList>("/api/system/devices").then((r) => r.data),
};
+21
View File
@@ -0,0 +1,21 @@
import api from "./client";
import type { HostPathResult, VolumeInfo } from "@/types";
export const volumesApi = {
list: () => api.get<VolumeInfo[]>("/api/volumes").then((r) => r.data),
orphaned: () =>
api.get<VolumeInfo[]>("/api/volumes/orphaned").then((r) => r.data),
remove: (name: string, force = false) =>
api.delete(`/api/volumes/${name}?force=${force}`).then((r) => r.data),
prune: () => api.post("/api/volumes/prune").then((r) => r.data),
generateYaml: (spec: Record<string, unknown>) =>
api
.post<{ yaml: string }>("/api/volumes/generate-yaml", spec)
.then((r) => r.data.yaml),
hostPaths: (path: string, showHidden = false) =>
api
.get<HostPathResult>(
`/api/host/paths?path=${encodeURIComponent(path)}&show_hidden=${showHidden}`
)
.then((r) => r.data),
};
@@ -0,0 +1,97 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Usb, Cable, Plus, ShieldAlert } from "lucide-react";
import { Button, Input } from "@/components/ui";
import { systemApi } from "@/api/system";
import type { HostDevice } from "@/types";
export function DevicePanel({
privileged,
onAddDevice,
onTogglePrivileged,
}: {
privileged: boolean;
onAddDevice: (path: string) => void;
onTogglePrivileged: (value: boolean) => void;
}) {
const { data } = useQuery({ queryKey: ["devices"], queryFn: systemApi.devices });
const [custom, setCustom] = useState("");
const Section = ({
title,
icon: Icon,
items,
}: {
title: string;
icon: typeof Usb;
items: HostDevice[];
}) => (
<div className="space-y-1">
<p className="flex items-center gap-1 text-xs font-semibold text-slate-500">
<Icon className="h-3.5 w-3.5" /> {title}
</p>
{items.length === 0 ? (
<p className="text-xs text-slate-400">none detected</p>
) : (
items.map((d) => (
<div
key={d.path}
className="flex items-center justify-between rounded border border-slate-200 px-2 py-1 text-xs dark:border-slate-700"
>
<span className="min-w-0">
<span className="font-mono">{d.path}</span>
{d.name && <span className="ml-1 text-slate-500"> {d.name}</span>}
</span>
<button
onClick={() => onAddDevice(d.path)}
className="rounded p-1 text-accent hover:bg-slate-100 dark:hover:bg-slate-700"
title="Add device"
>
<Plus className="h-4 w-4" />
</button>
</div>
))
)}
</div>
);
return (
<div className="space-y-3">
<Section title="USB devices" icon={Usb} items={data?.usb ?? []} />
<Section title="Serial / TTY" icon={Cable} items={data?.tty ?? []} />
<Section title="GPU render nodes" icon={Cable} items={data?.dri ?? []} />
<div className="space-y-1">
<p className="text-xs font-semibold text-slate-500">Custom device path</p>
<div className="flex gap-2">
<Input
value={custom}
onChange={(e) => setCustom(e.target.value)}
placeholder="/dev/ttyUSB0"
/>
<Button
variant="outline"
onClick={() => {
if (custom.trim()) {
onAddDevice(custom.trim());
setCustom("");
}
}}
>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
</div>
<label className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 p-2 text-sm text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
<input
type="checkbox"
checked={privileged}
onChange={(e) => onTogglePrivileged(e.target.checked)}
/>
<ShieldAlert className="h-4 w-4" />
privileged mode (full host device access use with care)
</label>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Cpu, Check as CheckIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { systemApi } from "@/api/system";
import type { GPUInfo } from "@/types";
type Mode = "none" | "nvidia" | "dri";
export function GPUSelector({
onApply,
}: {
onApply: (config: Record<string, unknown>) => void;
}) {
const { data: gpus } = useQuery({ queryKey: ["gpus"], queryFn: systemApi.gpus });
const [mode, setMode] = useState<Mode>("none");
// nvidia
const [useAll, setUseAll] = useState(true);
const [deviceId, setDeviceId] = useState<string>("");
const [caps, setCaps] = useState<string[]>(["gpu"]);
// dri
const [vendor, setVendor] = useState<"intel" | "amd">("intel");
const [renderGroup, setRenderGroup] = useState(true);
const [videoGroup, setVideoGroup] = useState(false);
const [libva, setLibva] = useState(false);
const nvidia = (gpus ?? []).filter((g) => g.vendor === "nvidia");
const dri = (gpus ?? []).filter((g) => g.vendor !== "nvidia");
const toggleCap = (c: string) =>
setCaps((prev) => (prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]));
const apply = () => {
if (mode === "none") return onApply({ mode: "none" });
if (mode === "nvidia")
return onApply({
mode: "nvidia",
...(useAll || !deviceId ? { count: 1 } : { device_ids: [deviceId] }),
capabilities: caps,
});
return onApply({
mode: "dri",
vendor,
add_render_group: renderGroup,
add_video_group: videoGroup,
set_libva: libva,
});
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Cpu className="h-4 w-4" /> GPU access
</div>
<div className="flex gap-2">
{(["none", "nvidia", "dri"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={
mode === m
? "rounded-lg bg-accent px-3 py-1.5 text-sm text-white dark:bg-accent-dark dark:text-slate-900"
: "rounded-lg border border-slate-300 px-3 py-1.5 text-sm dark:border-slate-600"
}
>
{m === "dri" ? "AMD / Intel" : m === "none" ? "None" : "NVIDIA"}
</button>
))}
</div>
{gpus && gpus.length > 0 ? (
<p className="text-xs text-slate-500">
Detected: {gpus.map((g: GPUInfo) => g.name).join(", ")}
</p>
) : (
<p className="text-xs text-slate-400">
No GPUs detected on host (passthrough still configurable manually).
</p>
)}
{mode === "nvidia" && (
<div className="space-y-2 rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={useAll} onChange={(e) => setUseAll(e.target.checked)} />
Use all / count 1
</label>
{!useAll && (
<select
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
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"
>
<option value="">Select GPU UUID</option>
{nvidia.map((g) => (
<option key={g.uuid ?? g.index} value={g.uuid ?? ""}>
{g.name} ({g.uuid})
</option>
))}
</select>
)}
<div className="flex flex-wrap gap-3 text-sm">
{["gpu", "compute", "video", "utility"].map((c) => (
<label key={c} className="flex items-center gap-1">
<input type="checkbox" checked={caps.includes(c)} onChange={() => toggleCap(c)} />
{c}
</label>
))}
</div>
</div>
)}
{mode === "dri" && (
<div className="space-y-2 rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="flex gap-2">
{(["intel", "amd"] as const).map((v) => (
<button
key={v}
onClick={() => setVendor(v)}
className={
vendor === v
? "rounded bg-slate-200 px-2 py-1 text-xs dark:bg-slate-600"
: "rounded border border-slate-300 px-2 py-1 text-xs dark:border-slate-600"
}
>
{v.toUpperCase()}
</button>
))}
</div>
{dri.length > 0 && (
<p className="text-xs text-slate-500">
{dri.map((g) => `${g.name}${g.device_path}`).join(", ")}
</p>
)}
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={renderGroup} onChange={(e) => setRenderGroup(e.target.checked)} /> add render group
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={videoGroup} onChange={(e) => setVideoGroup(e.target.checked)} /> add video group
</label>
{vendor === "intel" && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={libva} onChange={(e) => setLibva(e.target.checked)} /> set LIBVA_DRIVER_NAME=iHD
</label>
)}
</div>
)}
<Button onClick={apply}>
<CheckIcon className="h-4 w-4" /> Apply to YAML
</Button>
</div>
);
}
@@ -0,0 +1,136 @@
import { useEffect, useState } from "react";
import { RefreshCw, HardDrive, Cpu, Plug } from "lucide-react";
import { VolumeWizard } from "@/components/volumes/VolumeWizard";
import { GPUSelector } from "@/components/gpu/GPUSelector";
import { DevicePanel } from "@/components/gpu/DevicePanel";
import { editorApi } from "@/api/editor";
import { apiErrorMessage } from "@/api/client";
import { toast } from "sonner";
type Tab = "volumes" | "gpu" | "devices";
export function EditorHelperPanel({
yaml,
onYaml,
}: {
yaml: string;
onYaml: (next: string) => void;
}) {
const [services, setServices] = useState<string[]>([]);
const [service, setService] = useState<string>("");
const [tab, setTab] = useState<Tab>("volumes");
const [privileged, setPrivileged] = useState(false);
const loadServices = async () => {
try {
const svc = await editorApi.services(yaml);
setServices(svc);
setService((cur) => (cur && svc.includes(cur) ? cur : svc[0] ?? ""));
} catch (e) {
/* invalid yaml while typing — ignore */
}
};
useEffect(() => {
loadServices();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const guard = (): string | null => {
if (!service) {
toast.error("Add/select a service first (check YAML is valid, then ↻)");
return null;
}
return service;
};
const run = async (fn: () => Promise<string>) => {
try {
onYaml(await fn());
toast.success("Applied to YAML");
} catch (e) {
toast.error(apiErrorMessage(e));
}
};
return (
<div className="flex h-full flex-col">
{/* Service picker */}
<div className="flex items-center gap-2 border-b border-slate-200 p-2 dark:border-slate-700">
<span className="text-xs text-slate-500">Service</span>
<select
value={service}
onChange={(e) => setService(e.target.value)}
className="flex-1 rounded-lg border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-800"
>
{services.length === 0 && <option value="">(none)</option>}
{services.map((s) => (
<option key={s}>{s}</option>
))}
</select>
<button
onClick={loadServices}
title="Reload services from YAML"
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-slate-200 dark:border-slate-700">
{([
["volumes", "Volumes", HardDrive],
["gpu", "GPU", Cpu],
["devices", "Devices", Plug],
] as [Tab, string, typeof Cpu][]).map(([id, label, Icon]) => (
<button
key={id}
onClick={() => setTab(id)}
className={
tab === id
? "flex flex-1 items-center justify-center gap-1 border-b-2 border-accent py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
: "flex flex-1 items-center justify-center gap-1 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}
>
<Icon className="h-4 w-4" /> {label}
</button>
))}
</div>
<div className="flex-1 overflow-auto p-3">
{tab === "volumes" && (
<VolumeWizard
service={service}
onApply={(spec) => {
if (!guard()) return;
run(() => editorApi.addVolume(yaml, service, spec));
}}
/>
)}
{tab === "gpu" && (
<GPUSelector
onApply={(config) => {
if (!guard()) return;
run(() => editorApi.setGpu(yaml, service, config));
}}
/>
)}
{tab === "devices" && (
<DevicePanel
privileged={privileged}
onAddDevice={(path) => {
if (!guard()) return;
run(() => editorApi.addDevice(yaml, service, path));
}}
onTogglePrivileged={(value) => {
if (!guard()) return;
setPrivileged(value);
run(() => editorApi.setPrivileged(yaml, service, value));
}}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { Folder, File as FileIcon, ArrowUp, CornerDownLeft } from "lucide-react";
import { volumesApi } from "@/api/volumes";
import { apiErrorMessage } from "@/api/client";
import { Button } from "@/components/ui";
import type { HostPathResult } from "@/types";
export function HostPathBrowser({
onPick,
}: {
onPick: (path: string) => void;
}) {
const [data, setData] = useState<HostPathResult | null>(null);
const [path, setPath] = useState("/");
const [error, setError] = useState<string | null>(null);
const load = (p: string) => {
volumesApi
.hostPaths(p)
.then((d) => {
setData(d);
setPath(d.path);
setError(null);
})
.catch((e) => setError(apiErrorMessage(e)));
};
useEffect(() => {
load("/");
}, []);
return (
<div className="rounded-lg border border-slate-200 dark:border-slate-700">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 p-2 dark:border-slate-700">
{data?.roots.map((r) => (
<button
key={r}
onClick={() => load(r)}
className="rounded bg-slate-100 px-2 py-0.5 text-xs hover:bg-slate-200 dark:bg-slate-700 dark:hover:bg-slate-600"
>
{r}
</button>
))}
<span className="ml-auto font-mono text-xs text-slate-500">{path}</span>
</div>
<div className="flex items-center gap-2 border-b border-slate-200 p-2 dark:border-slate-700">
<Button
variant="outline"
onClick={() => data?.parent && load(data.parent)}
disabled={!data?.parent}
>
<ArrowUp className="h-4 w-4" /> Up
</Button>
<Button variant="primary" onClick={() => onPick(path)}>
<CornerDownLeft className="h-4 w-4" /> Use this folder
</Button>
</div>
{error && <p className="p-2 text-xs text-red-500">{error}</p>}
<div className="max-h-56 overflow-auto p-1">
{data?.entries.length === 0 && (
<p className="p-2 text-xs text-slate-500">Empty directory.</p>
)}
{data?.entries.map((e) => (
<button
key={e.name}
disabled={e.type !== "dir"}
onClick={() => e.type === "dir" && load(`${path === "/" ? "" : path}/${e.name}`)}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm enabled:hover:bg-slate-100 disabled:opacity-50 dark:enabled:hover:bg-slate-700"
>
{e.type === "dir" ? (
<Folder className="h-4 w-4 text-sky-500" />
) : (
<FileIcon className="h-4 w-4 text-slate-400" />
)}
<span className="truncate">{e.name}</span>
<span className="ml-auto font-mono text-[10px] text-slate-400">
{e.permissions}
</span>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,276 @@
import { useState } from "react";
import {
FolderOpen,
Package,
Globe,
Network,
Zap,
Eye,
Plus,
} from "lucide-react";
import { Button, Input } from "@/components/ui";
import { HostPathBrowser } from "./HostPathBrowser";
import { volumesApi } from "@/api/volumes";
import { apiErrorMessage } from "@/api/client";
import { toast } from "sonner";
type VType = "bind" | "named" | "nfs" | "smb" | "tmpfs";
const TYPES: { id: VType; label: string; icon: typeof FolderOpen }[] = [
{ id: "bind", label: "Bind Mount", icon: FolderOpen },
{ id: "named", label: "Named Volume", icon: Package },
{ id: "nfs", label: "NFS Share", icon: Globe },
{ id: "smb", label: "SMB / CIFS", icon: Network },
{ id: "tmpfs", label: "tmpfs", icon: Zap },
];
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">{label}</span>
{children}
</label>
);
}
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-accent"
/>
{label}
</label>
);
}
export function VolumeWizard({
service,
onApply,
}: {
service: string;
onApply: (spec: Record<string, unknown>) => void;
}) {
const [type, setType] = useState<VType | null>(null);
const [preview, setPreview] = useState<string | null>(null);
// shared
const [containerPath, setContainerPath] = useState("");
const [volumeName, setVolumeName] = useState("");
const [rw, setRw] = useState(true);
// bind
const [hostPath, setHostPath] = useState("");
const [showBrowser, setShowBrowser] = useState(false);
// nfs
const [nfsServer, setNfsServer] = useState("");
const [nfsPath, setNfsPath] = useState("");
const [nfsVers, setNfsVers] = useState("4.1");
const [soft, setSoft] = useState(true);
const [nolock, setNolock] = useState(false);
const [noatime, setNoatime] = useState(false);
const [timeo, setTimeo] = useState("30");
// smb
const [smbShare, setSmbShare] = useState("");
const [smbUser, setSmbUser] = useState("");
const [smbPass, setSmbPass] = useState("");
const [uid, setUid] = useState("1000");
const [gid, setGid] = useState("1000");
const [smbVers, setSmbVers] = useState("3.0");
// tmpfs
const [size, setSize] = useState("256m");
const [mode, setMode] = useState("1777");
const buildSpec = (): Record<string, unknown> | null => {
if (!type) return null;
const base: Record<string, unknown> = { type, service, container_path: containerPath };
if (type === "bind") return { ...base, host_path: hostPath, options: { rw } };
if (type === "named")
return { ...base, volume_name: volumeName || "data", options: { rw } };
if (type === "nfs")
return {
...base,
volume_name: volumeName || "nfs_volume",
nfs_server: nfsServer,
nfs_path: nfsPath,
options: { nfsvers: nfsVers, rw, soft, nolock, noatime, timeo: Number(timeo) },
};
if (type === "smb")
return {
...base,
volume_name: volumeName || "smb_volume",
smb_share: smbShare,
options: {
username: smbUser,
password: smbPass,
uid: Number(uid),
gid: Number(gid),
vers: smbVers,
},
};
if (type === "tmpfs") return { ...base, options: { size, mode } };
return base;
};
const doPreview = async () => {
const spec = buildSpec();
if (!spec) return;
try {
setPreview(await volumesApi.generateYaml(spec));
} catch (e) {
toast.error(apiErrorMessage(e));
}
};
const apply = () => {
const spec = buildSpec();
if (!spec) return;
if (!containerPath) {
toast.error("Container path is required");
return;
}
onApply(spec);
setType(null);
setPreview(null);
};
if (!type) {
return (
<div className="grid grid-cols-2 gap-2">
{TYPES.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setType(id)}
className="flex flex-col items-center gap-2 rounded-lg border border-slate-200 p-4 text-sm hover:border-accent hover:bg-accent/5 dark:border-slate-700 dark:hover:border-accent-dark"
>
<Icon className="h-6 w-6 text-accent dark:text-accent-dark" />
{label}
</button>
))}
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold capitalize">{type} volume</span>
<button
onClick={() => {
setType(null);
setPreview(null);
}}
className="text-xs text-slate-500 hover:underline"
>
change type
</button>
</div>
{(type === "named" || type === "nfs" || type === "smb") && (
<Field label="Volume name">
<Input value={volumeName} onChange={(e) => setVolumeName(e.target.value)} placeholder="auto" />
</Field>
)}
<Field label="Container mount path">
<Input value={containerPath} onChange={(e) => setContainerPath(e.target.value)} placeholder="/data" />
</Field>
{type === "bind" && (
<>
<Field label="Host path">
<div className="flex gap-2">
<Input value={hostPath} onChange={(e) => setHostPath(e.target.value)} placeholder="/srv/appdata" />
<Button variant="outline" onClick={() => setShowBrowser((v) => !v)}>
Browse
</Button>
</div>
</Field>
{showBrowser && (
<HostPathBrowser
onPick={(p) => {
setHostPath(p);
setShowBrowser(false);
}}
/>
)}
<Check label="Read/Write" checked={rw} onChange={setRw} />
</>
)}
{type === "nfs" && (
<>
<div className="grid grid-cols-2 gap-2">
<Field label="NFS server"><Input value={nfsServer} onChange={(e) => setNfsServer(e.target.value)} placeholder="10.10.1.80" /></Field>
<Field label="Export path"><Input value={nfsPath} onChange={(e) => setNfsPath(e.target.value)} placeholder="/mnt/media" /></Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="NFS version">
<select value={nfsVers} onChange={(e) => setNfsVers(e.target.value)} 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">
{["3", "4", "4.1", "4.2"].map((v) => <option key={v}>{v}</option>)}
</select>
</Field>
<Field label="timeo (1/10s)"><Input value={timeo} onChange={(e) => setTimeo(e.target.value)} /></Field>
</div>
<div className="flex flex-wrap gap-4">
<Check label="rw" checked={rw} onChange={setRw} />
<Check label="soft" checked={soft} onChange={setSoft} />
<Check label="nolock" checked={nolock} onChange={setNolock} />
<Check label="noatime" checked={noatime} onChange={setNoatime} />
</div>
</>
)}
{type === "smb" && (
<>
<Field label="Share (//server/share)"><Input value={smbShare} onChange={(e) => setSmbShare(e.target.value)} placeholder="//nas.local/media" /></Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Username"><Input value={smbUser} onChange={(e) => setSmbUser(e.target.value)} /></Field>
<Field label="Password"><Input type="password" value={smbPass} onChange={(e) => setSmbPass(e.target.value)} /></Field>
</div>
<div className="grid grid-cols-3 gap-2">
<Field label="UID"><Input value={uid} onChange={(e) => setUid(e.target.value)} /></Field>
<Field label="GID"><Input value={gid} onChange={(e) => setGid(e.target.value)} /></Field>
<Field label="vers"><Input value={smbVers} onChange={(e) => setSmbVers(e.target.value)} /></Field>
</div>
</>
)}
{type === "tmpfs" && (
<div className="grid grid-cols-2 gap-2">
<Field label="Size"><Input value={size} onChange={(e) => setSize(e.target.value)} placeholder="256m" /></Field>
<Field label="Mode"><Input value={mode} onChange={(e) => setMode(e.target.value)} placeholder="1777" /></Field>
</div>
)}
{preview && (
<pre className="max-h-40 overflow-auto rounded-lg bg-slate-950 p-2 font-mono text-[11px] text-slate-200">
{preview}
</pre>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={doPreview}>
<Eye className="h-4 w-4" /> Preview
</Button>
<Button onClick={apply}>
<Plus className="h-4 w-4" /> Add volume
</Button>
</div>
</div>
);
}
+30 -19
View File
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import Editor from "@monaco-editor/react";
import { Rocket, Save, Wand2, FileCode } from "lucide-react";
import { Button, Card, Input } from "@/components/ui";
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
import { useThemeStore } from "@/store/theme";
@@ -131,25 +132,35 @@ export function StackEditor() {
</TabBtn>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
{tab === "compose" ? (
<Editor
height="100%"
language="yaml"
theme={theme === "dark" ? "vs-dark" : "light"}
value={yaml}
onChange={(v) => setYaml(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
/>
) : (
<Editor
height="100%"
language="ini"
theme={theme === "dark" ? "vs-dark" : "light"}
value={env}
onChange={(v) => setEnv(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13 }}
/>
<div className="flex min-h-0 flex-1 gap-3">
{/* Editor */}
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
{tab === "compose" ? (
<Editor
height="100%"
language="yaml"
theme={theme === "dark" ? "vs-dark" : "light"}
value={yaml}
onChange={(v) => setYaml(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
/>
) : (
<Editor
height="100%"
language="ini"
theme={theme === "dark" ? "vs-dark" : "light"}
value={env}
onChange={(v) => setEnv(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13 }}
/>
)}
</div>
{/* Helper panel (Volumes / GPU / Devices) */}
{tab === "compose" && (
<div className="w-[38%] min-w-[320px] overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
<EditorHelperPanel yaml={yaml} onYaml={setYaml} />
</div>
)}
</div>
+48
View File
@@ -71,6 +71,54 @@ export interface User {
is_active: boolean;
}
export interface GPUInfo {
vendor: "nvidia" | "amd" | "intel";
index: number;
name: string;
uuid?: string | null;
device_path?: string | null;
driver: string;
vram_mb?: number | null;
}
export interface HostDevice {
path: string;
kind: "usb" | "tty" | "dri" | "other";
name: string;
}
export interface DeviceList {
usb: HostDevice[];
tty: HostDevice[];
dri: HostDevice[];
}
export interface VolumeInfo {
name: string;
driver: string;
mountpoint: string;
created_at?: string;
labels: Record<string, string>;
scope?: string;
stack?: string | null;
used_by: string[];
in_use: boolean;
}
export interface HostPathEntry {
name: string;
type: "dir" | "file";
size?: number | null;
permissions: string;
}
export interface HostPathResult {
path: string;
parent: string | null;
roots: string[];
entries: HostPathEntry[];
}
export interface TokenPair {
access_token: string;
refresh_token: string;