feat: implement tool types definition system

- Add ToolType SQLAlchemy model with Docker Compose template support
- Create CRUD API endpoints for tool type management
- Implement YAML and template variable validation
- Add built-in tool types (code-server, jupyter-notebook) seeded on startup
- Create frontend page with list, create, edit, and delete functionality
- Add tool types navigation to app shell
- Update mypy config to ignore missing imports

Quality gates: ruff (passed), mypy (passed), pytest unit (8 passed),
typecheck (passed), lint (passed), build (passed)
This commit is contained in:
Fusion
2026-05-18 16:27:19 +02:00
parent fb5725947d
commit 6b302b3279
15 changed files with 945 additions and 1 deletions
+264
View File
@@ -0,0 +1,264 @@
import { useCallback, useEffect, useState } from "react";
import {
createToolType,
deleteToolType,
listToolTypes,
updateToolType,
type CreateToolTypeRequest,
type UpdateToolTypeRequest,
} from "../api/tool_types";
import type { ToolType } from "../api/tool_types";
type ToolTypesStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ToolTypesPage = () => {
const [status, setStatus] = useState<ToolTypesStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingToolType, setEditingToolType] = useState<ToolType | null>(null);
const [formName, setFormName] = useState("");
const [formDisplayName, setFormDisplayName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formTemplate, setFormTemplate] = useState("");
const [formVariables, setFormVariables] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadToolTypes = useCallback(async () => {
setStatus("loading");
try {
const data = await listToolTypes();
setToolTypes(data);
setStatus("ready");
} catch {
setToolTypes([]);
setStatus("error");
}
}, []);
useEffect(() => {
void loadToolTypes();
}, [loadToolTypes]);
const openCreate = () => {
setFormName("");
setFormDisplayName("");
setFormDescription("");
setFormTemplate("");
setFormVariables("");
setFormError(null);
setEditingToolType(null);
setDialogMode("create");
};
const openEdit = (toolType: ToolType) => {
setFormName(toolType.name);
setFormDisplayName(toolType.display_name);
setFormDescription(toolType.description ?? "");
setFormTemplate(toolType.compose_template);
setFormVariables(toolType.required_variables.join(", "));
setFormError(null);
setEditingToolType(toolType);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingToolType(null);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
setFormError("Name, display name, and compose template are required");
return;
}
const variables = formVariables
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
try {
if (dialogMode === "create") {
const input: CreateToolTypeRequest = {
name: formName.trim(),
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
compose_template: formTemplate.trim(),
required_variables: variables,
};
await createToolType(input);
} else if (dialogMode === "edit" && editingToolType) {
const input: UpdateToolTypeRequest = {
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
compose_template: formTemplate.trim(),
required_variables: variables,
};
await updateToolType(editingToolType.id, input);
}
closeDialog();
await loadToolTypes();
} catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } };
const detail = axiosError?.response?.data?.detail || "Failed to save tool type";
setFormError(detail);
}
};
const handleDelete = async (id: string) => {
try {
await deleteToolType(id);
setDeleteConfirmId(null);
await loadToolTypes();
} catch {
alert("Failed to delete tool type");
}
};
if (status === "loading") {
return (
<div className="container">
<p>Loading tool types...</p>
</div>
);
}
if (status === "error") {
return (
<div className="container">
<p className="text-error">Failed to load tool types.</p>
<button onClick={loadToolTypes}>Retry</button>
</div>
);
}
return (
<div className="container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<h1>Tool Types</h1>
<button onClick={openCreate}>Create Tool Type</button>
</div>
{toolTypes.length === 0 ? (
<p>No tool types found.</p>
) : (
<div className="card-grid">
{toolTypes.map((toolType) => (
<div key={toolType.id} className="card">
<div className="card-header">
<h3>{toolType.display_name}</h3>
{toolType.is_builtin && <span className="badge">Built-in</span>}
</div>
<p className="text-secondary">{toolType.description || "No description"}</p>
<div className="card-actions">
{!toolType.is_builtin && (
<>
<button onClick={() => openEdit(toolType)} className="button-secondary">
Edit
</button>
<button
onClick={() => setDeleteConfirmId(toolType.id)}
className="button-danger"
>
Delete
</button>
</>
)}
</div>
{deleteConfirmId === toolType.id && (
<div className="dialog-overlay">
<div className="dialog">
<p>Delete tool type "{toolType.display_name}"?</p>
<div className="dialog-actions">
<button onClick={() => handleDelete(toolType.id)} className="button-danger">
Delete
</button>
<button onClick={() => setDeleteConfirmId(null)}>Cancel</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{dialogMode !== "none" && (
<div className="dialog-overlay">
<div className="dialog">
<h2>{dialogMode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name (unique identifier)</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
disabled={dialogMode === "edit"}
placeholder="e.g., code-server"
/>
</div>
<div className="form-group">
<label>Display Name</label>
<input
type="text"
value={formDisplayName}
onChange={(e) => setFormDisplayName(e.target.value)}
placeholder="e.g., VS Code Server"
/>
</div>
<div className="form-group">
<label>Description</label>
<input
type="text"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
<div className="form-group">
<label>Compose Template (YAML)</label>
<textarea
value={formTemplate}
onChange={(e) => setFormTemplate(e.target.value)}
rows={10}
placeholder="version: '3.8'&#10;services:&#10; app:&#10; image: ..."
/>
</div>
<div className="form-group">
<label>Required Variables (comma-separated)</label>
<input
type="text"
value={formVariables}
onChange={(e) => setFormVariables(e.target.value)}
placeholder="REPO_PATH, TOOL_NAME"
/>
</div>
{formError && <p className="text-error">{formError}</p>}
<div className="dialog-actions">
<button type="submit">{dialogMode === "create" ? "Create" : "Update"}</button>
<button type="button" onClick={closeDialog} className="button-secondary">
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};