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