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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user