a9e2dd3552
During refactoring verification found several remaining inconsistencies: API files (kebab-case naming): - Rename config_profiles.ts → config-profiles.ts - Rename tool_definitions.ts → tool-definitions.ts - Update all imports across 8 files Missing Python __init__.py (backend package structure): - Add utils/__init__.py - Add services/__init__.py - Add schemas/__init__.py - Add services/build/__init__.py Test file import fixes (component reorganization fallout): - DashboardPage.test.tsx: import from ./dashboard → ./DashboardPage - ProjectsPage.test.tsx: import from ./projects → ./ProjectsPage - event-toast-bridge.test.tsx: fix relative paths for moved components (../state/events → ../../../state/events, ./toast-rules → ../../toast-rules) - notification-center.test.tsx: fix relative path (../state/notifications → ../../../state/notifications) Quality gates: tsc --noEmit (pass), build (pass), py_compile (pass) Tests: 9/12 test files pass (3 pre-existing UI test failures unrelated to refactoring)
859 lines
24 KiB
TypeScript
859 lines
24 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { Icon } from "../../icon";
|
|
import { extractErrorMessage } from "../../../utils/errors";
|
|
import {
|
|
compileToolDefinition,
|
|
type ToolDefinitionManifest,
|
|
} from "../../../api/tool-definitions";
|
|
|
|
interface PackageEntry {
|
|
name: string;
|
|
}
|
|
|
|
interface MountEntry {
|
|
name: string;
|
|
target: string;
|
|
source_type: string;
|
|
writable: boolean;
|
|
owner: string;
|
|
mode: string;
|
|
file_mode: string;
|
|
readonly: boolean;
|
|
git_mount_ref: string;
|
|
}
|
|
|
|
interface ManifestEditorProps {
|
|
manifest: Record<string, unknown> | null;
|
|
baseDefinitions: ToolDefinitionManifest[];
|
|
onChange: (manifest: Record<string, unknown>) => void;
|
|
definitionId?: string | null;
|
|
}
|
|
|
|
export const ManifestEditor = ({
|
|
manifest,
|
|
baseDefinitions,
|
|
onChange,
|
|
definitionId,
|
|
}: ManifestEditorProps) => {
|
|
const [baseImage, setBaseImage] = useState("");
|
|
const [baseDefinitionId, setBaseDefinitionId] = useState("");
|
|
const [aptPackages, setAptPackages] = useState<PackageEntry[]>([]);
|
|
const [npmPackages, setNpmPackages] = useState<PackageEntry[]>([]);
|
|
const [pipPackages, setPipPackages] = useState<PackageEntry[]>([]);
|
|
const [nodeVersion, setNodeVersion] = useState("");
|
|
const [userName, setUserName] = useState("user");
|
|
const [userUid, setUserUid] = useState("1000");
|
|
const [userGid, setUserGid] = useState("1000");
|
|
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]);
|
|
const [buildScripts, setBuildScripts] = useState<string[]>([""]);
|
|
const [startupScripts, setStartupScripts] = useState<string[]>([""]);
|
|
const [mounts, setMounts] = useState<MountEntry[]>([]);
|
|
const [command, setCommand] = useState<string[]>([""]);
|
|
const [workingDir, setWorkingDir] = useState("/workspace");
|
|
const [stdinOpen, setStdinOpen] = useState(true);
|
|
const [tty, setTty] = useState(true);
|
|
|
|
const [preview, setPreview] = useState<{
|
|
dockerfile: string;
|
|
compose: string;
|
|
entrypoint: string;
|
|
} | null>(null);
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
|
|
// Load manifest into form
|
|
useEffect(() => {
|
|
if (!manifest) return;
|
|
|
|
const pkgs = (manifest.packages as Record<string, unknown>) || {};
|
|
setBaseImage((manifest.base_image as string) || "");
|
|
setBaseDefinitionId((manifest.base_definition_id as string) || "");
|
|
setAptPackages(((pkgs.apt as string[]) || []).map((p) => ({ name: p })));
|
|
setNpmPackages(
|
|
((pkgs.npm_global as string[]) || []).map((p) => ({ name: p })),
|
|
);
|
|
setPipPackages(((pkgs.pip as string[]) || []).map((p) => ({ name: p })));
|
|
setNodeVersion((pkgs.node as Record<string, string>)?.version || "");
|
|
|
|
const user = (manifest.user as Record<string, unknown>) || {};
|
|
setUserName((user.name as string) || "user");
|
|
setUserUid(String(user.uid || "1000"));
|
|
setUserGid(String(user.gid || "1000"));
|
|
|
|
const env = (manifest.env as Record<string, string>) || {};
|
|
setEnvVars(Object.entries(env).map(([key, value]) => ({ key, value })));
|
|
|
|
const scripts = (manifest.scripts as Record<string, string[]>) || {};
|
|
setBuildScripts((scripts.build || []).length > 0 ? scripts.build : [""]);
|
|
setStartupScripts(
|
|
(scripts.startup || []).length > 0 ? scripts.startup : [""],
|
|
);
|
|
|
|
const mts = (manifest.mounts as MountEntry[]) || [];
|
|
setMounts(mts);
|
|
|
|
const runtime = (manifest.runtime as Record<string, unknown>) || {};
|
|
setCommand((runtime.command as string[]) || [""]);
|
|
setWorkingDir((runtime.working_dir as string) || "/workspace");
|
|
setStdinOpen((runtime.stdin_open as boolean) ?? true);
|
|
setTty((runtime.tty as boolean) ?? true);
|
|
}, [manifest]);
|
|
|
|
// Build manifest from form state
|
|
const buildManifest = useCallback((): Record<string, unknown> => {
|
|
const packages: Record<string, unknown> = {};
|
|
const apt = aptPackages.map((p) => p.name).filter(Boolean);
|
|
if (apt.length) packages.apt = apt;
|
|
const npm = npmPackages.map((p) => p.name).filter(Boolean);
|
|
if (npm.length) packages.npm_global = npm;
|
|
const pip = pipPackages.map((p) => p.name).filter(Boolean);
|
|
if (pip.length) packages.pip = pip;
|
|
if (nodeVersion) packages.node = { version: nodeVersion };
|
|
|
|
const env: Record<string, string> = {};
|
|
envVars.forEach(({ key, value }) => {
|
|
if (key) env[key] = value;
|
|
});
|
|
|
|
const scripts: Record<string, string[]> = {};
|
|
const build = buildScripts.filter(Boolean);
|
|
if (build.length) scripts.build = build;
|
|
const startup = startupScripts.filter(Boolean);
|
|
if (startup.length) scripts.startup = startup;
|
|
|
|
const mts = mounts.filter((m) => m.name && m.target);
|
|
|
|
const result: Record<string, unknown> = {
|
|
packages,
|
|
user: {
|
|
name: userName,
|
|
uid: parseInt(userUid) || 1000,
|
|
gid: parseInt(userGid) || 1000,
|
|
create_home: true,
|
|
shell: "/bin/bash",
|
|
},
|
|
env,
|
|
scripts,
|
|
mounts: mts,
|
|
runtime: {
|
|
command:
|
|
command.filter(Boolean).length > 0
|
|
? command.filter(Boolean)
|
|
: ["/bin/bash"],
|
|
stdin_open: stdinOpen,
|
|
tty: tty,
|
|
working_dir: workingDir,
|
|
},
|
|
};
|
|
|
|
if (baseImage) result.base_image = baseImage;
|
|
if (baseDefinitionId) result.base_definition_id = baseDefinitionId;
|
|
|
|
return result;
|
|
}, [
|
|
aptPackages,
|
|
npmPackages,
|
|
pipPackages,
|
|
nodeVersion,
|
|
userName,
|
|
userUid,
|
|
userGid,
|
|
envVars,
|
|
buildScripts,
|
|
startupScripts,
|
|
mounts,
|
|
command,
|
|
workingDir,
|
|
stdinOpen,
|
|
tty,
|
|
baseImage,
|
|
baseDefinitionId,
|
|
]);
|
|
|
|
// Notify parent of changes — only when built manifest actually differs
|
|
// from what we last sent, to avoid feedback loops with the manifest prop.
|
|
const lastSentRef = useRef<string>("");
|
|
const onChangeRef = useRef(onChange);
|
|
onChangeRef.current = onChange;
|
|
|
|
useEffect(() => {
|
|
const m = buildManifest();
|
|
const serialized = JSON.stringify(m);
|
|
if (serialized !== lastSentRef.current) {
|
|
lastSentRef.current = serialized;
|
|
onChangeRef.current(m);
|
|
}
|
|
}, [buildManifest]);
|
|
|
|
const handlePreview = async () => {
|
|
if (!definitionId) {
|
|
setPreviewError("Save the tool definition first to preview");
|
|
return;
|
|
}
|
|
setPreviewLoading(true);
|
|
setPreviewError(null);
|
|
try {
|
|
const result = await compileToolDefinition(definitionId);
|
|
setPreview({
|
|
dockerfile: result.dockerfile,
|
|
compose: result.compose,
|
|
entrypoint: result.entrypoint,
|
|
});
|
|
} catch (err) {
|
|
setPreviewError(extractErrorMessage(err));
|
|
} finally {
|
|
setPreviewLoading(false);
|
|
}
|
|
};
|
|
|
|
const addAptPackage = () => setAptPackages([...aptPackages, { name: "" }]);
|
|
const removeAptPackage = (idx: number) =>
|
|
setAptPackages(aptPackages.filter((_, i) => i !== idx));
|
|
const updateAptPackage = (idx: number, name: string) => {
|
|
const copy = [...aptPackages];
|
|
copy[idx] = { name };
|
|
setAptPackages(copy);
|
|
};
|
|
|
|
const addNpmPackage = () => setNpmPackages([...npmPackages, { name: "" }]);
|
|
const removeNpmPackage = (idx: number) =>
|
|
setNpmPackages(npmPackages.filter((_, i) => i !== idx));
|
|
const updateNpmPackage = (idx: number, name: string) => {
|
|
const copy = [...npmPackages];
|
|
copy[idx] = { name };
|
|
setNpmPackages(copy);
|
|
};
|
|
|
|
const addPipPackage = () => setPipPackages([...pipPackages, { name: "" }]);
|
|
const removePipPackage = (idx: number) =>
|
|
setPipPackages(pipPackages.filter((_, i) => i !== idx));
|
|
const updatePipPackage = (idx: number, name: string) => {
|
|
const copy = [...pipPackages];
|
|
copy[idx] = { name };
|
|
setPipPackages(copy);
|
|
};
|
|
|
|
const addEnvVar = () => setEnvVars([...envVars, { key: "", value: "" }]);
|
|
const removeEnvVar = (idx: number) =>
|
|
setEnvVars(envVars.filter((_, i) => i !== idx));
|
|
const updateEnvVar = (idx: number, field: "key" | "value", val: string) => {
|
|
const copy = [...envVars];
|
|
copy[idx] = { ...copy[idx], [field]: val };
|
|
setEnvVars(copy);
|
|
};
|
|
|
|
const addBuildScript = () => setBuildScripts([...buildScripts, ""]);
|
|
const removeBuildScript = (idx: number) =>
|
|
setBuildScripts(buildScripts.filter((_, i) => i !== idx));
|
|
const updateBuildScript = (idx: number, val: string) => {
|
|
const copy = [...buildScripts];
|
|
copy[idx] = val;
|
|
setBuildScripts(copy);
|
|
};
|
|
|
|
const addStartupScript = () => setStartupScripts([...startupScripts, ""]);
|
|
const removeStartupScript = (idx: number) =>
|
|
setStartupScripts(startupScripts.filter((_, i) => i !== idx));
|
|
const updateStartupScript = (idx: number, val: string) => {
|
|
const copy = [...startupScripts];
|
|
copy[idx] = val;
|
|
setStartupScripts(copy);
|
|
};
|
|
|
|
const addMount = () =>
|
|
setMounts([
|
|
...mounts,
|
|
{
|
|
name: "",
|
|
target: "",
|
|
source_type: "repo",
|
|
writable: true,
|
|
owner: "",
|
|
mode: "",
|
|
file_mode: "",
|
|
readonly: false,
|
|
git_mount_ref: "",
|
|
},
|
|
]);
|
|
const removeMount = (idx: number) =>
|
|
setMounts(mounts.filter((_, i) => i !== idx));
|
|
const updateMount = (idx: number, field: keyof MountEntry, val: unknown) => {
|
|
const copy = [...mounts];
|
|
copy[idx] = { ...copy[idx], [field]: val };
|
|
setMounts(copy);
|
|
};
|
|
|
|
return (
|
|
<div className="stack" style={{ gap: "1.5rem" }}>
|
|
{/* Base Image */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Base Image</h4>
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>Base Definition</label>
|
|
<select
|
|
value={baseDefinitionId}
|
|
onChange={(e) => {
|
|
setBaseDefinitionId(e.target.value);
|
|
setBaseImage("");
|
|
}}
|
|
className="form-input"
|
|
>
|
|
<option value="">Custom image...</option>
|
|
{baseDefinitions.map((b) => (
|
|
<option key={b.id} value={b.id}>
|
|
{b.display_name} ({b.version})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>Custom Base Image</label>
|
|
<input
|
|
type="text"
|
|
value={baseImage}
|
|
onChange={(e) => {
|
|
setBaseImage(e.target.value);
|
|
setBaseDefinitionId("");
|
|
}}
|
|
placeholder="e.g., ubuntu:24.04"
|
|
className="form-input"
|
|
disabled={!!baseDefinitionId}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Packages */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Packages</h4>
|
|
|
|
<div className="form-group">
|
|
<label>Node.js Version</label>
|
|
<input
|
|
type="text"
|
|
value={nodeVersion}
|
|
onChange={(e) => setNodeVersion(e.target.value)}
|
|
placeholder="e.g., 20"
|
|
className="form-input"
|
|
style={{ width: "120px" }}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label>APT Packages</label>
|
|
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
|
{aptPackages.map((pkg, idx) => (
|
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
|
<input
|
|
type="text"
|
|
value={pkg.name}
|
|
onChange={(e) => updateAptPackage(idx, e.target.value)}
|
|
placeholder="e.g., neovim"
|
|
className="form-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeAptPackage(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addAptPackage}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add APT Package
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label>NPM Global Packages</label>
|
|
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
|
{npmPackages.map((pkg, idx) => (
|
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
|
<input
|
|
type="text"
|
|
value={pkg.name}
|
|
onChange={(e) => updateNpmPackage(idx, e.target.value)}
|
|
placeholder="e.g., @scope/pkg"
|
|
className="form-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeNpmPackage(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addNpmPackage}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add NPM Package
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label>Pip Packages</label>
|
|
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
|
{pipPackages.map((pkg, idx) => (
|
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
|
<input
|
|
type="text"
|
|
value={pkg.name}
|
|
onChange={(e) => updatePipPackage(idx, e.target.value)}
|
|
placeholder="e.g., requests"
|
|
className="form-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removePipPackage(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addPipPackage}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Pip Package
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Runtime User */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Runtime User</h4>
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>User Name</label>
|
|
<input
|
|
type="text"
|
|
value={userName}
|
|
onChange={(e) => setUserName(e.target.value)}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>UID</label>
|
|
<input
|
|
type="number"
|
|
value={userUid}
|
|
onChange={(e) => setUserUid(e.target.value)}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>GID</label>
|
|
<input
|
|
type="number"
|
|
value={userGid}
|
|
onChange={(e) => setUserGid(e.target.value)}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Environment */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Environment Variables</h4>
|
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
|
{envVars.map((ev, idx) => (
|
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
|
<input
|
|
type="text"
|
|
value={ev.key}
|
|
onChange={(e) => updateEnvVar(idx, "key", e.target.value)}
|
|
placeholder="KEY"
|
|
className="form-input"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={ev.value}
|
|
onChange={(e) => updateEnvVar(idx, "value", e.target.value)}
|
|
placeholder="value"
|
|
className="form-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeEnvVar(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addEnvVar}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Env Var
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Build Scripts */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Build Scripts (run during docker build)</h4>
|
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
|
{buildScripts.map((script, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="row"
|
|
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
|
>
|
|
<textarea
|
|
value={script}
|
|
onChange={(e) => updateBuildScript(idx, e.target.value)}
|
|
placeholder="git config --global user.email 'dev@example.com'"
|
|
className="form-input"
|
|
rows={2}
|
|
style={{
|
|
fontFamily: "monospace",
|
|
fontSize: "0.8125rem",
|
|
flex: 1,
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeBuildScript(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addBuildScript}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Build Script
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Startup Scripts */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>
|
|
Startup Scripts (run when container starts)
|
|
</h4>
|
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
|
{startupScripts.map((script, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="row"
|
|
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
|
>
|
|
<textarea
|
|
value={script}
|
|
onChange={(e) => updateStartupScript(idx, e.target.value)}
|
|
placeholder="chown -R user:user /workspace"
|
|
className="form-input"
|
|
rows={2}
|
|
style={{
|
|
fontFamily: "monospace",
|
|
fontSize: "0.8125rem",
|
|
flex: 1,
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeStartupScript(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addStartupScript}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Startup Script
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mounts */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Mount Schema</h4>
|
|
<div className="stack" style={{ gap: "1rem" }}>
|
|
{mounts.map((mount, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="stack"
|
|
style={{
|
|
gap: "0.5rem",
|
|
padding: "0.75rem",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "0.375rem",
|
|
}}
|
|
>
|
|
<div className="row" style={{ gap: "0.5rem" }}>
|
|
<input
|
|
type="text"
|
|
value={mount.name}
|
|
onChange={(e) => updateMount(idx, "name", e.target.value)}
|
|
placeholder="Name (e.g., workspace)"
|
|
className="form-input"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={mount.target}
|
|
onChange={(e) => updateMount(idx, "target", e.target.value)}
|
|
placeholder="Target (e.g., /workspace)"
|
|
className="form-input"
|
|
/>
|
|
<select
|
|
value={mount.source_type}
|
|
onChange={(e) =>
|
|
updateMount(idx, "source_type", e.target.value)
|
|
}
|
|
className="form-input"
|
|
>
|
|
<option value="repo">Repository</option>
|
|
<option value="ssh_key">SSH Key</option>
|
|
<option value="instance">Instance</option>
|
|
<option value="git_mount">Git Mount</option>
|
|
<option value="host_path">Host Path</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeMount(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
<div className="row" style={{ gap: "0.5rem" }}>
|
|
<label
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.25rem",
|
|
fontSize: "0.875rem",
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={mount.writable}
|
|
onChange={(e) =>
|
|
updateMount(idx, "writable", e.target.checked)
|
|
}
|
|
/>
|
|
Writable
|
|
</label>
|
|
<label
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.25rem",
|
|
fontSize: "0.875rem",
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={mount.readonly}
|
|
onChange={(e) =>
|
|
updateMount(idx, "readonly", e.target.checked)
|
|
}
|
|
/>
|
|
Read-only
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={mount.owner}
|
|
onChange={(e) => updateMount(idx, "owner", e.target.value)}
|
|
placeholder="Owner (e.g., user)"
|
|
className="form-input"
|
|
style={{ width: "120px" }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={mount.mode}
|
|
onChange={(e) => updateMount(idx, "mode", e.target.value)}
|
|
placeholder="Mode (e.g., 0755)"
|
|
className="form-input"
|
|
style={{ width: "100px" }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={mount.file_mode}
|
|
onChange={(e) =>
|
|
updateMount(idx, "file_mode", e.target.value)
|
|
}
|
|
placeholder="File mode (e.g., 0644)"
|
|
className="form-input"
|
|
style={{ width: "120px" }}
|
|
/>
|
|
{mount.source_type === "git_mount" && (
|
|
<input
|
|
type="text"
|
|
value={mount.git_mount_ref}
|
|
onChange={(e) =>
|
|
updateMount(idx, "git_mount_ref", e.target.value)
|
|
}
|
|
placeholder="Git mount ref"
|
|
className="form-input"
|
|
style={{ width: "120px" }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addMount}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Mount
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Runtime */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Runtime</h4>
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>Command</label>
|
|
<input
|
|
type="text"
|
|
value={command.join(" ")}
|
|
onChange={(e) => setCommand(e.target.value.split(" "))}
|
|
placeholder="/bin/bash"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>Working Directory</label>
|
|
<input
|
|
type="text"
|
|
value={workingDir}
|
|
onChange={(e) => setWorkingDir(e.target.value)}
|
|
placeholder="/workspace"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<label
|
|
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={stdinOpen}
|
|
onChange={(e) => setStdinOpen(e.target.checked)}
|
|
/>
|
|
stdin_open
|
|
</label>
|
|
<label
|
|
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={tty}
|
|
onChange={(e) => setTty(e.target.checked)}
|
|
/>
|
|
tty
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preview */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<h4 style={{ margin: 0 }}>Live Preview</h4>
|
|
<button
|
|
type="button"
|
|
onClick={handlePreview}
|
|
disabled={previewLoading}
|
|
className="button-secondary"
|
|
>
|
|
<Icon name="refresh" size="sm" />
|
|
{previewLoading ? "Compiling..." : "Preview"}
|
|
</button>
|
|
</div>
|
|
{previewError && <p className="text-error">{previewError}</p>}
|
|
{preview && (
|
|
<div className="stack" style={{ gap: "1rem" }}>
|
|
<div>
|
|
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
|
Dockerfile
|
|
</label>
|
|
<pre
|
|
style={{
|
|
background: "var(--code-bg, #1e1e1e)",
|
|
color: "var(--code-fg, #d4d4d4)",
|
|
padding: "1rem",
|
|
borderRadius: "0.375rem",
|
|
overflow: "auto",
|
|
fontSize: "0.8125rem",
|
|
maxHeight: "300px",
|
|
}}
|
|
>
|
|
{preview.dockerfile}
|
|
</pre>
|
|
</div>
|
|
<div>
|
|
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
|
Compose
|
|
</label>
|
|
<pre
|
|
style={{
|
|
background: "var(--code-bg, #1e1e1e)",
|
|
color: "var(--code-fg, #d4d4d4)",
|
|
padding: "1rem",
|
|
borderRadius: "0.375rem",
|
|
overflow: "auto",
|
|
fontSize: "0.8125rem",
|
|
maxHeight: "200px",
|
|
}}
|
|
>
|
|
{preview.compose}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|