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:
@@ -0,0 +1,53 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ToolType {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
compose_template: string;
|
||||
required_variables: string[];
|
||||
is_builtin: boolean;
|
||||
created_by_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateToolTypeRequest {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
compose_template: string;
|
||||
required_variables: string[];
|
||||
}
|
||||
|
||||
export interface UpdateToolTypeRequest {
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
compose_template?: string;
|
||||
required_variables?: string[];
|
||||
}
|
||||
|
||||
export const listToolTypes = async (): Promise<ToolType[]> => {
|
||||
const response = await apiClient.get<ToolType[]>("/tool-types");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getToolType = async (id: string): Promise<ToolType> => {
|
||||
const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createToolType = async (data: CreateToolTypeRequest): Promise<ToolType> => {
|
||||
const response = await apiClient.post<ToolType>("/tool-types", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateToolType = async (id: string, data: UpdateToolTypeRequest): Promise<ToolType> => {
|
||||
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteToolType = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/tool-types/${id}`);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ const NAV_ITEMS = [
|
||||
{ to: "/", label: "Dashboard" },
|
||||
{ to: "/projects", label: "Projects" },
|
||||
{ to: "/ssh-keys", label: "SSH Keys" },
|
||||
{ to: "/tool-types", label: "Tool Types" },
|
||||
{ to: "/settings", label: "Settings" }
|
||||
];
|
||||
|
||||
|
||||
@@ -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' services: app: 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>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { ProjectsPage } from "./pages/projects";
|
||||
import { GitRepositoriesPage } from "./pages/git-repositories";
|
||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||
import { SettingsPage } from "./pages/settings";
|
||||
import { ToolTypesPage } from "./pages/tool-types";
|
||||
|
||||
export const AppRouter = () => {
|
||||
return (
|
||||
@@ -28,6 +29,7 @@ export const AppRouter = () => {
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="tool-types" element={<ToolTypesPage />} />
|
||||
</Route>
|
||||
<Route path="/404" element={<NotFoundPage />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
|
||||
Reference in New Issue
Block a user