diff --git a/README.md b/README.md index db888c7..a09df2c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/main.py b/backend/main.py index 0082136..3292ca5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/routers/editor.py b/backend/routers/editor.py new file mode 100644 index 0000000..50bf2d1 --- /dev/null +++ b/backend/routers/editor.py @@ -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) diff --git a/backend/routers/system.py b/backend/routers/system.py index 13984cc..a46f90e 100644 --- a/backend/routers/system.py +++ b/backend/routers/system.py @@ -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() diff --git a/backend/routers/volumes.py b/backend/routers/volumes.py new file mode 100644 index 0000000..da77cb9 --- /dev/null +++ b/backend/routers/volumes.py @@ -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 diff --git a/backend/services/compose_edit_service.py b/backend/services/compose_edit_service.py new file mode 100644 index 0000000..d69b594 --- /dev/null +++ b/backend/services/compose_edit_service.py @@ -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()) diff --git a/backend/services/device_service.py b/backend/services/device_service.py new file mode 100644 index 0000000..ffb4446 --- /dev/null +++ b/backend/services/device_service.py @@ -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// + 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, + } diff --git a/backend/services/gpu_service.py b/backend/services/gpu_service.py new file mode 100644 index 0000000..c91ad47 --- /dev/null +++ b/backend/services/gpu_service.py @@ -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 diff --git a/backend/services/volume_service.py b/backend/services/volume_service.py new file mode 100644 index 0000000..65ea159 --- /dev/null +++ b/backend/services/volume_service.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 8ea71a0..3654349 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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): diff --git a/frontend/src/api/editor.ts b/frontend/src/api/editor.ts new file mode 100644 index 0000000..846dc62 --- /dev/null +++ b/frontend/src/api/editor.ts @@ -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) => + api + .post<{ yaml: string }>("/api/editor/add-volume", { yaml, service, spec }) + .then((r) => r.data.yaml), + setGpu: (yaml: string, service: string, config: Record) => + 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), +}; diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 9509245..eb99aa7 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -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("/api/system/info").then((r) => r.data), audit: (limit = 10) => api.get(`/api/audit?limit=${limit}`).then((r) => r.data), + gpus: () => api.get("/api/system/gpus").then((r) => r.data), + devices: () => api.get("/api/system/devices").then((r) => r.data), }; diff --git a/frontend/src/api/volumes.ts b/frontend/src/api/volumes.ts new file mode 100644 index 0000000..bbab39b --- /dev/null +++ b/frontend/src/api/volumes.ts @@ -0,0 +1,21 @@ +import api from "./client"; +import type { HostPathResult, VolumeInfo } from "@/types"; + +export const volumesApi = { + list: () => api.get("/api/volumes").then((r) => r.data), + orphaned: () => + api.get("/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) => + api + .post<{ yaml: string }>("/api/volumes/generate-yaml", spec) + .then((r) => r.data.yaml), + hostPaths: (path: string, showHidden = false) => + api + .get( + `/api/host/paths?path=${encodeURIComponent(path)}&show_hidden=${showHidden}` + ) + .then((r) => r.data), +}; diff --git a/frontend/src/components/gpu/DevicePanel.tsx b/frontend/src/components/gpu/DevicePanel.tsx new file mode 100644 index 0000000..c01de6a --- /dev/null +++ b/frontend/src/components/gpu/DevicePanel.tsx @@ -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[]; + }) => ( +
+

+ {title} +

