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
+33
View File
@@ -0,0 +1,33 @@
import api from "./client";
export const editorApi = {
services: (yaml: string) =>
api
.post<{ services: string[] }>("/api/editor/services", { yaml })
.then((r) => r.data.services),
addVolume: (yaml: string, service: string, spec: Record<string, unknown>) =>
api
.post<{ yaml: string }>("/api/editor/add-volume", { yaml, service, spec })
.then((r) => r.data.yaml),
setGpu: (yaml: string, service: string, config: Record<string, unknown>) =>
api
.post<{ yaml: string }>("/api/editor/set-gpu", { yaml, service, config })
.then((r) => r.data.yaml),
addDevice: (yaml: string, service: string, host_path: string, target?: string) =>
api
.post<{ yaml: string }>("/api/editor/add-device", {
yaml,
service,
host_path,
target,
})
.then((r) => r.data.yaml),
setPrivileged: (yaml: string, service: string, value: boolean) =>
api
.post<{ yaml: string }>("/api/editor/set-privileged", {
yaml,
service,
value,
})
.then((r) => r.data.yaml),
};
+3 -1
View File
@@ -1,8 +1,10 @@
import api from "./client";
import type { AuditEntry, SystemInfo } from "@/types";
import type { AuditEntry, DeviceList, GPUInfo, SystemInfo } from "@/types";
export const systemApi = {
info: () => api.get<SystemInfo>("/api/system/info").then((r) => r.data),
audit: (limit = 10) =>
api.get<AuditEntry[]>(`/api/audit?limit=${limit}`).then((r) => r.data),
gpus: () => api.get<GPUInfo[]>("/api/system/gpus").then((r) => r.data),
devices: () => api.get<DeviceList>("/api/system/devices").then((r) => r.data),
};
+21
View File
@@ -0,0 +1,21 @@
import api from "./client";
import type { HostPathResult, VolumeInfo } from "@/types";
export const volumesApi = {
list: () => api.get<VolumeInfo[]>("/api/volumes").then((r) => r.data),
orphaned: () =>
api.get<VolumeInfo[]>("/api/volumes/orphaned").then((r) => r.data),
remove: (name: string, force = false) =>
api.delete(`/api/volumes/${name}?force=${force}`).then((r) => r.data),
prune: () => api.post("/api/volumes/prune").then((r) => r.data),
generateYaml: (spec: Record<string, unknown>) =>
api
.post<{ yaml: string }>("/api/volumes/generate-yaml", spec)
.then((r) => r.data.yaml),
hostPaths: (path: string, showHidden = false) =>
api
.get<HostPathResult>(
`/api/host/paths?path=${encodeURIComponent(path)}&show_hidden=${showHidden}`
)
.then((r) => r.data),
};
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
+30 -19
View File
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import Editor from "@monaco-editor/react";
import { Rocket, Save, Wand2, FileCode } from "lucide-react";
import { Button, Card, Input } from "@/components/ui";
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
import { useThemeStore } from "@/store/theme";
@@ -131,25 +132,35 @@ export function StackEditor() {
</TabBtn>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
{tab === "compose" ? (
<Editor
height="100%"
language="yaml"
theme={theme === "dark" ? "vs-dark" : "light"}
value={yaml}
onChange={(v) => setYaml(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
/>
) : (
<Editor
height="100%"
language="ini"
theme={theme === "dark" ? "vs-dark" : "light"}
value={env}
onChange={(v) => setEnv(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13 }}
/>
<div className="flex min-h-0 flex-1 gap-3">
{/* Editor */}
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
{tab === "compose" ? (
<Editor
height="100%"
language="yaml"
theme={theme === "dark" ? "vs-dark" : "light"}
value={yaml}
onChange={(v) => setYaml(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
/>
) : (
<Editor
height="100%"
language="ini"
theme={theme === "dark" ? "vs-dark" : "light"}
value={env}
onChange={(v) => setEnv(v ?? "")}
options={{ minimap: { enabled: false }, fontSize: 13 }}
/>
)}
</div>
{/* Helper panel (Volumes / GPU / Devices) */}
{tab === "compose" && (
<div className="w-[38%] min-w-[320px] overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
<EditorHelperPanel yaml={yaml} onYaml={setYaml} />
</div>
)}
</div>
+48
View File
@@ -71,6 +71,54 @@ export interface User {
is_active: boolean;
}
export interface GPUInfo {
vendor: "nvidia" | "amd" | "intel";
index: number;
name: string;
uuid?: string | null;
device_path?: string | null;
driver: string;
vram_mb?: number | null;
}
export interface HostDevice {
path: string;
kind: "usb" | "tty" | "dri" | "other";
name: string;
}
export interface DeviceList {
usb: HostDevice[];
tty: HostDevice[];
dri: HostDevice[];
}
export interface VolumeInfo {
name: string;
driver: string;
mountpoint: string;
created_at?: string;
labels: Record<string, string>;
scope?: string;
stack?: string | null;
used_by: string[];
in_use: boolean;
}
export interface HostPathEntry {
name: string;
type: "dir" | "file";
size?: number | null;
permissions: string;
}
export interface HostPathResult {
path: string;
parent: string | null;
roots: string[];
entries: HostPathEntry[];
}
export interface TokenPair {
access_token: string;
refresh_token: string;