refactor: split tool-workshop page into tab components (Task 4.1)
- Extract ToolTypesTab, ToolConfigsTab, ConfigFoldersTab from inline page - Each tab is self-contained with own state, API calls, and forms - Slim page to 77 lines (tab switcher + composition only) - Add barrel export for tool-workshop feature components - Add tsconfig path alias for @/* imports Quality gates: tsc (pass), eslint (pass) Refs: repo-restructure Task 4.1
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../../../components/icon";
|
||||
import {
|
||||
createConfigFolder,
|
||||
deleteConfigFolder,
|
||||
listConfigFolders,
|
||||
updateConfigFolder,
|
||||
type ConfigFolder,
|
||||
type CreateConfigFolderRequest,
|
||||
type UpdateConfigFolderRequest,
|
||||
} from "../../../api/config_folders";
|
||||
|
||||
export const ConfigFoldersTab = () => {
|
||||
const [folders, setFolders] = useState<ConfigFolder[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [selectedFolder, setSelectedFolder] = useState<ConfigFolder | null>(null);
|
||||
const [folderForm, setFolderForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
mount_path: "/home/user",
|
||||
files_json: "{}",
|
||||
is_active: true,
|
||||
});
|
||||
const [folderError, setFolderError] = useState<string | null>(null);
|
||||
const [showFolderForm, setShowFolderForm] = useState(false);
|
||||
|
||||
const loadFolders = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listConfigFolders();
|
||||
setFolders(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFolders();
|
||||
}, [loadFolders]);
|
||||
|
||||
const openCreateFolder = () => {
|
||||
setFolderForm({
|
||||
name: "",
|
||||
description: "",
|
||||
mount_path: "/home/user",
|
||||
files_json: "{}",
|
||||
is_active: true,
|
||||
});
|
||||
setFolderError(null);
|
||||
setShowFolderForm(true);
|
||||
setSelectedFolder(null);
|
||||
};
|
||||
|
||||
const openEditFolder = (folder: ConfigFolder) => {
|
||||
setFolderForm({
|
||||
name: folder.name,
|
||||
description: folder.description || "",
|
||||
mount_path: folder.mount_path,
|
||||
files_json: JSON.stringify(folder.files, null, 2),
|
||||
is_active: folder.is_active,
|
||||
});
|
||||
setFolderError(null);
|
||||
setShowFolderForm(true);
|
||||
setSelectedFolder(folder);
|
||||
};
|
||||
|
||||
const handleFolderSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFolderError(null);
|
||||
|
||||
if (!folderForm.name.trim() || !folderForm.mount_path.trim()) {
|
||||
setFolderError("Name and mount path are required");
|
||||
return;
|
||||
}
|
||||
|
||||
let files: Record<string, string> | undefined;
|
||||
try {
|
||||
if (folderForm.files_json.trim() && folderForm.files_json.trim() !== "{}") {
|
||||
files = JSON.parse(folderForm.files_json);
|
||||
}
|
||||
} catch {
|
||||
setFolderError("Files must be valid JSON object");
|
||||
return;
|
||||
}
|
||||
|
||||
const data: CreateConfigFolderRequest | UpdateConfigFolderRequest = {
|
||||
name: folderForm.name.trim(),
|
||||
description: folderForm.description.trim() || undefined,
|
||||
mount_path: folderForm.mount_path.trim(),
|
||||
files,
|
||||
is_active: folderForm.is_active,
|
||||
};
|
||||
|
||||
try {
|
||||
if (selectedFolder) {
|
||||
await updateConfigFolder(selectedFolder.id, data);
|
||||
} else {
|
||||
await createConfigFolder(data as CreateConfigFolderRequest);
|
||||
}
|
||||
setShowFolderForm(false);
|
||||
setSelectedFolder(null);
|
||||
await loadFolders();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
setFolderError(axiosError?.response?.data?.detail || "Failed to save folder");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFolder = async (id: string) => {
|
||||
if (!window.confirm("Delete this config folder?")) return;
|
||||
try {
|
||||
await deleteConfigFolder(id);
|
||||
await loadFolders();
|
||||
} catch {
|
||||
alert("Failed to delete folder");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return <p className="muted">Loading Config Folders...</p>;
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="card stack">
|
||||
<p className="text-error">Failed to load config folders.</p>
|
||||
<button onClick={() => void loadFolders()}>
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h2>Config Folders</h2>
|
||||
<button onClick={openCreateFolder}>
|
||||
<Icon name="add" size="sm" /> Create Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showFolderForm && (
|
||||
<div className="card stack" style={{ marginBottom: "1rem" }}>
|
||||
<h3>{selectedFolder ? "Edit" : "Create"} Config Folder</h3>
|
||||
<form onSubmit={handleFolderSubmit} className="stack">
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-name">Name *</label>
|
||||
<input
|
||||
id="folder-name"
|
||||
type="text"
|
||||
value={folderForm.name}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, name: e.target.value })}
|
||||
placeholder="e.g., my-dotfiles"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-description">Description</label>
|
||||
<input
|
||||
id="folder-description"
|
||||
type="text"
|
||||
value={folderForm.description}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, description: e.target.value })}
|
||||
placeholder="Optional description"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-mount-path">Mount Path *</label>
|
||||
<input
|
||||
id="folder-mount-path"
|
||||
type="text"
|
||||
value={folderForm.mount_path}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, mount_path: e.target.value })}
|
||||
placeholder="e.g., /home/user"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-files">Files (JSON object)</label>
|
||||
<textarea
|
||||
id="folder-files"
|
||||
value={folderForm.files_json}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, files_json: e.target.value })}
|
||||
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'
|
||||
className="form-input"
|
||||
rows={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={folderForm.is_active}
|
||||
onChange={(e) => setFolderForm({ ...folderForm, is_active: e.target.checked })}
|
||||
/>
|
||||
Active (mount into new instances)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{folderError && <p className="text-error">{folderError}</p>}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">{selectedFolder ? "Update" : "Create"}</button>
|
||||
<button type="button" onClick={() => setShowFolderForm(false)} className="button-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-grid">
|
||||
{folders.map((folder) => (
|
||||
<div key={folder.id} className="card">
|
||||
<div className="card-header">
|
||||
<h3>{folder.name}</h3>
|
||||
{folder.is_active && <span className="badge">Active</span>}
|
||||
</div>
|
||||
<p className="text-secondary">{folder.description || "No description"}</p>
|
||||
<div className="tool-type-meta">
|
||||
<span>Mount: {folder.mount_path}</span>
|
||||
<span>Files: {Object.keys(folder.files || {}).length}</span>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<button onClick={() => openEditFolder(folder)} className="button-secondary">
|
||||
<Icon name="edit" size="sm" /> Edit
|
||||
</button>
|
||||
<button onClick={() => handleDeleteFolder(folder.id)} className="button-danger">
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../../../components/icon";
|
||||
import {
|
||||
createToolConfig,
|
||||
deleteToolConfig,
|
||||
listToolConfigs,
|
||||
updateToolConfig,
|
||||
type CreateToolConfigRequest,
|
||||
type ToolConfig,
|
||||
} from "../../../api/tool_configs";
|
||||
import { listToolTypes, type ToolType } from "../../../api/tool_types";
|
||||
|
||||
export const ToolConfigsTab = () => {
|
||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [selectedConfig, setSelectedConfig] = useState<ToolConfig | null>(null);
|
||||
const [configForm, setConfigForm] = useState({
|
||||
tool_type_id: "",
|
||||
key: "",
|
||||
value: "",
|
||||
config_type: "env",
|
||||
file_path: "",
|
||||
port_override: "",
|
||||
start_command: "",
|
||||
working_directory: "",
|
||||
env_vars_json: "{}",
|
||||
volumes_json: "[]",
|
||||
});
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
const [showConfigForm, setShowConfigForm] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [cfgs, types] = await Promise.all([
|
||||
listToolConfigs(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setConfigs(cfgs);
|
||||
setToolTypes(types);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const openCreateConfig = () => {
|
||||
setConfigForm({
|
||||
tool_type_id: toolTypes[0]?.id || "",
|
||||
key: "",
|
||||
value: "",
|
||||
config_type: "env",
|
||||
file_path: "",
|
||||
port_override: "",
|
||||
start_command: "",
|
||||
working_directory: "",
|
||||
env_vars_json: "{}",
|
||||
volumes_json: "[]",
|
||||
});
|
||||
setConfigError(null);
|
||||
setShowConfigForm(true);
|
||||
setSelectedConfig(null);
|
||||
};
|
||||
|
||||
const openEditConfig = (config: ToolConfig) => {
|
||||
setConfigForm({
|
||||
tool_type_id: config.tool_type_id,
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
config_type: config.config_type,
|
||||
file_path: config.file_path || "",
|
||||
port_override: config.port_override?.toString() || "",
|
||||
start_command: config.start_command || "",
|
||||
working_directory: config.working_directory || "",
|
||||
env_vars_json: config.environment_variables ? JSON.stringify(config.environment_variables, null, 2) : "{}",
|
||||
volumes_json: config.volumes ? JSON.stringify(config.volumes, null, 2) : "[]",
|
||||
});
|
||||
setConfigError(null);
|
||||
setShowConfigForm(true);
|
||||
setSelectedConfig(config);
|
||||
};
|
||||
|
||||
const handleConfigSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setConfigError(null);
|
||||
|
||||
if (!configForm.tool_type_id || !configForm.key.trim()) {
|
||||
setConfigError("Tool type and key are required");
|
||||
return;
|
||||
}
|
||||
|
||||
let envVars: Record<string, string> | undefined;
|
||||
let volumes: Array<{ source: string; target: string; type?: string }> | undefined;
|
||||
|
||||
try {
|
||||
if (configForm.env_vars_json.trim() && configForm.env_vars_json.trim() !== "{}") {
|
||||
envVars = JSON.parse(configForm.env_vars_json);
|
||||
}
|
||||
} catch {
|
||||
setConfigError("Environment variables must be valid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (configForm.volumes_json.trim() && configForm.volumes_json.trim() !== "[]") {
|
||||
volumes = JSON.parse(configForm.volumes_json);
|
||||
}
|
||||
} catch {
|
||||
setConfigError("Volumes must be valid JSON array");
|
||||
return;
|
||||
}
|
||||
|
||||
const data: CreateToolConfigRequest = {
|
||||
tool_type_id: configForm.tool_type_id,
|
||||
key: configForm.key.trim(),
|
||||
value: configForm.value,
|
||||
config_type: configForm.config_type,
|
||||
file_path: configForm.config_type === "file" ? configForm.file_path : undefined,
|
||||
port_override: configForm.port_override ? Number(configForm.port_override) : undefined,
|
||||
start_command: configForm.start_command.trim() || undefined,
|
||||
working_directory: configForm.working_directory.trim() || undefined,
|
||||
environment_variables: envVars,
|
||||
volumes,
|
||||
};
|
||||
|
||||
try {
|
||||
if (selectedConfig) {
|
||||
await updateToolConfig(selectedConfig.id, data);
|
||||
} else {
|
||||
await createToolConfig(data);
|
||||
}
|
||||
setShowConfigForm(false);
|
||||
setSelectedConfig(null);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
setConfigError(axiosError?.response?.data?.detail || "Failed to save config");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfig = async (id: string) => {
|
||||
if (!window.confirm("Delete this config?")) return;
|
||||
try {
|
||||
await deleteToolConfig(id);
|
||||
await loadData();
|
||||
} catch {
|
||||
alert("Failed to delete config");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return <p className="muted">Loading Configurations...</p>;
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="card stack">
|
||||
<p className="text-error">Failed to load configurations.</p>
|
||||
<button onClick={() => void loadData()}>
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h2>Tool Configurations</h2>
|
||||
<button onClick={openCreateConfig}>
|
||||
<Icon name="add" size="sm" /> Add Config
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showConfigForm && (
|
||||
<div className="card stack" style={{ marginBottom: "1rem" }}>
|
||||
<h3>{selectedConfig ? "Edit" : "Add"} Config</h3>
|
||||
<form onSubmit={handleConfigSubmit} className="stack">
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-tool-type">Tool Type *</label>
|
||||
<select
|
||||
id="config-tool-type"
|
||||
value={configForm.tool_type_id}
|
||||
onChange={(e) => setConfigForm({ ...configForm, tool_type_id: e.target.value })}
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Select a tool type...</option>
|
||||
{toolTypes.map((tt) => (
|
||||
<option key={tt.id} value={tt.id}>{tt.display_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-key">Key *</label>
|
||||
<input
|
||||
id="config-key"
|
||||
type="text"
|
||||
value={configForm.key}
|
||||
onChange={(e) => setConfigForm({ ...configForm, key: e.target.value })}
|
||||
placeholder="e.g., OPENAI_API_KEY"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-type">Config Type</label>
|
||||
<select
|
||||
id="config-type"
|
||||
value={configForm.config_type}
|
||||
onChange={(e) => setConfigForm({ ...configForm, config_type: e.target.value })}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="env">Environment Variable</option>
|
||||
<option value="file">Configuration File</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{configForm.config_type === "file" && (
|
||||
<div className="form-group">
|
||||
<label>File Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={configForm.file_path}
|
||||
onChange={(e) => setConfigForm({ ...configForm, file_path: e.target.value })}
|
||||
placeholder="e.g., /app/config.json"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-value">Value</label>
|
||||
<textarea
|
||||
id="config-value"
|
||||
value={configForm.value}
|
||||
onChange={(e) => setConfigForm({ ...configForm, value: e.target.value })}
|
||||
placeholder={configForm.config_type === "env" ? "Enter value..." : "Enter file contents..."}
|
||||
className="form-input"
|
||||
rows={configForm.config_type === "file" ? 8 : 2}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="config-port-override">Port Override</label>
|
||||
<input
|
||||
id="config-port-override"
|
||||
type="number"
|
||||
value={configForm.port_override}
|
||||
onChange={(e) => setConfigForm({ ...configForm, port_override: e.target.value })}
|
||||
placeholder="e.g., 8080"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="config-start-command">Start Command</label>
|
||||
<input
|
||||
id="config-start-command"
|
||||
type="text"
|
||||
value={configForm.start_command}
|
||||
onChange={(e) => setConfigForm({ ...configForm, start_command: e.target.value })}
|
||||
placeholder="e.g., npm start"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Working Directory</label>
|
||||
<input
|
||||
type="text"
|
||||
value={configForm.working_directory}
|
||||
onChange={(e) => setConfigForm({ ...configForm, working_directory: e.target.value })}
|
||||
placeholder="e.g., /workspace"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Environment Variables (JSON)</label>
|
||||
<textarea
|
||||
value={configForm.env_vars_json}
|
||||
onChange={(e) => setConfigForm({ ...configForm, env_vars_json: e.target.value })}
|
||||
placeholder='{"KEY": "value"}'
|
||||
className="form-input"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Volumes (JSON array)</label>
|
||||
<textarea
|
||||
value={configForm.volumes_json}
|
||||
onChange={(e) => setConfigForm({ ...configForm, volumes_json: e.target.value })}
|
||||
placeholder='[{"source": "/host", "target": "/container"}]'
|
||||
className="form-input"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{configError && <p className="text-error">{configError}</p>}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">{selectedConfig ? "Update" : "Add"}</button>
|
||||
<button type="button" onClick={() => setShowConfigForm(false)} className="button-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{configs.length === 0 ? (
|
||||
<p className="muted">No configurations yet.</p>
|
||||
) : (
|
||||
configs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0.75rem 1rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}>
|
||||
<code style={{ fontWeight: 600 }}>{config.key}</code>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
background: config.config_type === "env" ? "var(--color-info)" : "var(--color-warning)",
|
||||
color: "white",
|
||||
padding: "0.125rem 0.5rem",
|
||||
borderRadius: "9999px",
|
||||
}}
|
||||
>
|
||||
{config.config_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}>
|
||||
{config.config_type === "file" && config.file_path
|
||||
? `File: ${config.file_path}`
|
||||
: "Environment variable"}
|
||||
{config.port_override && ` · Port: ${config.port_override}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => openEditConfig(config)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => handleDeleteConfig(config.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../../../components/icon";
|
||||
import {
|
||||
createToolType,
|
||||
deleteToolType,
|
||||
listToolTypes,
|
||||
updateToolType,
|
||||
type CreateToolTypeRequest,
|
||||
type ReadinessProbe,
|
||||
type ToolType,
|
||||
type UpdateToolTypeRequest,
|
||||
} from "../../../api/tool_types";
|
||||
|
||||
export const ToolTypesTab = () => {
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [selectedToolType, setSelectedToolType] = useState<ToolType | null>(null);
|
||||
const [toolTypeForm, setToolTypeForm] = useState({
|
||||
name: "",
|
||||
display_name: "",
|
||||
description: "",
|
||||
category: "",
|
||||
interfaces: [] as string[],
|
||||
default_port: "",
|
||||
definition_type: "compose" as "compose" | "dockerfile",
|
||||
compose_template: "",
|
||||
dockerfile_template: "",
|
||||
readiness_command: "",
|
||||
readiness_timeout: "30",
|
||||
readiness_interval: "2",
|
||||
required_variables: "",
|
||||
});
|
||||
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
||||
const [showToolTypeForm, setShowToolTypeForm] = useState(false);
|
||||
|
||||
const loadToolTypes = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadToolTypes();
|
||||
}, [loadToolTypes]);
|
||||
|
||||
const openCreateToolType = () => {
|
||||
setToolTypeForm({
|
||||
name: "",
|
||||
display_name: "",
|
||||
description: "",
|
||||
category: "",
|
||||
interfaces: [],
|
||||
default_port: "",
|
||||
definition_type: "compose",
|
||||
compose_template: "",
|
||||
dockerfile_template: "",
|
||||
readiness_command: "",
|
||||
readiness_timeout: "30",
|
||||
readiness_interval: "2",
|
||||
required_variables: "",
|
||||
});
|
||||
setToolTypeError(null);
|
||||
setShowToolTypeForm(true);
|
||||
setSelectedToolType(null);
|
||||
};
|
||||
|
||||
const openEditToolType = (toolType: ToolType) => {
|
||||
setToolTypeForm({
|
||||
name: toolType.name,
|
||||
display_name: toolType.display_name,
|
||||
description: toolType.description || "",
|
||||
category: toolType.category || "",
|
||||
interfaces: toolType.interfaces || [],
|
||||
default_port: toolType.default_port?.toString() || "",
|
||||
definition_type: toolType.definition_type || "compose",
|
||||
compose_template: toolType.compose_template || "",
|
||||
dockerfile_template: toolType.dockerfile_template || "",
|
||||
readiness_command: toolType.readiness_probe?.command || "",
|
||||
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
|
||||
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
|
||||
required_variables: toolType.required_variables?.join(", ") || "",
|
||||
});
|
||||
setToolTypeError(null);
|
||||
setShowToolTypeForm(true);
|
||||
setSelectedToolType(toolType);
|
||||
};
|
||||
|
||||
const handleToolTypeSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setToolTypeError(null);
|
||||
|
||||
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
|
||||
setToolTypeError("Name and display name are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port))) {
|
||||
setToolTypeError("Default port is required and must be a number");
|
||||
return;
|
||||
}
|
||||
|
||||
const template = toolTypeForm.definition_type === "compose"
|
||||
? toolTypeForm.compose_template
|
||||
: toolTypeForm.dockerfile_template;
|
||||
|
||||
if (!template.trim()) {
|
||||
setToolTypeError(`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`);
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = toolTypeForm.required_variables
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
|
||||
const readinessProbe: ReadinessProbe | undefined = toolTypeForm.readiness_command.trim()
|
||||
? {
|
||||
command: toolTypeForm.readiness_command.trim(),
|
||||
timeout: parseInt(toolTypeForm.readiness_timeout) || 30,
|
||||
interval: parseInt(toolTypeForm.readiness_interval) || 2,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
if (selectedToolType) {
|
||||
const input: UpdateToolTypeRequest = {
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
|
||||
default_port: Number(toolTypeForm.default_port),
|
||||
definition_type: toolTypeForm.definition_type,
|
||||
compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
||||
readiness_probe: readinessProbe,
|
||||
required_variables: variables,
|
||||
};
|
||||
await updateToolType(selectedToolType.id, input);
|
||||
} else {
|
||||
const input: CreateToolTypeRequest = {
|
||||
name: toolTypeForm.name.trim(),
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
|
||||
default_port: Number(toolTypeForm.default_port),
|
||||
definition_type: toolTypeForm.definition_type,
|
||||
compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
||||
readiness_probe: readinessProbe,
|
||||
required_variables: variables,
|
||||
};
|
||||
await createToolType(input);
|
||||
}
|
||||
setShowToolTypeForm(false);
|
||||
setSelectedToolType(null);
|
||||
await loadToolTypes();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
setToolTypeError(axiosError?.response?.data?.detail || "Failed to save tool type");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteToolType = async (id: string) => {
|
||||
if (!window.confirm("Delete this tool type? All associated configs will be removed.")) return;
|
||||
try {
|
||||
await deleteToolType(id);
|
||||
await loadToolTypes();
|
||||
} catch {
|
||||
alert("Failed to delete tool type");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return <p className="muted">Loading Tool Types...</p>;
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="card stack">
|
||||
<p className="text-error">Failed to load tool types.</p>
|
||||
<button onClick={() => void loadToolTypes()}>
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h2>Tool Types</h2>
|
||||
<button onClick={openCreateToolType}>
|
||||
<Icon name="add" size="sm" /> Create Tool Type
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showToolTypeForm && (
|
||||
<div className="card stack" style={{ marginBottom: "1rem" }}>
|
||||
<h3>{selectedToolType ? "Edit" : "Create"} Tool Type</h3>
|
||||
<form onSubmit={handleToolTypeSubmit} className="stack">
|
||||
<div className="form-group">
|
||||
<label htmlFor="definition-type">Definition Type</label>
|
||||
<select
|
||||
id="definition-type"
|
||||
value={toolTypeForm.definition_type}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, definition_type: e.target.value as "compose" | "dockerfile" })}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="compose">Docker Compose</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-name">Name *</label>
|
||||
<input
|
||||
id="tool-type-name"
|
||||
type="text"
|
||||
value={toolTypeForm.name}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, name: e.target.value })}
|
||||
disabled={!!selectedToolType}
|
||||
placeholder="e.g., code-server"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-display-name">Display Name *</label>
|
||||
<input
|
||||
id="tool-type-display-name"
|
||||
type="text"
|
||||
value={toolTypeForm.display_name}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, display_name: e.target.value })}
|
||||
placeholder="e.g., VS Code Server"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-description">Description</label>
|
||||
<input
|
||||
id="tool-type-description"
|
||||
type="text"
|
||||
value={toolTypeForm.description}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, description: e.target.value })}
|
||||
placeholder="Optional description"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-category">Category</label>
|
||||
<input
|
||||
id="tool-type-category"
|
||||
type="text"
|
||||
value={toolTypeForm.category}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, category: e.target.value })}
|
||||
placeholder="e.g., editor, notebook, ai-assistant"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Interfaces</label>
|
||||
<div className="checkbox-group">
|
||||
{["web", "terminal"].map((iface) => (
|
||||
<label key={iface} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolTypeForm.interfaces.includes(iface)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setToolTypeForm({ ...toolTypeForm, interfaces: [...toolTypeForm.interfaces, iface] });
|
||||
} else {
|
||||
setToolTypeForm({ ...toolTypeForm, interfaces: toolTypeForm.interfaces.filter((i) => i !== iface) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{iface}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-default-port">Default Port *</label>
|
||||
<input
|
||||
id="tool-type-default-port"
|
||||
type="number"
|
||||
value={toolTypeForm.default_port}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, default_port: e.target.value })}
|
||||
placeholder="e.g., 8443"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-template">{toolTypeForm.definition_type === "compose" ? "Compose Template" : "Dockerfile"} *</label>
|
||||
<textarea
|
||||
id="tool-type-template"
|
||||
value={toolTypeForm.definition_type === "compose" ? toolTypeForm.compose_template : toolTypeForm.dockerfile_template}
|
||||
onChange={(e) => {
|
||||
if (toolTypeForm.definition_type === "compose") {
|
||||
setToolTypeForm({ ...toolTypeForm, compose_template: e.target.value });
|
||||
} else {
|
||||
setToolTypeForm({ ...toolTypeForm, dockerfile_template: e.target.value });
|
||||
}
|
||||
}}
|
||||
rows={10}
|
||||
placeholder={toolTypeForm.definition_type === "compose" ? "version: '3.8'\nservices:\n app:\n image: ..." : "FROM node:18\nWORKDIR /app\n..."}
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="readiness-command">Readiness Probe Command</label>
|
||||
<input
|
||||
id="readiness-command"
|
||||
type="text"
|
||||
value={toolTypeForm.readiness_command}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })}
|
||||
placeholder="e.g., curl -f http://localhost:8080"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
|
||||
<input
|
||||
id="readiness-timeout"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_timeout}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="readiness-interval">Interval (seconds)</label>
|
||||
<input
|
||||
id="readiness-interval"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_interval}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Required Variables (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={toolTypeForm.required_variables}
|
||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, required_variables: e.target.value })}
|
||||
placeholder="REPO_PATH, TOOL_NAME"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{toolTypeError && <p className="text-error">{toolTypeError}</p>}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">{selectedToolType ? "Update" : "Create"}</button>
|
||||
<button type="button" onClick={() => setShowToolTypeForm(false)} className="button-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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="tool-type-meta">
|
||||
<span>Type: {toolType.definition_type}</span>
|
||||
<span>Port: {toolType.default_port || "N/A"}</span>
|
||||
{toolType.interfaces?.length > 0 && (
|
||||
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
||||
)}
|
||||
{toolType.category && <span>Category: {toolType.category}</span>}
|
||||
{toolType.readiness_probe && (
|
||||
<span>Probe: {toolType.readiness_probe.command}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
{!toolType.is_builtin && (
|
||||
<>
|
||||
<button onClick={() => openEditToolType(toolType)} className="button-secondary">
|
||||
<Icon name="edit" size="sm" /> Edit
|
||||
</button>
|
||||
<button onClick={() => handleDeleteToolType(toolType.id)} className="button-danger">
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ToolTypesTab } from "./ToolTypesTab";
|
||||
export { ToolConfigsTab } from "./ToolConfigsTab";
|
||||
export { ConfigFoldersTab } from "./ConfigFoldersTab";
|
||||
Reference in New Issue
Block a user