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