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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 16:35:22 +00:00
co-authored by Claude Opus 4.8
parent 7775128c07
commit b553c1b861
20 changed files with 1841 additions and 26 deletions
@@ -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>
);
}