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 —
|
A self-hosted Docker Compose manager for power users and homelab enthusiasts —
|
||||||
as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
||||||
|
|
||||||
> **Status:** Phase 1 (Core) complete. Volume/GPU wizards, image-update checks,
|
> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) complete. Image-update
|
||||||
> templates, multi-host agents and backups land in later phases.
|
> checks, templates, multi-host agents and backups land in later phases.
|
||||||
|
|
||||||
## What works today (Phase 1)
|
## 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.
|
dir containing a compose file) are picked up automatically.
|
||||||
- **Dark / light theme.**
|
- **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
|
## 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}
|
POST /api/stacks/{id}/{start|stop|restart|pull|update|down|clone}
|
||||||
GET /api/stacks/{id}/logs GET /api/stacks/{id}/export
|
GET /api/stacks/{id}/logs GET /api/stacks/{id}/export
|
||||||
POST /api/stacks/convert (docker run → compose)
|
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
|
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
|
## Security notes
|
||||||
|
|
||||||
- The Docker socket is only ever touched by the backend process; it is never
|
- The Docker socket is only ever touched by the backend process; it is never
|
||||||
|
|||||||
+4
-2
@@ -12,7 +12,7 @@ from sqlmodel import Session
|
|||||||
from config import settings
|
from config import settings
|
||||||
from database import engine, init_db
|
from database import engine, init_db
|
||||||
from docker_client import DockerError
|
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)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger("stackpilot")
|
logger = logging.getLogger("stackpilot")
|
||||||
@@ -31,7 +31,7 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.1.1", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.2.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -53,6 +53,8 @@ async def docker_error_handler(_request: Request, exc: DockerError):
|
|||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(stacks.router)
|
app.include_router(stacks.router)
|
||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
|
app.include_router(volumes.router)
|
||||||
|
app.include_router(editor.router)
|
||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
app.include_router(ws.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 config import settings
|
||||||
from docker_client import DockerError, get_client, safe_call
|
from docker_client import DockerError, get_client, safe_call
|
||||||
from models.user import User
|
from models.user import User
|
||||||
|
from services import device_service, gpu_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||||
|
|
||||||
@@ -88,5 +89,17 @@ def system_info(_user: User = Depends(get_current_user)) -> dict:
|
|||||||
"uptime_seconds": _uptime(),
|
"uptime_seconds": _uptime(),
|
||||||
"containers_running": containers_running,
|
"containers_running": containers_running,
|
||||||
"containers_total": containers_total,
|
"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
|
- ./data:/data
|
||||||
- ${STACKS_HOST_DIR:-./data/stacks}:/opt/stacks
|
- ${STACKS_HOST_DIR:-./data/stacks}:/opt/stacks
|
||||||
- /proc:/host_proc:ro
|
- /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:
|
expose:
|
||||||
- "5008"
|
- "5008"
|
||||||
# Uncomment to expose the API directly (normally proxied by the frontend):
|
# 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 api from "./client";
|
||||||
import type { AuditEntry, SystemInfo } from "@/types";
|
import type { AuditEntry, DeviceList, GPUInfo, SystemInfo } from "@/types";
|
||||||
|
|
||||||
export const systemApi = {
|
export const systemApi = {
|
||||||
info: () => api.get<SystemInfo>("/api/system/info").then((r) => r.data),
|
info: () => api.get<SystemInfo>("/api/system/info").then((r) => r.data),
|
||||||
audit: (limit = 10) =>
|
audit: (limit = 10) =>
|
||||||
api.get<AuditEntry[]>(`/api/audit?limit=${limit}`).then((r) => r.data),
|
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 Editor from "@monaco-editor/react";
|
||||||
import { Rocket, Save, Wand2, FileCode } from "lucide-react";
|
import { Rocket, Save, Wand2, FileCode } from "lucide-react";
|
||||||
import { Button, Card, Input } from "@/components/ui";
|
import { Button, Card, Input } from "@/components/ui";
|
||||||
|
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { useThemeStore } from "@/store/theme";
|
import { useThemeStore } from "@/store/theme";
|
||||||
@@ -131,25 +132,35 @@ export function StackEditor() {
|
|||||||
</TabBtn>
|
</TabBtn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
|
<div className="flex min-h-0 flex-1 gap-3">
|
||||||
{tab === "compose" ? (
|
{/* Editor */}
|
||||||
<Editor
|
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
height="100%"
|
{tab === "compose" ? (
|
||||||
language="yaml"
|
<Editor
|
||||||
theme={theme === "dark" ? "vs-dark" : "light"}
|
height="100%"
|
||||||
value={yaml}
|
language="yaml"
|
||||||
onChange={(v) => setYaml(v ?? "")}
|
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||||
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
|
value={yaml}
|
||||||
/>
|
onChange={(v) => setYaml(v ?? "")}
|
||||||
) : (
|
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
|
||||||
<Editor
|
/>
|
||||||
height="100%"
|
) : (
|
||||||
language="ini"
|
<Editor
|
||||||
theme={theme === "dark" ? "vs-dark" : "light"}
|
height="100%"
|
||||||
value={env}
|
language="ini"
|
||||||
onChange={(v) => setEnv(v ?? "")}
|
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||||
options={{ minimap: { enabled: false }, fontSize: 13 }}
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,54 @@ export interface User {
|
|||||||
is_active: boolean;
|
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 {
|
export interface TokenPair {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user