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>
);
}
@@ -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>
);
}