Files
headquarter/apps/web/src/components/manifest-editor.tsx
T
Alex Blank 0a0af4e02a fix: stop manifest editor base image selection loop
The ManifestEditor had a feedback loop:
1. State change → buildManifest changes → onChange notifies parent
2. Parent updates manifestData → new manifest prop
3. Loading effect sets all state from manifest (arrays get new refs even if same content)
4. New array refs → buildManifest changes → onChange fires again → loop

Fix: track the last-sent manifest via a ref and only call onChange when the
serialized built manifest actually differs. This breaks the cycle because after
the loading effect syncs state, the rebuilt manifest is identical in content
so we skip the parent notification.
2026-05-28 21:44:49 +02:00

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