From ad09ffa6ec775518965b4912bc1ffe5bc9d98229 Mon Sep 17 00:00:00 2001 From: Fusion Date: Wed, 20 May 2026 11:13:25 +0200 Subject: [PATCH] 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 --- apps/api/src/api/tool_instances.py | 37 ++- apps/api/src/services/docker.py | 42 +++- apps/web/src/api/tool_configs.ts | 56 +++++ apps/web/src/components/app-shell.tsx | 1 + apps/web/src/pages/tool-configs.tsx | 346 ++++++++++++++++++++++++++ apps/web/src/router.tsx | 2 + 6 files changed, 481 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/api/tool_configs.ts create mode 100644 apps/web/src/pages/tool-configs.tsx diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index c240552..46aeafe 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -18,6 +18,7 @@ from src.auth.dependencies import get_current_user_id from src.auth.dependencies import get_db_session from src.models.git_repository import GitRepository from src.models.project import Project +from src.models.tool_config import ToolConfig from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType from src.models.user import User @@ -31,6 +32,8 @@ from src.services.docker import ( get_container_status, render_compose_template, write_compose_file, + write_config_files, + write_env_file, ) router = APIRouter(prefix="/projects", tags=["tool-instances"]) @@ -342,9 +345,39 @@ async def start_instance( instance.status = "building" await session.commit() - # Execute docker compose up + # Fetch tool configs for this tool type + env_vars = {} + config_files = {} + + config_query = select(ToolConfig).where( + ToolConfig.user_id == user_id, + ToolConfig.tool_type_id == instance.tool_type_id, + ).where( + (ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)) + ) + + config_result = await session.execute(config_query) + configs = config_result.scalars().all() + + for config in configs: + if config.config_type == "env": + env_vars[config.key] = config.value + elif config.config_type == "file" and config.file_path: + config_files[config.file_path] = config.value + + # Write env file and config files + instance_dir = os.path.dirname(instance.compose_path) + env_file_path = None + + if env_vars: + env_file_path = write_env_file(instance_dir, env_vars) + + if config_files: + write_config_files(instance_dir, config_files) + + # Execute docker compose up with env file returncode, stdout, stderr = execute_compose_command( - instance.compose_path, "up" + instance.compose_path, "up", env_file=env_file_path ) if returncode != 0: diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index dcc7f30..cccc210 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -56,8 +56,44 @@ def write_compose_file(instance_dir: str, content: str) -> str: return str(compose_path) +def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str: + """Write environment variables to a .env file. + + Args: + instance_dir: Path to instance directory + env_vars: Dictionary of env var names to values + + Returns: + Path to the env file + """ + env_path = Path(instance_dir) / ".env" + lines = [f'{key}="{value}"' for key, value in env_vars.items()] + env_path.write_text("\n".join(lines) + "\n") + return str(env_path) + + +def write_config_files(instance_dir: str, files: dict[str, str]) -> None: + """Write config files to the instance directory. + + Args: + instance_dir: Path to instance directory + files: Dictionary of file paths (relative to instance dir) to content + """ + instance_path = Path(instance_dir) + for file_path, content in files.items(): + # Ensure the path is within the instance directory (security) + full_path = instance_path / file_path + try: + full_path.resolve().relative_to(instance_path.resolve()) + except ValueError: + raise ValueError(f"File path '{file_path}' escapes instance directory") + + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content) + + def execute_compose_command( - compose_path: str, action: str, timeout: int = 60 + compose_path: str, action: str, timeout: int = 60, env_file: str | None = None ) -> tuple[int, str, str]: """Execute a docker compose command. @@ -65,6 +101,7 @@ def execute_compose_command( compose_path: Path to docker-compose.yml action: The compose action (up, down, start, stop, restart) timeout: Command timeout in seconds + env_file: Optional path to .env file for environment variables Returns: Tuple of (returncode, stdout, stderr) @@ -72,6 +109,9 @@ def execute_compose_command( instance_dir = Path(compose_path).parent cmd = ["docker", "compose", "-f", compose_path] + + if env_file: + cmd.extend(["--env-file", env_file]) if action == "up": cmd.extend(["up", "-d"]) diff --git a/apps/web/src/api/tool_configs.ts b/apps/web/src/api/tool_configs.ts new file mode 100644 index 0000000..52c750c --- /dev/null +++ b/apps/web/src/api/tool_configs.ts @@ -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 => { + 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 => { + const response = await apiClient.post<{ configs: ToolConfig[] }>("/tool-configs", data); + return response.data.configs[0]; +}; + +export const updateToolConfig = async ( + id: string, + data: CreateToolConfigRequest +): Promise => { + const response = await apiClient.put<{ configs: ToolConfig[] }>( + `/tool-configs/${id}`, + data + ); + return response.data.configs[0]; +}; + +export const deleteToolConfig = async (id: string): Promise => { + await apiClient.delete(`/tool-configs/${id}`); +}; \ No newline at end of file diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 1f4196f..4eae2b1 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -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" } ]; diff --git a/apps/web/src/pages/tool-configs.tsx b/apps/web/src/pages/tool-configs.tsx new file mode 100644 index 0000000..008058a --- /dev/null +++ b/apps/web/src/pages/tool-configs.tsx @@ -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("loading"); + const [toolTypes, setToolTypes] = useState([]); + const [configs, setConfigs] = useState([]); + const [selectedToolType, setSelectedToolType] = useState(""); + const [showForm, setShowForm] = useState(false); + const [editingConfig, setEditingConfig] = useState(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 ( +
+
+

Tool Configurations

+
+

Loading...

+
+ ); + } + + if (status === "error") { + return ( +
+
+

Tool Configurations

+
+
+

Failed to load configurations

+ +
+
+ ); + } + + return ( +
+
+

Tool Configurations

+

+ Manage environment variables and configuration files for your tools +

+
+ + {/* Tool Type Selector */} +
+ + + {selectedTool && ( +

+ Category: {selectedTool.category} ยท Interfaces: {selectedTool.interfaces?.join(", ")} +

+ )} +
+ + {/* Config List */} +
+
+

Configuration Variables

+ +
+ + {filteredConfigs.length === 0 ? ( +

No configurations for this tool yet.

+ ) : ( +
+ {filteredConfigs.map((config) => ( +
+
+
+ {config.key} + + {config.config_type} + +
+

+ {config.config_type === "file" && config.file_path + ? `File: ${config.file_path}` + : "Environment variable"} +

+
+
+ + +
+
+ ))} +
+ )} +
+ + {/* Add/Edit Form */} + {showForm && ( +
+

{editingConfig ? "Edit Config" : "Add Config"}

+
+
+ + setFormData({ ...formData, key: e.target.value })} + placeholder="e.g., OPENAI_API_KEY" + className="form-input" + required + /> +
+ +
+ + +
+ + {formData.config_type === "file" && ( +
+ + + setFormData({ ...formData, file_path: e.target.value }) + } + placeholder="e.g., /app/config.json" + className="form-input" + required + /> +
+ )} + +
+ +