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