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,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>
);
}
+156
View File
@@ -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>
);
}