+ {items.length === 0 ? ( +

none detected

+ ) : ( + items.map((d) => ( +
+ + {d.path} + {d.name && — {d.name}} + + +
+ )) + )} +
+ ); + + return ( +
+
+
+
+ +
+

Custom device path

+
+ setCustom(e.target.value)} + placeholder="/dev/ttyUSB0" + /> + +
+
+ + +
+ ); +} diff --git a/frontend/src/components/gpu/GPUSelector.tsx b/frontend/src/components/gpu/GPUSelector.tsx new file mode 100644 index 0000000..3e05969 --- /dev/null +++ b/frontend/src/components/gpu/GPUSelector.tsx @@ -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) => void; +}) { + const { data: gpus } = useQuery({ queryKey: ["gpus"], queryFn: systemApi.gpus }); + const [mode, setMode] = useState("none"); + + // nvidia + const [useAll, setUseAll] = useState(true); + const [deviceId, setDeviceId] = useState(""); + const [caps, setCaps] = useState(["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 ( +
+
+ GPU access +
+ +
+ {(["none", "nvidia", "dri"] as Mode[]).map((m) => ( + + ))} +
+ + {gpus && gpus.length > 0 ? ( +

+ Detected: {gpus.map((g: GPUInfo) => g.name).join(", ")} +

+ ) : ( +

+ No GPUs detected on host (passthrough still configurable manually). +

+ )} + + {mode === "nvidia" && ( +
+ + {!useAll && ( + + )} +
+ {["gpu", "compute", "video", "utility"].map((c) => ( + + ))} +
+
+ )} + + {mode === "dri" && ( +
+
+ {(["intel", "amd"] as const).map((v) => ( + + ))} +
+ {dri.length > 0 && ( +

+ {dri.map((g) => `${g.name} → ${g.device_path}`).join(", ")} +

+ )} + + + {vendor === "intel" && ( + + )} +
+ )} + + +
+ ); +} diff --git a/frontend/src/components/stacks/EditorHelperPanel.tsx b/frontend/src/components/stacks/EditorHelperPanel.tsx new file mode 100644 index 0000000..8435166 --- /dev/null +++ b/frontend/src/components/stacks/EditorHelperPanel.tsx @@ -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([]); + const [service, setService] = useState(""); + const [tab, setTab] = useState("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) => { + try { + onYaml(await fn()); + toast.success("Applied to YAML"); + } catch (e) { + toast.error(apiErrorMessage(e)); + } + }; + + return ( +
+ {/* Service picker */} +
+ Service + + +
+ + {/* Tabs */} +
+ {([ + ["volumes", "Volumes", HardDrive], + ["gpu", "GPU", Cpu], + ["devices", "Devices", Plug], + ] as [Tab, string, typeof Cpu][]).map(([id, label, Icon]) => ( + + ))} +
+ +
+ {tab === "volumes" && ( + { + if (!guard()) return; + run(() => editorApi.addVolume(yaml, service, spec)); + }} + /> + )} + {tab === "gpu" && ( + { + if (!guard()) return; + run(() => editorApi.setGpu(yaml, service, config)); + }} + /> + )} + {tab === "devices" && ( + { + if (!guard()) return; + run(() => editorApi.addDevice(yaml, service, path)); + }} + onTogglePrivileged={(value) => { + if (!guard()) return; + setPrivileged(value); + run(() => editorApi.setPrivileged(yaml, service, value)); + }} + /> + )} +
+
+ ); +} diff --git a/frontend/src/components/volumes/HostPathBrowser.tsx b/frontend/src/components/volumes/HostPathBrowser.tsx new file mode 100644 index 0000000..11024f5 --- /dev/null +++ b/frontend/src/components/volumes/HostPathBrowser.tsx @@ -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(null); + const [path, setPath] = useState("/"); + const [error, setError] = useState(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 ( +
+
+ {data?.roots.map((r) => ( + + ))} + {path} +
+ +
+ + +
+ + {error &&

{error}

} + +
+ {data?.entries.length === 0 && ( +

Empty directory.

+ )} + {data?.entries.map((e) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/volumes/VolumeWizard.tsx b/frontend/src/components/volumes/VolumeWizard.tsx new file mode 100644 index 0000000..43eeec6 --- /dev/null +++ b/frontend/src/components/volumes/VolumeWizard.tsx @@ -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 ( + + ); +} + +function Check({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} + +export function VolumeWizard({ + service, + onApply, +}: { + service: string; + onApply: (spec: Record) => void; +}) { + const [type, setType] = useState(null); + const [preview, setPreview] = useState(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 | null => { + if (!type) return null; + const base: Record = { 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 ( +
+ {TYPES.map(({ id, label, icon: Icon }) => ( + + ))} +
+ ); + } + + return ( +
+
+ {type} volume + +
+ + {(type === "named" || type === "nfs" || type === "smb") && ( + + setVolumeName(e.target.value)} placeholder="auto" /> + + )} + + + setContainerPath(e.target.value)} placeholder="/data" /> + + + {type === "bind" && ( + <> + +
+ setHostPath(e.target.value)} placeholder="/srv/appdata" /> + +
+
+ {showBrowser && ( + { + setHostPath(p); + setShowBrowser(false); + }} + /> + )} + + + )} + + {type === "nfs" && ( + <> +
+ setNfsServer(e.target.value)} placeholder="10.10.1.80" /> + setNfsPath(e.target.value)} placeholder="/mnt/media" /> +
+
+ + + + setTimeo(e.target.value)} /> +
+
+ + + + +
+ + )} + + {type === "smb" && ( + <> + setSmbShare(e.target.value)} placeholder="//nas.local/media" /> +
+ setSmbUser(e.target.value)} /> + setSmbPass(e.target.value)} /> +
+
+ setUid(e.target.value)} /> + setGid(e.target.value)} /> + setSmbVers(e.target.value)} /> +
+ + )} + + {type === "tmpfs" && ( +
+ setSize(e.target.value)} placeholder="256m" /> + setMode(e.target.value)} placeholder="1777" /> +
+ )} + + {preview && ( +
+          {preview}
+        
+ )} + +
+ + +
+
+ ); +} diff --git a/frontend/src/pages/StackEditor.tsx b/frontend/src/pages/StackEditor.tsx index cd8da4c..bb9bdb9 100644 --- a/frontend/src/pages/StackEditor.tsx +++ b/frontend/src/pages/StackEditor.tsx @@ -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() { -
- {tab === "compose" ? ( - setYaml(v ?? "")} - options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }} - /> - ) : ( - setEnv(v ?? "")} - options={{ minimap: { enabled: false }, fontSize: 13 }} - /> +
+ {/* Editor */} +
+ {tab === "compose" ? ( + setYaml(v ?? "")} + options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }} + /> + ) : ( + setEnv(v ?? "")} + options={{ minimap: { enabled: false }, fontSize: 13 }} + /> + )} +
+ + {/* Helper panel (Volumes / GPU / Devices) */} + {tab === "compose" && ( +
+ +
)}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2bb2482..3fb4cb5 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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; + 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;