feat(tool-configs): add frontend tool config management page

- Create ToolConfigsPage with tool type selector, config list, and add/edit form
- Support both env and file config types
- Add route /tool-configs and navigation item
- Update API client with tool config endpoints
- Build passes successfully
This commit is contained in:
Fusion
2026-05-20 11:13:25 +02:00
parent 63ae706dd0
commit ad09ffa6ec
6 changed files with 481 additions and 3 deletions
+56
View File
@@ -0,0 +1,56 @@
import { apiClient } from "./client";
export interface ToolConfig {
id: string;
tool_type_id: string;
project_id: string | null;
key: string;
value: string;
config_type: string;
file_path: string | null;
}
export interface CreateToolConfigRequest {
tool_type_id: string;
project_id?: string;
key: string;
value: string;
config_type?: string;
file_path?: string;
}
export const listToolConfigs = async (
tool_type_id?: string,
project_id?: string
): Promise<ToolConfig[]> => {
const params = new URLSearchParams();
if (tool_type_id) params.append("tool_type_id", tool_type_id);
if (project_id) params.append("project_id", project_id);
const response = await apiClient.get<{ configs: ToolConfig[] }>(
`/tool-configs?${params.toString()}`
);
return response.data.configs;
};
export const createToolConfig = async (
data: CreateToolConfigRequest
): Promise<ToolConfig> => {
const response = await apiClient.post<{ configs: ToolConfig[] }>("/tool-configs", data);
return response.data.configs[0];
};
export const updateToolConfig = async (
id: string,
data: CreateToolConfigRequest
): Promise<ToolConfig> => {
const response = await apiClient.put<{ configs: ToolConfig[] }>(
`/tool-configs/${id}`,
data
);
return response.data.configs[0];
};
export const deleteToolConfig = async (id: string): Promise<void> => {
await apiClient.delete(`/tool-configs/${id}`);
};
+1
View File
@@ -15,6 +15,7 @@ const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
{ to: "/tool-types", label: "Tool Types", icon: "code" },
{ to: "/tool-configs", label: "Tool Configs", icon: "settings" },
{ to: "/settings", label: "Settings", icon: "settings" }
];
+346
View File
@@ -0,0 +1,346 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../components/icon";
import { listToolTypes, type ToolType } from "../api/tool_types";
import {
createToolConfig,
deleteToolConfig,
listToolConfigs,
updateToolConfig,
type ToolConfig,
} from "../api/tool_configs";
type ConfigStatus = "loading" | "ready" | "error";
export const ToolConfigsPage = () => {
const [status, setStatus] = useState<ConfigStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]);
const [selectedToolType, setSelectedToolType] = useState<string>("");
const [showForm, setShowForm] = useState(false);
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
const [formData, setFormData] = useState({
key: "",
value: "",
config_type: "env",
file_path: "",
});
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const loadData = useCallback(async () => {
try {
const [typesData, configsData] = await Promise.all([
listToolTypes(),
listToolConfigs(),
]);
setToolTypes(typesData);
setConfigs(configsData);
if (typesData.length > 0 && !selectedToolType) {
setSelectedToolType(typesData[0].id);
}
setStatus("ready");
} catch {
setStatus("error");
}
}, [selectedToolType]);
useEffect(() => {
void loadData();
}, [loadData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaveStatus("saving");
try {
const data = {
tool_type_id: selectedToolType,
key: formData.key,
value: formData.value,
config_type: formData.config_type,
file_path: formData.config_type === "file" ? formData.file_path : undefined,
};
if (editingConfig) {
await updateToolConfig(editingConfig.id, data);
} else {
await createToolConfig(data);
}
setSaveStatus("saved");
setShowForm(false);
setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
await loadData();
} catch {
setSaveStatus("error");
}
};
const handleEdit = (config: ToolConfig) => {
setEditingConfig(config);
setFormData({
key: config.key,
value: config.value,
config_type: config.config_type,
file_path: config.file_path || "",
});
setSelectedToolType(config.tool_type_id);
setShowForm(true);
};
const handleDelete = async (id: string) => {
if (!window.confirm("Delete this config?")) return;
try {
await deleteToolConfig(id);
await loadData();
} catch {
// Error handled by UI state
}
};
const filteredConfigs = configs.filter(
(c) => c.tool_type_id === selectedToolType
);
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
if (status === "loading") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<p className="muted">Loading...</p>
</section>
);
}
if (status === "error") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<div className="card stack">
<p>Failed to load configurations</p>
<button className="secondary-button" onClick={() => void loadData()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
</section>
);
}
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
<p className="muted">
Manage environment variables and configuration files for your tools
</p>
</div>
{/* Tool Type Selector */}
<div className="card">
<label htmlFor="tool-type-select">Select Tool</label>
<select
id="tool-type-select"
value={selectedToolType}
onChange={(e) => {
setSelectedToolType(e.target.value);
setShowForm(false);
setEditingConfig(null);
}}
className="form-input"
>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
{selectedTool && (
<p className="muted" style={{ marginTop: "0.5rem" }}>
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
</p>
)}
</div>
{/* Config List */}
<div className="card stack">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<h2>Configuration Variables</h2>
<button
className="primary-button small"
onClick={() => {
setShowForm(true);
setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
}}
type="button"
>
<Icon name="add" size="sm" />
Add Config
</button>
</div>
{filteredConfigs.length === 0 ? (
<p className="muted">No configurations for this tool yet.</p>
) : (
<div className="stack" style={{ gap: "0.5rem" }}>
{filteredConfigs.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"}
</p>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<button
className="ghost-button small"
onClick={() => handleEdit(config)}
type="button"
>
<Icon name="edit" size="sm" />
</button>
<button
className="ghost-button small"
onClick={() => void handleDelete(config.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Add/Edit Form */}
{showForm && (
<div className="card stack">
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
<form onSubmit={handleSubmit} className="stack">
<div>
<label htmlFor="config-key">Key</label>
<input
id="config-key"
type="text"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
placeholder="e.g., OPENAI_API_KEY"
className="form-input"
required
/>
</div>
<div>
<label htmlFor="config-type">Type</label>
<select
id="config-type"
value={formData.config_type}
onChange={(e) =>
setFormData({ ...formData, config_type: e.target.value })
}
className="form-input"
>
<option value="env">Environment Variable</option>
<option value="file">Configuration File</option>
</select>
</div>
{formData.config_type === "file" && (
<div>
<label htmlFor="config-file-path">File Path</label>
<input
id="config-file-path"
type="text"
value={formData.file_path}
onChange={(e) =>
setFormData({ ...formData, file_path: e.target.value })
}
placeholder="e.g., /app/config.json"
className="form-input"
required
/>
</div>
)}
<div>
<label htmlFor="config-value">Value</label>
<textarea
id="config-value"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
placeholder={
formData.config_type === "env"
? "Enter value..."
: "Enter file contents..."
}
className="form-input"
rows={formData.config_type === "file" ? 8 : 2}
required
/>
</div>
<div className="row" style={{ gap: "0.5rem", justifyContent: "flex-end" }}>
<button
type="button"
className="secondary-button"
onClick={() => {
setShowForm(false);
setEditingConfig(null);
}}
>
Cancel
</button>
<button type="submit" className="primary-button">
{editingConfig ? "Update" : "Add"} Config
</button>
</div>
{saveStatus === "saved" && (
<p className="text-success" style={{ textAlign: "right" }}>
Saved successfully!
</p>
)}
{saveStatus === "error" && (
<p className="text-error" style={{ textAlign: "right" }}>
Failed to save. Please try again.
</p>
)}
</form>
</div>
)}
</section>
);
};
+2
View File
@@ -14,6 +14,7 @@ import { RepoWorkspace } from "./pages/repo-workspace";
import { SSHKeysPage } from "./pages/ssh-keys";
import { SettingsPage } from "./pages/settings";
import { TerminalPage } from "./pages/terminal";
import { ToolConfigsPage } from "./pages/tool-configs";
import { ToolTypesPage } from "./pages/tool-types";
export const AppRouter = () => {
@@ -39,6 +40,7 @@ export const AppRouter = () => {
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="tool-configs" element={<ToolConfigsPage />} />
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
</Route>
<Route path="/404" element={<NotFoundPage />} />