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:
co-authored by
Claude Opus 4.8
parent
7775128c07
commit
b553c1b861
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user