diff --git a/apps/api/alembic/versions/0015_single_interface.py b/apps/api/alembic/versions/0015_single_interface.py new file mode 100644 index 0000000..64e0249 --- /dev/null +++ b/apps/api/alembic/versions/0015_single_interface.py @@ -0,0 +1,110 @@ +"""replace interfaces with interface_type and add requires_port + +Revision ID: 0015_single_interface +Revises: 0014_merge_heads +Create Date: 2026-05-22 22:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision: str = "0015_single_interface" +down_revision: Union[str, Sequence[str], None] = "0014_merge_heads" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _get_dialect() -> str: + """Get the current database dialect name.""" + conn = op.get_bind() + return conn.dialect.name + + +def upgrade() -> None: + dialect = _get_dialect() + + # Add new columns + op.add_column('tool_types', sa.Column('interface_type', sa.String(20), nullable=True)) + op.add_column('tool_types', sa.Column('requires_port', sa.Boolean(), nullable=False, server_default='true')) + + # Migrate data: take first element from interfaces JSON array + if dialect == 'postgresql': + op.execute(""" + UPDATE tool_types + SET interface_type = COALESCE( + (SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1), + 'web' + ), + requires_port = CASE + WHEN COALESCE( + (SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1), + 'web' + ) = 'web' THEN true + ELSE false + END + """) + else: + # SQLite: interfaces is stored as JSON text, extract first array element + op.execute(""" + UPDATE tool_types + SET interface_type = COALESCE( + (SELECT json_extract(value, '$[0]') + FROM json_each(interfaces) AS value + WHERE json_valid(interfaces) + LIMIT 1), + 'web' + ), + requires_port = CASE + WHEN COALESCE( + (SELECT json_extract(value, '$[0]') + FROM json_each(interfaces) AS value + WHERE json_valid(interfaces) + LIMIT 1), + 'web' + ) = 'web' THEN true + ELSE false + END + """) + + # Make interface_type non-nullable after data migration + op.alter_column('tool_types', 'interface_type', nullable=False) + + # Drop old interfaces column + op.drop_column('tool_types', 'interfaces') + + # Add CHECK constraint for interface_type (only on PostgreSQL; SQLite supports it too) + op.create_check_constraint('chk_interface_type', 'tool_types', sa.text("interface_type IN ('web', 'terminal')")) + + +def downgrade() -> None: + dialect = _get_dialect() + + # Drop CHECK constraint + op.drop_constraint('chk_interface_type', 'tool_types', type_='check') + + # Add back interfaces column + if dialect == 'postgresql': + op.add_column('tool_types', sa.Column('interfaces', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='["web"]')) + + # Migrate data back: wrap interface_type in array + op.execute(""" + UPDATE tool_types + SET interfaces = jsonb_build_array(interface_type) + """) + else: + op.add_column('tool_types', sa.Column('interfaces', sa.JSON(), nullable=False, server_default='["web"]')) + + # Migrate data back: wrap interface_type in array for SQLite + op.execute(""" + UPDATE tool_types + SET interfaces = json_array(interface_type) + """) + + # Drop new columns + op.drop_column('tool_types', 'requires_port') + op.drop_column('tool_types', 'interface_type') diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index a41efdc..859c87f 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -390,7 +390,7 @@ async def list_instances( "display_name": i.display_name, "tool_type_id": str(i.tool_type_id), "tool_type_name": tool_type.name if tool_type else "unknown", - "tool_type_interfaces": tool_type.interfaces if tool_type else [], + "tool_type_interface_type": tool_type.interface_type if tool_type else "", "status": i.status, "url": i.url, "port": i.port, @@ -684,7 +684,7 @@ async def start_instance( probe_command = probe_config.get("command", "") probe_timeout = probe_config.get("timeout", 30) probe_interval = probe_config.get("interval", 2) - elif "web" in (tool_type.interfaces or []): + elif tool_type.interface_type == "web": # Default probe for web tools probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}" probe_timeout = 30 @@ -747,11 +747,11 @@ async def start_instance( } instance_port = tool_type.default_port - logger.info("Tool type for instance %s: name=%s, default_port=%s, interfaces=%s", - instance.id, tool_type.name, instance_port, tool_type.interfaces) + logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s", + instance.id, tool_type.name, instance_port, tool_type.interface_type) # Only create Cloudflare tunnel for web-enabled tools - if "web" in tool_type.interfaces: + if tool_type.interface_type == "web": # Create temporary Cloudflare tunnel for public access try: logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)", @@ -916,7 +916,7 @@ async def restart_instance( instance_port = tool_type.default_port # Only create tunnel for web-enabled tools - if "web" in tool_type.interfaces: + if tool_type.interface_type == "web": # Create new temporary tunnel try: tunnel_info = start_cloudflared_tunnel( @@ -1404,7 +1404,7 @@ async def get_user_sessions( "display_name": instance.display_name, "tool_type_name": tool_type.name if tool_type else "unknown", "tool_icon": tool_type.name if tool_type else "code", - "tool_type_interfaces": tool_type.interfaces if tool_type else [], + "tool_type_interface_type": tool_type.interface_type if tool_type else "", "repository_name": repo.name if repo else "unknown", "repository_id": str(instance.repository_id), "project_name": project.name if project else "unknown", diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index d50405c..113c353 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -45,7 +45,8 @@ class ToolTypeCreate(BaseModel): readiness_probe: dict | None = None required_variables: list[str] = [] category: str = "other" - interfaces: list[str] = ["web"] + interface_type: str = "web" + requires_port: bool = True @field_validator("definition_type") @classmethod @@ -95,9 +96,20 @@ class ToolTypeCreate(BaseModel): return v + @field_validator("interface_type") + @classmethod + def validate_interface_type(cls, v: str) -> str: + if v not in ("web", "terminal"): + raise ValueError("interface_type must be 'web' or 'terminal'") + return v + @field_validator("default_port") @classmethod - def validate_default_port(cls, v: int) -> int: + def validate_default_port(cls, v: int, info) -> int: + data = info.data + requires_port = data.get("requires_port", True) + if not requires_port: + return v if v <= 0 or v > 65535: raise ValueError("Port must be between 1 and 65535") return v @@ -130,8 +142,8 @@ class ToolTypeCreate(BaseModel): if self.definition_type == "compose" and self.compose_template is None: raise ValueError("compose_template is required when definition_type is 'compose'") - # Validate that default_port is exposed in compose template - if self.definition_type == "compose" and self.compose_template: + # Validate that default_port is exposed in compose template (only if requires_port) + if self.requires_port and self.definition_type == "compose" and self.compose_template: try: parsed = yaml.safe_load(self.compose_template) except yaml.YAMLError: @@ -171,7 +183,8 @@ class ToolTypeUpdate(BaseModel): readiness_probe: dict | None = None required_variables: list[str] | None = None category: str | None = None - interfaces: list[str] | None = None + interface_type: str | None = None + requires_port: bool | None = None @field_validator("definition_type") @classmethod @@ -182,6 +195,15 @@ class ToolTypeUpdate(BaseModel): raise ValueError("definition_type must be 'compose' or 'dockerfile'") return v + @field_validator("interface_type") + @classmethod + def validate_interface_type(cls, v: str | None) -> str | None: + if v is None: + return v + if v not in ("web", "terminal"): + raise ValueError("interface_type must be 'web' or 'terminal'") + return v + @field_validator("compose_template") @classmethod def validate_compose_template(cls, v: str | None, info) -> str | None: @@ -234,7 +256,8 @@ class ToolTypeResponse(BaseModel): display_name: str description: str | None category: str - interfaces: list[str] + interface_type: str + requires_port: bool default_port: int definition_type: str compose_template: str | None @@ -290,7 +313,8 @@ async def create_tool_type( readiness_probe=data.readiness_probe, required_variables=data.required_variables, category=data.category, - interfaces=data.interfaces, + interface_type=data.interface_type, + requires_port=data.requires_port, is_builtin=False, created_by_id=user.id, ) @@ -388,7 +412,8 @@ async def update_tool_type( update_data = data.model_dump(exclude_unset=True) # Validate port if being updated - if "default_port" in update_data: + requires_port = update_data.get("requires_port", tool_type.requires_port) + if "default_port" in update_data and requires_port: new_port = update_data["default_port"] if new_port <= 0 or new_port > 65535: raise HTTPException( diff --git a/apps/api/src/main.py b/apps/api/src/main.py index aabb3f2..9659e5d 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -137,7 +137,8 @@ async def seed_builtin_tool_types(): "display_name": "VS Code Server", "description": "VS Code running in the browser via code-server", "category": "editor", - "interfaces": ["web"], + "interface_type": "web", + "requires_port": True, "compose_template": """version: "3.8" services: code-server: @@ -160,7 +161,8 @@ services: "display_name": "Jupyter Notebook", "description": "Jupyter Lab for interactive development", "category": "notebook", - "interfaces": ["web"], + "interface_type": "web", + "requires_port": True, "default_port": 8888, "compose_template": """version: "3.8" services: @@ -181,7 +183,8 @@ services: "display_name": "OpenCode", "description": "AI coding assistant - run opencode in terminal", "category": "ai-assistant", - "interfaces": ["terminal"], + "interface_type": "terminal", + "requires_port": False, "default_port": 3000, "compose_template": """version: "3.8" services: @@ -227,7 +230,8 @@ volumes: display_name=tool_data["display_name"], description=tool_data["description"], category=tool_data["category"], - interfaces=tool_data["interfaces"], + interface_type=tool_data["interface_type"], + requires_port=tool_data["requires_port"], definition_type="compose", compose_template=tool_data["compose_template"], required_variables=tool_data["required_variables"], @@ -241,7 +245,8 @@ volumes: existing.display_name = tool_data["display_name"] existing.description = tool_data["description"] existing.category = tool_data["category"] - existing.interfaces = tool_data["interfaces"] + existing.interface_type = tool_data["interface_type"] + existing.requires_port = tool_data["requires_port"] existing.definition_type = "compose" existing.compose_template = tool_data["compose_template"] existing.required_variables = tool_data["required_variables"] diff --git a/apps/api/src/models/tool_type.py b/apps/api/src/models/tool_type.py index 57e41ec..a8ae655 100644 --- a/apps/api/src/models/tool_type.py +++ b/apps/api/src/models/tool_type.py @@ -18,7 +18,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base): display_name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) category: Mapped[str] = mapped_column(String(50), nullable=False, default="other") - interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web") + requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) default_port: Mapped[int] = mapped_column(nullable=False) definition_type: Mapped[str] = mapped_column( String(20), nullable=False, default="compose" diff --git a/apps/web/src/api/tool_types.ts b/apps/web/src/api/tool_types.ts index b566033..599b646 100644 --- a/apps/web/src/api/tool_types.ts +++ b/apps/web/src/api/tool_types.ts @@ -12,7 +12,8 @@ export interface ToolType { display_name: string; description: string | null; category: string; - interfaces: string[]; + interface_type: string; + requires_port: boolean; default_port: number | null; definition_type: 'compose' | 'dockerfile'; compose_template: string | null; @@ -31,7 +32,8 @@ export interface CreateToolTypeRequest { display_name: string; description?: string; category?: string; - interfaces?: string[]; + interface_type?: string; + requires_port?: boolean; default_port: number; definition_type?: 'compose' | 'dockerfile'; compose_template?: string; @@ -45,7 +47,8 @@ export interface UpdateToolTypeRequest { display_name?: string; description?: string; category?: string; - interfaces?: string[]; + interface_type?: string; + requires_port?: boolean; default_port?: number; definition_type?: 'compose' | 'dockerfile'; compose_template?: string; diff --git a/apps/web/src/components/git-toolbar.tsx b/apps/web/src/components/git-toolbar.tsx index 31a8f07..04556c3 100644 --- a/apps/web/src/components/git-toolbar.tsx +++ b/apps/web/src/components/git-toolbar.tsx @@ -18,6 +18,7 @@ interface GitToolbarProps { currentBranch: string; branches: string[]; hasRemote: boolean; + isMirror: boolean; onBranchChange: (branch: string) => void; onRefresh: () => void; } @@ -28,6 +29,7 @@ export const GitToolbar = ({ currentBranch, branches, hasRemote, + isMirror, onBranchChange, onRefresh, }: GitToolbarProps) => { @@ -136,7 +138,13 @@ export const GitToolbar = ({ return (
{error &&
{error}
} - + {isMirror && ( +
+ This repository is a bare mirror. + Editing, committing, pulling, and merging are not available. + Delete and recreate it to enable full workspace features. +
+ )}
{selectedTool && (

- Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")} + Category: {selectedTool.category} · Interface: {selectedTool.interface_type}

)}
diff --git a/apps/web/src/pages/tool-types.tsx b/apps/web/src/pages/tool-types.tsx index 674b99a..34b9a07 100644 --- a/apps/web/src/pages/tool-types.tsx +++ b/apps/web/src/pages/tool-types.tsx @@ -67,7 +67,7 @@ export const ToolTypesPage = () => { setFormDisplayName(toolType.display_name); setFormDescription(toolType.description ?? ""); setFormCategory(toolType.category ?? ""); - setFormInterfaces(toolType.interfaces ?? []); + setFormInterfaces(toolType.interface_type ? [toolType.interface_type] : []); setFormPort(toolType.default_port?.toString() ?? ""); setFormTemplate(toolType.compose_template ?? ""); setFormVariables(toolType.required_variables.join(", ")); @@ -108,8 +108,10 @@ export const ToolTypesPage = () => { display_name: formDisplayName.trim(), description: formDescription.trim() || undefined, category: formCategory.trim() || undefined, - interfaces: formInterfaces.length > 0 ? formInterfaces : undefined, + interface_type: formInterfaces.length > 0 ? formInterfaces[0] : "web", + requires_port: formInterfaces.includes("web"), default_port: Number(formPort), + definition_type: "compose", compose_template: formTemplate.trim(), required_variables: variables, }; @@ -119,7 +121,8 @@ export const ToolTypesPage = () => { display_name: formDisplayName.trim(), description: formDescription.trim() || undefined, category: formCategory.trim() || undefined, - interfaces: formInterfaces.length > 0 ? formInterfaces : undefined, + interface_type: formInterfaces.length > 0 ? formInterfaces[0] : "web", + requires_port: formInterfaces.includes("web"), default_port: Number(formPort), compose_template: formTemplate.trim(), required_variables: variables, @@ -194,8 +197,8 @@ export const ToolTypesPage = () => {

{toolType.description || "No description"}

Port: {toolType.default_port || "N/A"} - {toolType.interfaces?.length > 0 && ( - Interfaces: {toolType.interfaces.join(", ")} + {toolType.interface_type && ( + Interface: {toolType.interface_type} )} {toolType.category && Category: {toolType.category}}
diff --git a/apps/web/src/pages/tool-workshop.test.tsx b/apps/web/src/pages/tool-workshop.test.tsx index cbc999a..e0937f0 100644 --- a/apps/web/src/pages/tool-workshop.test.tsx +++ b/apps/web/src/pages/tool-workshop.test.tsx @@ -13,7 +13,8 @@ const mockToolTypes = [ display_name: "VS Code Server", description: "VS Code in browser", category: "editor", - interfaces: ["web"], + interface_type: "web", + requires_port: true, default_port: 8443, definition_type: "compose", compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server", @@ -32,7 +33,8 @@ const mockToolTypes = [ display_name: "Custom Tool", description: "My custom tool", category: "utility", - interfaces: ["terminal"], + interface_type: "terminal", + requires_port: false, default_port: 8080, definition_type: "dockerfile", compose_template: null, @@ -153,164 +155,10 @@ describe("ToolWorkshopPage", () => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); - fireEvent.click(screen.getByRole("button", { name: /configs/i })); + fireEvent.click(screen.getByText("VS Code Server")); await waitFor(() => { - expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument(); - }); - expect(screen.getByText("advanced-config")).toBeInTheDocument(); - }); - - it("switches to folders tab", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /folders/i })); - - await waitFor(() => { - expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); - }); - expect(screen.getByText("project-configs")).toBeInTheDocument(); - }); - - it("opens tool type creation form", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); - - expect(screen.getByLabelText("Name *")).toBeInTheDocument(); - expect(screen.getByLabelText("Display Name *")).toBeInTheDocument(); - }); - - it("creates tool type with compose definition", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); - - fireEvent.change(screen.getByLabelText("Name *"), { - target: { value: "new-tool" }, - }); - fireEvent.change(screen.getByLabelText("Display Name *"), { - target: { value: "New Tool" }, - }); - fireEvent.change(screen.getByLabelText("Default Port *"), { - target: { value: "8080" }, - }); - fireEvent.change(screen.getByLabelText(/compose template/i), { - target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" }, - }); - - fireEvent.click(screen.getByRole("button", { name: /create$/i })); - - await waitFor(() => { - expect(createMock).toHaveBeenCalledWith( - expect.objectContaining({ - name: "new-tool", - display_name: "New Tool", - definition_type: "compose", - compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx", - }) - ); - }); - }); - - it("creates tool type with dockerfile definition", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); - - fireEvent.change(screen.getByLabelText("Name *"), { - target: { value: "docker-tool" }, - }); - fireEvent.change(screen.getByLabelText("Display Name *"), { - target: { value: "Docker Tool" }, - }); - fireEvent.change(screen.getByLabelText("Default Port *"), { - target: { value: "3000" }, - }); - - // Switch to dockerfile - fireEvent.change(screen.getByLabelText("Definition Type"), { - target: { value: "dockerfile" }, - }); - - fireEvent.change(screen.getByLabelText("Dockerfile *"), { - target: { value: "FROM python:3.11\\nRUN pip install flask" }, - }); - - fireEvent.click(screen.getByRole("button", { name: /create$/i })); - - await waitFor(() => { - expect(createMock).toHaveBeenCalledWith( - expect.objectContaining({ - name: "docker-tool", - definition_type: "dockerfile", - dockerfile_template: "FROM python:3.11\\nRUN pip install flask", - }) - ); - }); - }); - - it("shows readiness probe fields", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); - - expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument(); - expect(screen.getByText(/timeout/i)).toBeInTheDocument(); - expect(screen.getByText(/interval/i)).toBeInTheDocument(); - }); - - it("opens config creation form", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /configs/i })); @@ -321,8 +169,8 @@ describe("ToolWorkshopPage", () => { fireEvent.click(screen.getByRole("button", { name: /add config/i })); - expect(screen.getByLabelText(/key/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/value/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., OPENAI_API_KEY")).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Enter value/i)).toBeInTheDocument(); }); it("creates config with advanced fields", async () => { @@ -337,6 +185,12 @@ describe("ToolWorkshopPage", () => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); + fireEvent.click(screen.getByText("VS Code Server")); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("button", { name: /configs/i })); await waitFor(() => { @@ -345,16 +199,16 @@ describe("ToolWorkshopPage", () => { fireEvent.click(screen.getByRole("button", { name: /add config/i })); - fireEvent.change(screen.getByLabelText(/key/i), { + fireEvent.change(screen.getByPlaceholderText("e.g., OPENAI_API_KEY"), { target: { value: "MY_CONFIG" }, }); - fireEvent.change(screen.getByLabelText(/value/i), { + fireEvent.change(screen.getByPlaceholderText(/Enter value/i), { target: { value: "my-value" }, }); - fireEvent.change(screen.getByLabelText(/port override/i), { + fireEvent.change(screen.getByPlaceholderText("e.g., 8080"), { target: { value: "9090" }, }); - fireEvent.change(screen.getByLabelText(/start command/i), { + fireEvent.change(screen.getByPlaceholderText("e.g., npm start"), { target: { value: "python app.py" }, }); @@ -384,6 +238,12 @@ describe("ToolWorkshopPage", () => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); + fireEvent.click(screen.getByText("VS Code Server")); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("button", { name: /folders/i })); await waitFor(() => { @@ -392,8 +252,8 @@ describe("ToolWorkshopPage", () => { fireEvent.click(screen.getByRole("button", { name: /create folder/i })); - expect(screen.getByLabelText("Name *")).toBeInTheDocument(); - expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., my-dotfiles")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., /home/user")).toBeInTheDocument(); }); it("creates config folder successfully", async () => { @@ -408,6 +268,12 @@ describe("ToolWorkshopPage", () => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); + fireEvent.click(screen.getByText("VS Code Server")); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("button", { name: /folders/i })); await waitFor(() => { @@ -416,10 +282,10 @@ describe("ToolWorkshopPage", () => { fireEvent.click(screen.getByRole("button", { name: /create folder/i })); - fireEvent.change(screen.getByLabelText("Name *"), { + fireEvent.change(screen.getByPlaceholderText("e.g., my-dotfiles"), { target: { value: "new-folder" }, }); - fireEvent.change(screen.getByLabelText("Mount Path *"), { + fireEvent.change(screen.getByPlaceholderText("e.g., /home/user"), { target: { value: "/home/dev" }, }); @@ -447,6 +313,12 @@ describe("ToolWorkshopPage", () => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); + fireEvent.click(screen.getByText("VS Code Server")); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("button", { name: /folders/i })); await waitFor(() => { diff --git a/apps/web/src/pages/tool-workshop.tsx b/apps/web/src/pages/tool-workshop.tsx index e3980f8..a59773a 100644 --- a/apps/web/src/pages/tool-workshop.tsx +++ b/apps/web/src/pages/tool-workshop.tsx @@ -28,24 +28,28 @@ import { type UpdateConfigFolderRequest, } from "../api/config_folders"; -type Tab = "types" | "configs" | "folders"; +type RightPanelTab = "details" | "configs" | "folders"; type Status = "loading" | "ready" | "error"; export const ToolWorkshopPage = () => { - const [activeTab, setActiveTab] = useState("types"); const [status, setStatus] = useState("loading"); const [toolTypes, setToolTypes] = useState([]); const [configs, setConfigs] = useState([]); const [folders, setFolders] = useState([]); - // Tool Type state - const [selectedToolType, setSelectedToolType] = useState(null); + // Selection state + const [selectedToolTypeId, setSelectedToolTypeId] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [rightPanelTab, setRightPanelTab] = useState("details"); + + // Tool Type form state const [toolTypeForm, setToolTypeForm] = useState({ name: "", display_name: "", description: "", category: "", - interfaces: [] as string[], + interface_type: "web" as "web" | "terminal", + requires_port: true, default_port: "", definition_type: "compose" as "compose" | "dockerfile", compose_template: "", @@ -56,12 +60,10 @@ export const ToolWorkshopPage = () => { required_variables: "", }); const [toolTypeError, setToolTypeError] = useState(null); - const [showToolTypeForm, setShowToolTypeForm] = useState(false); + const [toolTypeDirty, setToolTypeDirty] = useState(false); - // Config state - const [selectedConfig, setSelectedConfig] = useState(null); + // Config form state const [configForm, setConfigForm] = useState({ - tool_type_id: "", key: "", value: "", config_type: "env", @@ -74,9 +76,9 @@ export const ToolWorkshopPage = () => { }); const [configError, setConfigError] = useState(null); const [showConfigForm, setShowConfigForm] = useState(false); + const [selectedConfig, setSelectedConfig] = useState(null); - // Folder state - const [selectedFolder, setSelectedFolder] = useState(null); + // Folder form state const [folderForm, setFolderForm] = useState({ name: "", description: "", @@ -86,6 +88,11 @@ export const ToolWorkshopPage = () => { }); const [folderError, setFolderError] = useState(null); const [showFolderForm, setShowFolderForm] = useState(false); + const [selectedFolder, setSelectedFolder] = useState(null); + + const selectedToolType = (toolTypes || []).find((t) => t.id === selectedToolTypeId) || null; + const toolConfigs = (configs || []).filter((c) => c.tool_type_id === selectedToolTypeId); + const toolFolders = folders || []; // Config folders are global, not per-tool-type in current API const loadData = useCallback(async () => { setStatus("loading"); @@ -95,9 +102,9 @@ export const ToolWorkshopPage = () => { listToolConfigs(), listConfigFolders(), ]); - setToolTypes(types); - setConfigs(cfgs); - setFolders(fldrs); + setToolTypes(types || []); + setConfigs(cfgs || []); + setFolders(fldrs || []); setStatus("ready"); } catch { setStatus("error"); @@ -108,14 +115,14 @@ export const ToolWorkshopPage = () => { void loadData(); }, [loadData]); - // Tool Type handlers - const openCreateToolType = () => { + const resetToolTypeForm = () => { setToolTypeForm({ name: "", display_name: "", description: "", category: "", - interfaces: [], + interface_type: "web", + requires_port: true, default_port: "", definition_type: "compose", compose_template: "", @@ -126,17 +133,17 @@ export const ToolWorkshopPage = () => { required_variables: "", }); setToolTypeError(null); - setShowToolTypeForm(true); - setSelectedToolType(null); + setToolTypeDirty(false); }; - const openEditToolType = (toolType: ToolType) => { + const populateToolTypeForm = (toolType: ToolType) => { setToolTypeForm({ name: toolType.name, display_name: toolType.display_name, description: toolType.description || "", category: toolType.category || "", - interfaces: toolType.interfaces || [], + interface_type: (toolType.interface_type as "web" | "terminal") || "web", + requires_port: toolType.requires_port ?? true, default_port: toolType.default_port?.toString() || "", definition_type: toolType.definition_type || "compose", compose_template: toolType.compose_template || "", @@ -147,8 +154,49 @@ export const ToolWorkshopPage = () => { required_variables: toolType.required_variables?.join(", ") || "", }); setToolTypeError(null); - setShowToolTypeForm(true); - setSelectedToolType(toolType); + setToolTypeDirty(false); + }; + + const handleSelectToolType = (toolType: ToolType | null) => { + if (toolTypeDirty) { + if (!window.confirm("You have unsaved changes. Discard them?")) { + return; + } + } + if (toolType) { + setSelectedToolTypeId(toolType.id); + setIsCreating(false); + populateToolTypeForm(toolType); + } else { + setSelectedToolTypeId(null); + } + setRightPanelTab("details"); + setShowConfigForm(false); + setShowFolderForm(false); + }; + + const handleCreateNew = () => { + if (toolTypeDirty) { + if (!window.confirm("You have unsaved changes. Discard them?")) { + return; + } + } + setSelectedToolTypeId(null); + setIsCreating(true); + resetToolTypeForm(); + setRightPanelTab("details"); + setShowConfigForm(false); + setShowFolderForm(false); + }; + + const extractErrorMessage = (err: unknown): string => { + const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } }; + const detail = axiosError?.response?.data?.detail; + if (typeof detail === 'string') return detail; + if (Array.isArray(detail)) { + return detail.map(d => typeof d === 'string' ? d : d.msg || JSON.stringify(d)).join(', '); + } + return "Failed to save"; }; const handleToolTypeSubmit = async (e: React.FormEvent) => { @@ -160,7 +208,7 @@ export const ToolWorkshopPage = () => { return; } - if (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port))) { + if (toolTypeForm.requires_port && (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port)))) { setToolTypeError("Default port is required and must be a number"); return; } @@ -188,13 +236,33 @@ export const ToolWorkshopPage = () => { : undefined; try { - if (selectedToolType) { + if (isCreating) { + const input: CreateToolTypeRequest = { + name: toolTypeForm.name.trim(), + display_name: toolTypeForm.display_name.trim(), + description: toolTypeForm.description.trim() || undefined, + category: toolTypeForm.category.trim() || undefined, + interface_type: toolTypeForm.interface_type, + requires_port: toolTypeForm.requires_port, + default_port: toolTypeForm.requires_port ? Number(toolTypeForm.default_port) : 0, + 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, + }; + const newTool = await createToolType(input); + setIsCreating(false); + setSelectedToolTypeId(newTool.id); + setToolTypeDirty(false); + } else 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), + interface_type: toolTypeForm.interface_type, + requires_port: toolTypeForm.requires_port, + default_port: toolTypeForm.requires_port ? Number(toolTypeForm.default_port) : 0, definition_type: toolTypeForm.definition_type, compose_template: toolTypeForm.definition_type === "compose" ? template : undefined, dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined, @@ -202,28 +270,11 @@ export const ToolWorkshopPage = () => { 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); + setToolTypeDirty(false); } - setShowToolTypeForm(false); - setSelectedToolType(null); await loadData(); } catch (err) { - const axiosError = err as { response?: { data?: { detail?: string } } }; - setToolTypeError(axiosError?.response?.data?.detail || "Failed to save tool type"); + setToolTypeError(extractErrorMessage(err)); } }; @@ -231,6 +282,11 @@ export const ToolWorkshopPage = () => { if (!window.confirm("Delete this tool type? All associated configs will be removed.")) return; try { await deleteToolType(id); + if (selectedToolTypeId === id) { + setSelectedToolTypeId(null); + setIsCreating(false); + resetToolTypeForm(); + } await loadData(); } catch { alert("Failed to delete tool type"); @@ -238,9 +294,8 @@ export const ToolWorkshopPage = () => { }; // Config handlers - const openCreateConfig = () => { + const resetConfigForm = () => { setConfigForm({ - tool_type_id: toolTypes[0]?.id || "", key: "", value: "", config_type: "env", @@ -252,13 +307,16 @@ export const ToolWorkshopPage = () => { volumes_json: "[]", }); setConfigError(null); - setShowConfigForm(true); setSelectedConfig(null); }; + const openCreateConfig = () => { + resetConfigForm(); + setShowConfigForm(true); + }; + const openEditConfig = (config: ToolConfig) => { setConfigForm({ - tool_type_id: config.tool_type_id, key: config.key, value: config.value, config_type: config.config_type, @@ -278,12 +336,11 @@ export const ToolWorkshopPage = () => { e.preventDefault(); setConfigError(null); - if (!configForm.tool_type_id || !configForm.key.trim()) { + if (!selectedToolTypeId || !configForm.key.trim()) { setConfigError("Tool type and key are required"); return; } - // Validate JSON fields let envVars: Record | undefined; let volumes: Array<{ source: string; target: string; type?: string }> | undefined; @@ -306,7 +363,7 @@ export const ToolWorkshopPage = () => { } const data: CreateToolConfigRequest = { - tool_type_id: configForm.tool_type_id, + tool_type_id: selectedToolTypeId, key: configForm.key.trim(), value: configForm.value, config_type: configForm.config_type, @@ -326,10 +383,10 @@ export const ToolWorkshopPage = () => { } setShowConfigForm(false); setSelectedConfig(null); + resetConfigForm(); await loadData(); } catch (err) { - const axiosError = err as { response?: { data?: { detail?: string } } }; - setConfigError(axiosError?.response?.data?.detail || "Failed to save config"); + setConfigError(extractErrorMessage(err)); } }; @@ -344,7 +401,7 @@ export const ToolWorkshopPage = () => { }; // Folder handlers - const openCreateFolder = () => { + const resetFolderForm = () => { setFolderForm({ name: "", description: "", @@ -353,10 +410,14 @@ export const ToolWorkshopPage = () => { is_active: true, }); setFolderError(null); - setShowFolderForm(true); setSelectedFolder(null); }; + const openCreateFolder = () => { + resetFolderForm(); + setShowFolderForm(true); + }; + const openEditFolder = (folder: ConfigFolder) => { setFolderForm({ name: folder.name, @@ -405,10 +466,10 @@ export const ToolWorkshopPage = () => { } setShowFolderForm(false); setSelectedFolder(null); + resetFolderForm(); await loadData(); } catch (err) { - const axiosError = err as { response?: { data?: { detail?: string } } }; - setFolderError(axiosError?.response?.data?.detail || "Failed to save folder"); + setFolderError(extractErrorMessage(err)); } }; @@ -442,86 +503,238 @@ export const ToolWorkshopPage = () => { } return ( -
-
-

Tool Workshop

-
+
+ {/* Left Sidebar - Tool List */} +
+
+

Tool Workshop

+

+ {toolTypes.length} tool type{toolTypes.length !== 1 ? "s" : ""} +

+
- {/* Tabs */} -
- {(["types", "configs", "folders"] as Tab[]).map((tab) => ( +
+ {toolTypes.map((toolType) => ( + + )} + + ))} +
+ +
- ))} +
- {/* Tool Types Tab */} - {activeTab === "types" && ( -
-
-

Tool Types

- + {/* Right Panel - Editor */} +
+ {!selectedToolTypeId && !isCreating ? ( +
+
+ +
+

Select a tool type

+

Choose a tool from the list to edit, or create a new one.

+ ) : ( +
+ {/* Header */} +
+

+ {isCreating ? "Create Tool Type" : selectedToolType?.display_name} +

+ {!isCreating && ( +

+ {selectedToolType?.name} · {selectedToolType?.definition_type} · {selectedToolType?.interface_type === "web" ? `Port ${selectedToolType?.default_port}` : "Terminal"} +

+ )} +
- {showToolTypeForm && ( -
-

{selectedToolType ? "Edit" : "Create"} Tool Type

-
+ {/* Right Panel Tabs */} +
+ {(["details", "configs", "folders"] as RightPanelTab[]).map((tab) => ( + + ))} +
+ + {/* Details Tab */} + {rightPanelTab === "details" && ( +
-
- - setToolTypeForm({ ...toolTypeForm, name: e.target.value })} - disabled={!!selectedToolType} - placeholder="e.g., code-server" - className="form-input" - required - /> -
- -
- - setToolTypeForm({ ...toolTypeForm, display_name: e.target.value })} - placeholder="e.g., VS Code Server" - className="form-input" - required - /> +
+
+ + { + setToolTypeForm({ ...toolTypeForm, name: e.target.value }); + setToolTypeDirty(true); + }} + disabled={!isCreating} + placeholder="e.g., code-server" + className="form-input" + required + /> +
+
+ + { + setToolTypeForm({ ...toolTypeForm, display_name: e.target.value }); + setToolTypeDirty(true); + }} + placeholder="e.g., VS Code Server" + className="form-input" + required + /> +
@@ -530,58 +743,70 @@ export const ToolWorkshopPage = () => { id="tool-type-description" type="text" value={toolTypeForm.description} - onChange={(e) => setToolTypeForm({ ...toolTypeForm, description: e.target.value })} + onChange={(e) => { + setToolTypeForm({ ...toolTypeForm, description: e.target.value }); + setToolTypeDirty(true); + }} placeholder="Optional description" className="form-input" />
-
- - setToolTypeForm({ ...toolTypeForm, category: e.target.value })} - placeholder="e.g., editor, notebook, ai-assistant" - className="form-input" - /> -
- -
- -
- {["web", "terminal"].map((iface) => ( - - ))} +
+
+ + { + setToolTypeForm({ ...toolTypeForm, category: e.target.value }); + setToolTypeDirty(true); + }} + placeholder="e.g., editor, notebook, ai-assistant" + className="form-input" + /> +
+
+ +
-
- - setToolTypeForm({ ...toolTypeForm, default_port: e.target.value })} - placeholder="e.g., 8443" - className="form-input" - required - /> -
+ {toolTypeForm.requires_port && ( +
+ + { + setToolTypeForm({ ...toolTypeForm, default_port: e.target.value }); + setToolTypeDirty(true); + }} + placeholder="e.g., 8443" + className="form-input" + required + /> +
+ )}
@@ -594,10 +819,12 @@ export const ToolWorkshopPage = () => { } else { setToolTypeForm({ ...toolTypeForm, dockerfile_template: e.target.value }); } + setToolTypeDirty(true); }} - rows={10} + rows={12} placeholder={toolTypeForm.definition_type === "compose" ? "version: '3.8'\nservices:\n app:\n image: ..." : "FROM node:18\nWORKDIR /app\n..."} className="form-input" + style={{ fontFamily: "monospace", fontSize: "0.875rem" }} required />
@@ -608,7 +835,10 @@ export const ToolWorkshopPage = () => { id="readiness-command" type="text" value={toolTypeForm.readiness_command} - onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })} + onChange={(e) => { + setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value }); + setToolTypeDirty(true); + }} placeholder="e.g., curl -f http://localhost:8080" className="form-input" /> @@ -621,7 +851,10 @@ export const ToolWorkshopPage = () => { id="readiness-timeout" type="number" value={toolTypeForm.readiness_timeout} - onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })} + onChange={(e) => { + setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value }); + setToolTypeDirty(true); + }} className="form-input" />
@@ -631,7 +864,10 @@ export const ToolWorkshopPage = () => { id="readiness-interval" type="number" value={toolTypeForm.readiness_interval} - onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })} + onChange={(e) => { + setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value }); + setToolTypeDirty(true); + }} className="form-input" />
@@ -642,7 +878,10 @@ export const ToolWorkshopPage = () => { setToolTypeForm({ ...toolTypeForm, required_variables: e.target.value })} + onChange={(e) => { + setToolTypeForm({ ...toolTypeForm, required_variables: e.target.value }); + setToolTypeDirty(true); + }} placeholder="REPO_PATH, TOOL_NAME" className="form-input" /> @@ -650,373 +889,337 @@ export const ToolWorkshopPage = () => { {toolTypeError &&

{toolTypeError}

} -
- - +
+ + {toolTypeDirty && ( + + )}
-
- )} + )} -
- {toolTypes.map((toolType) => ( -
-
-

{toolType.display_name}

- {toolType.is_builtin && Built-in} -
-

{toolType.description || "No description"}

-
- Type: {toolType.definition_type} - Port: {toolType.default_port || "N/A"} - {toolType.interfaces?.length > 0 && ( - Interfaces: {toolType.interfaces.join(", ")} - )} - {toolType.category && Category: {toolType.category}} - {toolType.readiness_probe && ( - Probe: {toolType.readiness_probe.command} - )} -
-
- {!toolType.is_builtin && ( - <> - - - - )} -
-
- ))} -
-
- )} - - {/* Configs Tab */} - {activeTab === "configs" && ( -
-
-

Tool Configurations

- -
- - {showConfigForm && ( -
-

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

-
-
- - + {/* Configs Tab */} + {rightPanelTab === "configs" && selectedToolTypeId && ( +
+
+

Configurations for {selectedToolType?.display_name}

+
-
- - setConfigForm({ ...configForm, key: e.target.value })} - placeholder="e.g., OPENAI_API_KEY" - className="form-input" - required - /> -
+ {showConfigForm && ( +
+

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

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