refactor: remove Tool Configs and Config Folders
These features are fully superseded by Config Profiles which provide: - Env vars, file mounts, port overrides, start commands, working dirs - Git mounts, profile composition, cycle detection - Default selection, project/tool-type scoping Changes: - Delete backend models: ToolConfig, ConfigFolder - Delete backend APIs: tool_configs.py, config_folders.py - Delete frontend API clients: tool_configs.ts, config_folders.ts - Remove Tool Config fetching from start_instance, use ConfigProfile only - Simplify merge_with_config to accept only profile (no tool_configs) - Remove configs/folders tabs from Tool Workshop page - Delete associated integration and unit tests - Add Alembic migration to drop tool_configs and config_folders tables Quality gates: backend tests 59 passed, frontend typecheck clean
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
"""drop tool_configs and config_folders tables
|
||||||
|
|
||||||
|
Revision ID: 2026_05_28_drop_tool_configs_and_config_folders
|
||||||
|
Revises: 2026_05_28_add_tool_definition_manifests
|
||||||
|
Create Date: 2026-05-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_28_drop_tool_configs_and_config_folders"
|
||||||
|
down_revision: Union[str, None] = "2026_05_28_add_tool_definition_manifests"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Drop tool_configs table if it exists
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_name = 'tool_configs'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if result.fetchone():
|
||||||
|
op.drop_table("tool_configs")
|
||||||
|
|
||||||
|
# Drop config_folders table if it exists
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_name = 'config_folders'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if result.fetchone():
|
||||||
|
op.drop_table("config_folders")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Recreate config_folders table
|
||||||
|
op.create_table(
|
||||||
|
"config_folders",
|
||||||
|
sa.Column("id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("mount_path", sa.String(1024), nullable=False),
|
||||||
|
sa.Column("files", sa.JSON(), default=dict, nullable=False),
|
||||||
|
sa.Column("project_overrides", sa.JSON(), default=dict, nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), default=True, nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recreate tool_configs table
|
||||||
|
op.create_table(
|
||||||
|
"tool_configs",
|
||||||
|
sa.Column("id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("tool_type_id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("project_id", sa.UUID(), nullable=True),
|
||||||
|
sa.Column("key", sa.String(255), nullable=False),
|
||||||
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("config_type", sa.String(20), default="env", nullable=False),
|
||||||
|
sa.Column("file_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("port_override", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("start_command", sa.Text(), nullable=True),
|
||||||
|
sa.Column("working_directory", sa.Text(), nullable=True),
|
||||||
|
sa.Column("environment_variables", sa.JSON(), default=dict, nullable=True),
|
||||||
|
sa.Column("volumes", sa.JSON(), default=list, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
"""Config folder API endpoints."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.api.shared_validators import validate_files as _validate_files, validate_mount_path as _validate_mount_path
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
|
||||||
from src.models.config_folder import ConfigFolder
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderCreate(BaseModel):
|
|
||||||
name: str = Field(description="Folder name (unique per user)")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
mount_path: str = Field(description="Default mount path in container")
|
|
||||||
files: dict = Field(default_factory=dict, description="Files as {path: content}")
|
|
||||||
|
|
||||||
@field_validator("mount_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_mount_path(cls, v: str) -> str:
|
|
||||||
return _validate_mount_path(v)
|
|
||||||
|
|
||||||
@field_validator("files")
|
|
||||||
@classmethod
|
|
||||||
def validate_files(cls, v: dict) -> dict:
|
|
||||||
return _validate_files(v)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderUpdate(BaseModel):
|
|
||||||
name: str | None = Field(default=None, description="Folder name")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
mount_path: str | None = Field(default=None, description="Default mount path")
|
|
||||||
files: dict | None = Field(default=None, description="Files as {path: content}")
|
|
||||||
is_active: bool | None = Field(default=None, description="Active/inactive toggle")
|
|
||||||
|
|
||||||
@field_validator("mount_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
|
||||||
return _validate_mount_path(v)
|
|
||||||
|
|
||||||
@field_validator("files")
|
|
||||||
@classmethod
|
|
||||||
def validate_files(cls, v: dict | None) -> dict | None:
|
|
||||||
return _validate_files(v)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectOverrideCreate(BaseModel):
|
|
||||||
mount_path: str | None = Field(default=None, description="Override mount path")
|
|
||||||
files: dict = Field(default_factory=dict, description="Override files")
|
|
||||||
|
|
||||||
@field_validator("mount_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
|
||||||
return _validate_mount_path(v)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_id: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
mount_path: str
|
|
||||||
files: dict
|
|
||||||
project_overrides: dict | None
|
|
||||||
is_active: bool
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
|
|
||||||
async def list_config_folders(
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""List config folders for the current user."""
|
|
||||||
query = select(ConfigFolder).where(ConfigFolder.user_id == user_id)
|
|
||||||
result = await session.execute(query)
|
|
||||||
folders = result.scalars().all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"folders": [
|
|
||||||
{
|
|
||||||
"id": str(f.id),
|
|
||||||
"user_id": str(f.user_id),
|
|
||||||
"name": f.name,
|
|
||||||
"description": f.description,
|
|
||||||
"mount_path": f.mount_path,
|
|
||||||
"files": f.files,
|
|
||||||
"project_overrides": f.project_overrides,
|
|
||||||
"is_active": f.is_active,
|
|
||||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
|
||||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
|
||||||
}
|
|
||||||
for f in folders
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_config_folder(
|
|
||||||
data: ConfigFolderCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Create a config folder."""
|
|
||||||
# Check for duplicate name
|
|
||||||
existing = await session.scalar(
|
|
||||||
select(ConfigFolder).where(
|
|
||||||
ConfigFolder.user_id == user_id,
|
|
||||||
ConfigFolder.name == data.name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"config folder with name '{data.name}' already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
folder = ConfigFolder(
|
|
||||||
user_id=user_id,
|
|
||||||
name=data.name,
|
|
||||||
description=data.description,
|
|
||||||
mount_path=data.mount_path,
|
|
||||||
files=data.files,
|
|
||||||
)
|
|
||||||
session.add(folder)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"user_id": str(folder.user_id),
|
|
||||||
"name": folder.name,
|
|
||||||
"description": folder.description,
|
|
||||||
"mount_path": folder.mount_path,
|
|
||||||
"files": folder.files,
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
"is_active": folder.is_active,
|
|
||||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
|
||||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.")
|
|
||||||
async def update_config_folder(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
data: ConfigFolderUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Update a config folder."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
if data.name is not None:
|
|
||||||
folder.name = data.name
|
|
||||||
if data.description is not None:
|
|
||||||
folder.description = data.description
|
|
||||||
if data.mount_path is not None:
|
|
||||||
folder.mount_path = data.mount_path
|
|
||||||
if data.files is not None:
|
|
||||||
folder.files = data.files
|
|
||||||
if data.is_active is not None:
|
|
||||||
folder.is_active = data.is_active
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"user_id": str(folder.user_id),
|
|
||||||
"name": folder.name,
|
|
||||||
"description": folder.description,
|
|
||||||
"mount_path": folder.mount_path,
|
|
||||||
"files": folder.files,
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
"is_active": folder.is_active,
|
|
||||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
|
||||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_config_folder(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
"""Delete a config folder."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
await session.delete(folder)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectOverrideWithId(ProjectOverrideCreate):
|
|
||||||
project_id: uuid.UUID = Field(description="Project ID for the override")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
|
|
||||||
async def get_config_folder(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Get a config folder by ID."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"user_id": str(folder.user_id),
|
|
||||||
"name": folder.name,
|
|
||||||
"description": folder.description,
|
|
||||||
"mount_path": folder.mount_path,
|
|
||||||
"files": folder.files,
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
"is_active": folder.is_active,
|
|
||||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
|
||||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
|
|
||||||
async def add_project_override(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
data: ProjectOverrideWithId,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Add a project override to a config folder."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
# Initialize project_overrides if None
|
|
||||||
if folder.project_overrides is None:
|
|
||||||
folder.project_overrides = {}
|
|
||||||
|
|
||||||
# Add/update override
|
|
||||||
override_data = {}
|
|
||||||
if data.mount_path is not None:
|
|
||||||
override_data["mount_path"] = data.mount_path
|
|
||||||
if data.files is not None:
|
|
||||||
override_data["files"] = data.files
|
|
||||||
|
|
||||||
# Use a copy to trigger SQLAlchemy change detection on JSONB
|
|
||||||
current_overrides = dict(folder.project_overrides or {})
|
|
||||||
current_overrides[str(data.project_id)] = override_data
|
|
||||||
folder.project_overrides = current_overrides
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.")
|
|
||||||
async def update_project_override(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
data: ProjectOverrideCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Update a project override."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
# Initialize project_overrides if None
|
|
||||||
if folder.project_overrides is None:
|
|
||||||
folder.project_overrides = {}
|
|
||||||
|
|
||||||
# Update override
|
|
||||||
current_overrides = dict(folder.project_overrides or {})
|
|
||||||
override_data = current_overrides.get(str(project_id), {})
|
|
||||||
if data.mount_path is not None:
|
|
||||||
override_data["mount_path"] = data.mount_path
|
|
||||||
if data.files is not None:
|
|
||||||
override_data["files"] = data.files
|
|
||||||
|
|
||||||
current_overrides[str(project_id)] = override_data
|
|
||||||
folder.project_overrides = current_overrides
|
|
||||||
|
|
||||||
# Mark the field as modified to ensure SQLAlchemy detects the change
|
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
|
||||||
flag_modified(folder, "project_overrides")
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"project_overrides": folder.project_overrides,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.")
|
|
||||||
async def remove_project_override(
|
|
||||||
folder_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
"""Remove a project override."""
|
|
||||||
folder = await session.get(ConfigFolder, folder_id)
|
|
||||||
if folder is None or folder.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
|
||||||
|
|
||||||
# Remove override if exists
|
|
||||||
current_overrides = dict(folder.project_overrides or {})
|
|
||||||
if str(project_id) in current_overrides:
|
|
||||||
del current_overrides[str(project_id)]
|
|
||||||
folder.project_overrides = current_overrides
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(folder)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(folder.id),
|
|
||||||
"project_overrides": folder.project_overrides or {},
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
"""Tool configuration API endpoints."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
|
||||||
from src.models.tool_config import ToolConfig
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigCreate(BaseModel):
|
|
||||||
tool_type_id: str = Field(description="UUID of the tool type")
|
|
||||||
project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config")
|
|
||||||
key: str = Field(description="Config key name")
|
|
||||||
value: str = Field(description="Config value")
|
|
||||||
config_type: str = Field(default="env", description="Type: env or file")
|
|
||||||
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
|
||||||
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
|
||||||
start_command: str | None = Field(default=None, description="Override container start command")
|
|
||||||
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
|
||||||
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
|
||||||
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
|
||||||
|
|
||||||
@field_validator("port_override")
|
|
||||||
@classmethod
|
|
||||||
def validate_port(cls, v: int | None) -> int | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if v < 1 or v > 65535:
|
|
||||||
raise ValueError("Port must be between 1 and 65535")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("environment_variables")
|
|
||||||
@classmethod
|
|
||||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
|
||||||
return _validate_env_vars(v)
|
|
||||||
|
|
||||||
@field_validator("volumes")
|
|
||||||
@classmethod
|
|
||||||
def validate_volumes(cls, v: list | None) -> list | None:
|
|
||||||
return _validate_volumes(v)
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigUpdate(BaseModel):
|
|
||||||
key: str | None = Field(default=None, description="Config key name")
|
|
||||||
value: str | None = Field(default=None, description="Config value")
|
|
||||||
config_type: str | None = Field(default=None, description="Type: env or file")
|
|
||||||
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
|
||||||
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
|
||||||
start_command: str | None = Field(default=None, description="Override container start command")
|
|
||||||
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
|
||||||
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
|
||||||
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
|
||||||
|
|
||||||
@field_validator("port_override")
|
|
||||||
@classmethod
|
|
||||||
def validate_port(cls, v: int | None) -> int | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if v < 1 or v > 65535:
|
|
||||||
raise ValueError("Port must be between 1 and 65535")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("environment_variables")
|
|
||||||
@classmethod
|
|
||||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
|
||||||
return _validate_env_vars(v)
|
|
||||||
|
|
||||||
@field_validator("volumes")
|
|
||||||
@classmethod
|
|
||||||
def validate_volumes(cls, v: list | None) -> list | None:
|
|
||||||
return _validate_volumes(v)
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
tool_type_id: str
|
|
||||||
project_id: str | None
|
|
||||||
key: str
|
|
||||||
value: str
|
|
||||||
config_type: str
|
|
||||||
file_path: str | None
|
|
||||||
port_override: int | None
|
|
||||||
start_command: str | None
|
|
||||||
working_directory: str | None
|
|
||||||
environment_variables: dict | None
|
|
||||||
volumes: list[dict] | None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
|
||||||
async def list_configs(
|
|
||||||
tool_type_id: str | None = None,
|
|
||||||
project_id: str | None = None,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list:
|
|
||||||
"""List tool configs for the current user."""
|
|
||||||
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
|
|
||||||
|
|
||||||
if tool_type_id:
|
|
||||||
query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id))
|
|
||||||
if project_id:
|
|
||||||
query = query.where(ToolConfig.project_id == uuid.UUID(project_id))
|
|
||||||
else:
|
|
||||||
# If no project specified, get only global configs (project_id is None)
|
|
||||||
query = query.where(ToolConfig.project_id.is_(None))
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
configs = result.scalars().all()
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": str(c.id),
|
|
||||||
"tool_type_id": str(c.tool_type_id),
|
|
||||||
"project_id": str(c.project_id) if c.project_id else None,
|
|
||||||
"key": c.key,
|
|
||||||
"value": c.value,
|
|
||||||
"config_type": c.config_type,
|
|
||||||
"file_path": c.file_path,
|
|
||||||
"port_override": c.port_override,
|
|
||||||
"start_command": c.start_command,
|
|
||||||
"working_directory": c.working_directory,
|
|
||||||
"environment_variables": c.environment_variables,
|
|
||||||
"volumes": c.volumes,
|
|
||||||
}
|
|
||||||
for c in configs
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_config(
|
|
||||||
data: ToolConfigCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Create a tool config."""
|
|
||||||
# Verify tool type exists
|
|
||||||
tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id))
|
|
||||||
if tool_type is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
|
||||||
|
|
||||||
# Check for existing config with same key
|
|
||||||
query = select(ToolConfig).where(
|
|
||||||
ToolConfig.user_id == user_id,
|
|
||||||
ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id),
|
|
||||||
ToolConfig.key == data.key,
|
|
||||||
)
|
|
||||||
if data.project_id:
|
|
||||||
query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id))
|
|
||||||
else:
|
|
||||||
query = query.where(ToolConfig.project_id.is_(None))
|
|
||||||
|
|
||||||
existing = await session.scalar(query)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"config with key '{data.key}' already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
config = ToolConfig(
|
|
||||||
user_id=user_id,
|
|
||||||
tool_type_id=uuid.UUID(data.tool_type_id),
|
|
||||||
project_id=uuid.UUID(data.project_id) if data.project_id else None,
|
|
||||||
key=data.key,
|
|
||||||
value=data.value,
|
|
||||||
config_type=data.config_type,
|
|
||||||
file_path=data.file_path,
|
|
||||||
port_override=data.port_override,
|
|
||||||
start_command=data.start_command,
|
|
||||||
working_directory=data.working_directory,
|
|
||||||
environment_variables=data.environment_variables,
|
|
||||||
volumes=data.volumes,
|
|
||||||
)
|
|
||||||
session.add(config)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(config)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(config.id),
|
|
||||||
"tool_type_id": str(config.tool_type_id),
|
|
||||||
"project_id": str(config.project_id) if config.project_id else None,
|
|
||||||
"key": config.key,
|
|
||||||
"value": config.value,
|
|
||||||
"config_type": config.config_type,
|
|
||||||
"file_path": config.file_path,
|
|
||||||
"port_override": config.port_override,
|
|
||||||
"start_command": config.start_command,
|
|
||||||
"working_directory": config.working_directory,
|
|
||||||
"environment_variables": config.environment_variables,
|
|
||||||
"volumes": config.volumes,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
|
|
||||||
async def update_config(
|
|
||||||
config_id: uuid.UUID,
|
|
||||||
data: ToolConfigUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Update a tool config."""
|
|
||||||
config = await session.get(ToolConfig, config_id)
|
|
||||||
if config is None or config.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
|
||||||
|
|
||||||
if data.key is not None:
|
|
||||||
config.key = data.key
|
|
||||||
if data.value is not None:
|
|
||||||
config.value = data.value
|
|
||||||
if data.config_type is not None:
|
|
||||||
config.config_type = data.config_type
|
|
||||||
if data.file_path is not None:
|
|
||||||
config.file_path = data.file_path
|
|
||||||
if data.port_override is not None:
|
|
||||||
config.port_override = data.port_override
|
|
||||||
if data.start_command is not None:
|
|
||||||
config.start_command = data.start_command
|
|
||||||
if data.working_directory is not None:
|
|
||||||
config.working_directory = data.working_directory
|
|
||||||
if data.environment_variables is not None:
|
|
||||||
config.environment_variables = data.environment_variables
|
|
||||||
if data.volumes is not None:
|
|
||||||
config.volumes = data.volumes
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(config)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(config.id),
|
|
||||||
"tool_type_id": str(config.tool_type_id),
|
|
||||||
"project_id": str(config.project_id) if config.project_id else None,
|
|
||||||
"key": config.key,
|
|
||||||
"value": config.value,
|
|
||||||
"config_type": config.config_type,
|
|
||||||
"file_path": config.file_path,
|
|
||||||
"port_override": config.port_override,
|
|
||||||
"start_command": config.start_command,
|
|
||||||
"working_directory": config.working_directory,
|
|
||||||
"environment_variables": config.environment_variables,
|
|
||||||
"volumes": config.volumes,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.")
|
|
||||||
async def get_default_configs(
|
|
||||||
tool_type_id: str,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Get suggested default configs for a tool type."""
|
|
||||||
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
|
||||||
if tool_type is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
|
||||||
|
|
||||||
# Return suggested defaults based on required_variables
|
|
||||||
defaults = []
|
|
||||||
for var in tool_type.required_variables:
|
|
||||||
defaults.append({
|
|
||||||
"key": var,
|
|
||||||
"value": "",
|
|
||||||
"config_type": "env",
|
|
||||||
"description": f"Required variable: {var}",
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tool_type_id": tool_type_id,
|
|
||||||
"suggested_configs": defaults,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.")
|
|
||||||
async def delete_config(
|
|
||||||
config_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
"""Delete a tool config."""
|
|
||||||
config = await session.get(ToolConfig, config_id)
|
|
||||||
if config is None or config.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
|
||||||
|
|
||||||
await session.delete(config)
|
|
||||||
await session.commit()
|
|
||||||
@@ -32,7 +32,6 @@ from src.models.config_profile import ConfigProfile
|
|||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.tool_config import ToolConfig
|
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.services.clone import check_dirty_state, clone_repository
|
from src.services.clone import check_dirty_state, clone_repository
|
||||||
@@ -976,7 +975,6 @@ async def _prepare_manifest_instance(
|
|||||||
instance: ToolInstance,
|
instance: ToolInstance,
|
||||||
instance_dir: str,
|
instance_dir: str,
|
||||||
repo_path: str,
|
repo_path: str,
|
||||||
configs: list,
|
|
||||||
env_vars: dict,
|
env_vars: dict,
|
||||||
extra_volumes: list,
|
extra_volumes: list,
|
||||||
working_directory: str | None,
|
working_directory: str | None,
|
||||||
@@ -1011,22 +1009,7 @@ async def _prepare_manifest_instance(
|
|||||||
manifest_def.id,
|
manifest_def.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Merge tool configs
|
manifest = merge_with_config(manifest)
|
||||||
tool_config_dicts = [
|
|
||||||
{
|
|
||||||
"config_type": c.config_type,
|
|
||||||
"key": c.key,
|
|
||||||
"value": c.value,
|
|
||||||
"file_path": c.file_path,
|
|
||||||
"port_override": c.port_override,
|
|
||||||
"start_command": c.start_command,
|
|
||||||
"working_directory": c.working_directory,
|
|
||||||
"environment_variables": c.environment_variables,
|
|
||||||
"volumes": c.volumes,
|
|
||||||
}
|
|
||||||
for c in configs
|
|
||||||
]
|
|
||||||
manifest = merge_with_config(manifest, tool_config_dicts)
|
|
||||||
|
|
||||||
# Resolve extra env and volumes from merge_with_config
|
# Resolve extra env and volumes from merge_with_config
|
||||||
extra_env = manifest.pop("_extra_env", {})
|
extra_env = manifest.pop("_extra_env", {})
|
||||||
@@ -1159,51 +1142,14 @@ async def start_instance(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info("Starting instance %s (name=%s)", instance.id, instance.name)
|
logger.info("Starting instance %s (name=%s)", instance.id, instance.name)
|
||||||
|
|
||||||
# Fetch tool configs for this tool type
|
# Runtime overrides populated by config profiles
|
||||||
env_vars = {}
|
env_vars = {}
|
||||||
config_files = {}
|
config_files = {}
|
||||||
port_override = None
|
port_override = None
|
||||||
start_command = None
|
start_command = None
|
||||||
working_directory = None
|
working_directory = None
|
||||||
extra_env_vars = {}
|
|
||||||
extra_volumes = []
|
extra_volumes = []
|
||||||
|
|
||||||
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()
|
|
||||||
logger.debug("Found %d tool configs for instance %s", len(configs), instance.id)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Handle new config fields
|
|
||||||
if config.port_override:
|
|
||||||
port_override = config.port_override
|
|
||||||
if config.start_command:
|
|
||||||
start_command = config.start_command
|
|
||||||
if config.working_directory:
|
|
||||||
working_directory = config.working_directory
|
|
||||||
if config.environment_variables:
|
|
||||||
extra_env_vars.update(config.environment_variables)
|
|
||||||
if config.volumes:
|
|
||||||
extra_volumes.extend(config.volumes)
|
|
||||||
|
|
||||||
# Merge extra env vars
|
|
||||||
env_vars.update(extra_env_vars)
|
|
||||||
|
|
||||||
# Apply selected config profile if any
|
# Apply selected config profile if any
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
if instance.selected_config_profile_id is not None:
|
if instance.selected_config_profile_id is not None:
|
||||||
@@ -1288,7 +1234,6 @@ async def start_instance(
|
|||||||
instance=instance,
|
instance=instance,
|
||||||
instance_dir=instance_dir,
|
instance_dir=instance_dir,
|
||||||
repo_path=repo_path,
|
repo_path=repo_path,
|
||||||
configs=configs,
|
|
||||||
env_vars=env_vars,
|
env_vars=env_vars,
|
||||||
extra_volumes=extra_volumes,
|
extra_volumes=extra_volumes,
|
||||||
working_directory=working_directory,
|
working_directory=working_directory,
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ from src.api.projects import router as projects_router
|
|||||||
from src.api.ssh_keys import router as ssh_keys_router
|
from src.api.ssh_keys import router as ssh_keys_router
|
||||||
from src.api.terminal import router as terminal_router
|
from src.api.terminal import router as terminal_router
|
||||||
from src.api.instance_proxy import router as instance_proxy_router
|
from src.api.instance_proxy import router as instance_proxy_router
|
||||||
from src.api.config_folders import router as config_folders_router
|
|
||||||
from src.api.config_profiles import router as config_profiles_router
|
from src.api.config_profiles import router as config_profiles_router
|
||||||
from src.api.tool_configs import router as tool_configs_router
|
|
||||||
from src.api.tool_definitions import router as tool_definitions_router
|
from src.api.tool_definitions import router as tool_definitions_router
|
||||||
from src.api.tool_instances import router as tool_instances_router
|
from src.api.tool_instances import router as tool_instances_router
|
||||||
from src.api.tool_instances import sessions_router
|
from src.api.tool_instances import sessions_router
|
||||||
@@ -130,10 +128,8 @@ app.include_router(git_repositories_router)
|
|||||||
app.include_router(user_config_router)
|
app.include_router(user_config_router)
|
||||||
app.include_router(tool_types_router)
|
app.include_router(tool_types_router)
|
||||||
app.include_router(tool_definitions_router)
|
app.include_router(tool_definitions_router)
|
||||||
app.include_router(config_folders_router)
|
|
||||||
app.include_router(config_profiles_router)
|
app.include_router(config_profiles_router)
|
||||||
app.include_router(tool_instances_router)
|
app.include_router(tool_instances_router)
|
||||||
app.include_router(tool_configs_router)
|
|
||||||
app.include_router(sessions_router)
|
app.include_router(sessions_router)
|
||||||
app.include_router(instance_proxy_router)
|
app.include_router(instance_proxy_router)
|
||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.models.config_folder import ConfigFolder
|
|
||||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
@@ -13,7 +12,6 @@ from src.models.user_config import UserConfig
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"ConfigFolder",
|
|
||||||
"ConfigProfile",
|
"ConfigProfile",
|
||||||
"ConfigProfileInclude",
|
"ConfigProfileInclude",
|
||||||
"GitRepository",
|
"GitRepository",
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
|
|
||||||
from sqlalchemy import Uuid as UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from src.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "config_folders"
|
|
||||||
|
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
||||||
files: Mapped[dict] = mapped_column(
|
|
||||||
JSON, default=dict, nullable=False
|
|
||||||
) # {"relative/path": "content", ...}
|
|
||||||
project_overrides: Mapped[dict | None] = mapped_column(
|
|
||||||
JSON, default=dict, nullable=True
|
|
||||||
) # {"project_id": {"mount_path": "...", "files": {...}}}
|
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, JSON, String, Text
|
|
||||||
from sqlalchemy import Uuid as UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "tool_configs"
|
|
||||||
|
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("users.id"), nullable=False
|
|
||||||
)
|
|
||||||
tool_type_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("tool_types.id"), nullable=False
|
|
||||||
)
|
|
||||||
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(), ForeignKey("projects.id"), nullable=True
|
|
||||||
)
|
|
||||||
key: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
value: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
config_type: Mapped[str] = mapped_column(
|
|
||||||
String(20), nullable=False, default="env"
|
|
||||||
) # "env" or "file"
|
|
||||||
file_path: Mapped[str | None] = mapped_column(
|
|
||||||
String(1024), nullable=True
|
|
||||||
) # Only for file type
|
|
||||||
port_override: Mapped[int | None] = mapped_column(nullable=True)
|
|
||||||
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
environment_variables: Mapped[dict | None] = mapped_column(
|
|
||||||
JSON, default=dict, nullable=True
|
|
||||||
)
|
|
||||||
volumes: Mapped[list[dict] | None] = mapped_column(
|
|
||||||
JSON, default=list, nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
|
||||||
project: Mapped["Project | None"] = relationship()
|
|
||||||
@@ -351,13 +351,12 @@ def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def merge_with_config(
|
def merge_with_config(
|
||||||
manifest: dict, tool_configs: list[dict], profile: dict | None = None
|
manifest: dict, profile: dict | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Merge ToolConfig and ConfigProfile overrides into a manifest.
|
"""Merge ConfigProfile overrides into a manifest.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
manifest: Base manifest from tool definition.
|
manifest: Base manifest from tool definition.
|
||||||
tool_configs: List of ToolConfig records.
|
|
||||||
profile: Resolved ConfigProfile (optional).
|
profile: Resolved ConfigProfile (optional).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -365,29 +364,9 @@ def merge_with_config(
|
|||||||
"""
|
"""
|
||||||
result = deepcopy(manifest)
|
result = deepcopy(manifest)
|
||||||
|
|
||||||
# Apply ToolConfigs
|
|
||||||
extra_env: dict[str, str] = {}
|
extra_env: dict[str, str] = {}
|
||||||
extra_volumes: list[dict] = []
|
extra_volumes: list[dict] = []
|
||||||
|
|
||||||
for config in tool_configs:
|
|
||||||
if config.get("config_type") == "env":
|
|
||||||
extra_env[config["key"]] = config["value"]
|
|
||||||
elif config.get("config_type") == "file" and config.get("file_path"):
|
|
||||||
# Files are handled outside the manifest (written to instance dir)
|
|
||||||
pass
|
|
||||||
if config.get("port_override"):
|
|
||||||
result["default_port"] = config["port_override"]
|
|
||||||
if config.get("start_command"):
|
|
||||||
result["runtime"] = result.get("runtime", {})
|
|
||||||
result["runtime"]["command"] = config["start_command"].split()
|
|
||||||
if config.get("working_directory"):
|
|
||||||
result["runtime"] = result.get("runtime", {})
|
|
||||||
result["runtime"]["working_dir"] = config["working_directory"]
|
|
||||||
if config.get("environment_variables"):
|
|
||||||
extra_env.update(config["environment_variables"])
|
|
||||||
if config.get("volumes"):
|
|
||||||
extra_volumes.extend(config["volumes"])
|
|
||||||
|
|
||||||
# Apply ConfigProfile
|
# Apply ConfigProfile
|
||||||
if profile:
|
if profile:
|
||||||
if profile.get("environment_variables"):
|
if profile.get("environment_variables"):
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
import uuid
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestConfigFoldersAPI:
|
|
||||||
"""Integration tests for config folders API."""
|
|
||||||
|
|
||||||
def test_list_config_folders_requires_authentication(self, test_client: TestClient) -> None:
|
|
||||||
"""Test that listing config folders requires authentication."""
|
|
||||||
response = test_client.get("/config-folders")
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
def test_list_config_folders_returns_user_folders(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that authenticated users can list their folders."""
|
|
||||||
response = authenticated_client.get("/config-folders")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert isinstance(data, dict)
|
|
||||||
assert "folders" in data
|
|
||||||
assert isinstance(data["folders"], list)
|
|
||||||
|
|
||||||
def test_create_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test creating a config folder."""
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "test-folder",
|
|
||||||
"description": "Test folder",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {"test.txt": "hello world"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["name"] == "test-folder"
|
|
||||||
assert data["mount_path"] == "/home/user"
|
|
||||||
assert data["files"] == {"test.txt": "hello world"}
|
|
||||||
|
|
||||||
def test_create_config_folder_duplicate_name(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that duplicate folder names are rejected."""
|
|
||||||
# Create first folder
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "duplicate-folder",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
# Try to create second with same name
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "duplicate-folder",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 409
|
|
||||||
|
|
||||||
def test_create_config_folder_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that folders exceeding 10MB are rejected."""
|
|
||||||
large_content = "x" * (11 * 1024 * 1024) # 11MB
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "large-folder",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {"large.txt": large_content},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
def test_create_config_folder_path_traversal_attack(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that path traversal in file paths is prevented."""
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "bad-folder",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {"../../../etc/passwd": "malicious"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
def test_get_config_folder_by_id(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test getting a config folder by ID."""
|
|
||||||
# Create folder first
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "get-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Get it back
|
|
||||||
response = authenticated_client.get(f"/config-folders/{folder_id}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["name"] == "get-test"
|
|
||||||
|
|
||||||
def test_get_config_folder_not_found(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test getting a non-existent folder."""
|
|
||||||
response = authenticated_client.get(f"/config-folders/{uuid.uuid4()}")
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
def test_update_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test updating a config folder."""
|
|
||||||
# Create folder first
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "update-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Update it
|
|
||||||
response = authenticated_client.put(
|
|
||||||
f"/config-folders/{folder_id}",
|
|
||||||
json={
|
|
||||||
"name": "updated-name",
|
|
||||||
"mount_path": "/workspace",
|
|
||||||
"files": {"new.txt": "content"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["name"] == "updated-name"
|
|
||||||
assert data["mount_path"] == "/workspace"
|
|
||||||
|
|
||||||
def test_delete_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test deleting a config folder."""
|
|
||||||
# Create folder first
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "delete-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Delete it
|
|
||||||
response = authenticated_client.delete(f"/config-folders/{folder_id}")
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
# Verify it's gone
|
|
||||||
get_response = authenticated_client.get(f"/config-folders/{folder_id}")
|
|
||||||
assert get_response.status_code == 404
|
|
||||||
|
|
||||||
def test_add_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test adding a project override."""
|
|
||||||
# Create folder first
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "override-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {"global.txt": "global"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
project_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Add override
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-folders/{folder_id}/overrides",
|
|
||||||
json={
|
|
||||||
"project_id": project_id,
|
|
||||||
"mount_path": "/workspace",
|
|
||||||
"files": {"project.txt": "project"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert project_id in data["project_overrides"]
|
|
||||||
|
|
||||||
def test_update_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test updating a project override."""
|
|
||||||
# Create folder with override
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "update-override-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
project_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Add override
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-folders/{folder_id}/overrides",
|
|
||||||
json={
|
|
||||||
"project_id": project_id,
|
|
||||||
"mount_path": "/workspace",
|
|
||||||
"files": {"old.txt": "old"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update override
|
|
||||||
response = authenticated_client.put(
|
|
||||||
f"/config-folders/{folder_id}/overrides/{project_id}",
|
|
||||||
json={
|
|
||||||
"mount_path": "/app",
|
|
||||||
"files": {"new.txt": "new"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["project_overrides"][project_id]["mount_path"] == "/app"
|
|
||||||
|
|
||||||
def test_delete_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test deleting a project override."""
|
|
||||||
# Create folder with override
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/config-folders",
|
|
||||||
json={
|
|
||||||
"name": "delete-override-test",
|
|
||||||
"mount_path": "/home/user",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
folder_id = create_response.json()["id"]
|
|
||||||
project_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Add override
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-folders/{folder_id}/overrides",
|
|
||||||
json={
|
|
||||||
"project_id": project_id,
|
|
||||||
"mount_path": "/workspace",
|
|
||||||
"files": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete override
|
|
||||||
response = authenticated_client.delete(
|
|
||||||
f"/config-folders/{folder_id}/overrides/{project_id}"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert project_id not in data["project_overrides"]
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestToolConfigsAPIExtended:
|
|
||||||
"""Integration tests for tool configs API with new fields."""
|
|
||||||
|
|
||||||
def test_create_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test creating a tool config with all new fields."""
|
|
||||||
# Create a tool type first
|
|
||||||
tool_response = authenticated_client.post(
|
|
||||||
"/tool-types",
|
|
||||||
json={
|
|
||||||
"name": "config-test-tool",
|
|
||||||
"display_name": "Config Test Tool",
|
|
||||||
"default_port": 8080,
|
|
||||||
"definition_type": "compose",
|
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
|
||||||
"required_variables": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
tool_id = tool_response.json()["id"]
|
|
||||||
|
|
||||||
# Create config with new fields
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/tool-configs",
|
|
||||||
json={
|
|
||||||
"tool_type_id": tool_id,
|
|
||||||
"key": "ADVANCED_CONFIG",
|
|
||||||
"value": "test-value",
|
|
||||||
"config_type": "env",
|
|
||||||
"port_override": 9090,
|
|
||||||
"start_command": "python app.py",
|
|
||||||
"working_directory": "/app",
|
|
||||||
"environment_variables": {"DEBUG": "true", "LOG_LEVEL": "debug"},
|
|
||||||
"volumes": [
|
|
||||||
{"source": "data", "target": "/data", "type": "bind"}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["key"] == "ADVANCED_CONFIG"
|
|
||||||
assert data["port_override"] == 9090
|
|
||||||
assert data["start_command"] == "python app.py"
|
|
||||||
assert data["working_directory"] == "/app"
|
|
||||||
assert data["environment_variables"] == {"DEBUG": "true", "LOG_LEVEL": "debug"}
|
|
||||||
assert data["volumes"] == [{"source": "data", "target": "/data", "type": "bind"}]
|
|
||||||
|
|
||||||
def test_create_tool_config_invalid_port(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that invalid port numbers are rejected."""
|
|
||||||
# Create a tool type first
|
|
||||||
tool_response = authenticated_client.post(
|
|
||||||
"/tool-types",
|
|
||||||
json={
|
|
||||||
"name": "port-test-tool",
|
|
||||||
"display_name": "Port Test Tool",
|
|
||||||
"default_port": 8080,
|
|
||||||
"definition_type": "compose",
|
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
|
||||||
"required_variables": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
tool_id = tool_response.json()["id"]
|
|
||||||
|
|
||||||
# Try to create config with invalid port
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/tool-configs",
|
|
||||||
json={
|
|
||||||
"tool_type_id": tool_id,
|
|
||||||
"key": "BAD_PORT",
|
|
||||||
"value": "test",
|
|
||||||
"config_type": "env",
|
|
||||||
"port_override": 99999,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
def test_create_tool_config_invalid_volume_structure(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that invalid volume structures are rejected."""
|
|
||||||
# Create a tool type first
|
|
||||||
tool_response = authenticated_client.post(
|
|
||||||
"/tool-types",
|
|
||||||
json={
|
|
||||||
"name": "volume-test-tool",
|
|
||||||
"display_name": "Volume Test Tool",
|
|
||||||
"default_port": 8080,
|
|
||||||
"definition_type": "compose",
|
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
|
||||||
"required_variables": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
tool_id = tool_response.json()["id"]
|
|
||||||
|
|
||||||
# Try to create config with invalid volume
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/tool-configs",
|
|
||||||
json={
|
|
||||||
"tool_type_id": tool_id,
|
|
||||||
"key": "BAD_VOLUME",
|
|
||||||
"value": "test",
|
|
||||||
"config_type": "env",
|
|
||||||
"volumes": [{"invalid": "structure"}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
def test_update_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test updating a tool config with new fields."""
|
|
||||||
# Create a tool type first
|
|
||||||
tool_response = authenticated_client.post(
|
|
||||||
"/tool-types",
|
|
||||||
json={
|
|
||||||
"name": "update-config-tool",
|
|
||||||
"display_name": "Update Config Tool",
|
|
||||||
"default_port": 8080,
|
|
||||||
"definition_type": "compose",
|
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
|
||||||
"required_variables": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
tool_id = tool_response.json()["id"]
|
|
||||||
|
|
||||||
# Create config
|
|
||||||
create_response = authenticated_client.post(
|
|
||||||
"/tool-configs",
|
|
||||||
json={
|
|
||||||
"tool_type_id": tool_id,
|
|
||||||
"key": "UPDATE_TEST",
|
|
||||||
"value": "original",
|
|
||||||
"config_type": "env",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
config_id = create_response.json()["id"]
|
|
||||||
|
|
||||||
# Update with new fields
|
|
||||||
response = authenticated_client.put(
|
|
||||||
f"/tool-configs/{config_id}",
|
|
||||||
json={
|
|
||||||
"value": "updated",
|
|
||||||
"port_override": 3000,
|
|
||||||
"start_command": "npm start",
|
|
||||||
"working_directory": "/workspace",
|
|
||||||
"environment_variables": {"NODE_ENV": "production"},
|
|
||||||
"volumes": [{"source": "src", "target": "/app/src", "type": "bind"}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["value"] == "updated"
|
|
||||||
assert data["port_override"] == 3000
|
|
||||||
assert data["start_command"] == "npm start"
|
|
||||||
assert data["working_directory"] == "/workspace"
|
|
||||||
assert data["environment_variables"] == {"NODE_ENV": "production"}
|
|
||||||
|
|
||||||
def test_list_tool_configs_returns_new_fields(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that listing configs returns new fields."""
|
|
||||||
# Create a tool type first
|
|
||||||
tool_response = authenticated_client.post(
|
|
||||||
"/tool-types",
|
|
||||||
json={
|
|
||||||
"name": "list-config-tool",
|
|
||||||
"display_name": "List Config Tool",
|
|
||||||
"default_port": 8080,
|
|
||||||
"definition_type": "compose",
|
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
|
||||||
"required_variables": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
tool_id = tool_response.json()["id"]
|
|
||||||
|
|
||||||
# Create config with new fields
|
|
||||||
authenticated_client.post(
|
|
||||||
"/tool-configs",
|
|
||||||
json={
|
|
||||||
"tool_type_id": tool_id,
|
|
||||||
"key": "LIST_TEST",
|
|
||||||
"value": "test",
|
|
||||||
"config_type": "env",
|
|
||||||
"port_override": 5000,
|
|
||||||
"environment_variables": {"TEST": "true"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# List configs
|
|
||||||
response = authenticated_client.get("/tool-configs")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert len(data) > 0
|
|
||||||
config = data[0]
|
|
||||||
assert "port_override" in config
|
|
||||||
assert "start_command" in config
|
|
||||||
assert "working_directory" in config
|
|
||||||
assert "environment_variables" in config
|
|
||||||
assert "volumes" in config
|
|
||||||
|
|
||||||
def test_get_tool_config_defaults(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test getting tool config defaults."""
|
|
||||||
# Create a tool type first
|
|
||||||
tool_response = authenticated_client.post(
|
|
||||||
"/tool-types",
|
|
||||||
json={
|
|
||||||
"name": "defaults-tool",
|
|
||||||
"display_name": "Defaults Tool",
|
|
||||||
"default_port": 8080,
|
|
||||||
"definition_type": "compose",
|
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n volumes:\n - \"{{REPO_PATH}}:/workspace\"\n",
|
|
||||||
"required_variables": ["REPO_PATH"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
tool_id = tool_response.json()["id"]
|
|
||||||
|
|
||||||
# Get defaults
|
|
||||||
response = authenticated_client.get(f"/tool-configs/defaults/{tool_id}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["tool_type_id"] == tool_id
|
|
||||||
assert "suggested_configs" in data
|
|
||||||
|
|
||||||
def test_tool_config_backward_compatibility(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that old configs without new fields still work."""
|
|
||||||
# Create a tool type first
|
|
||||||
tool_response = authenticated_client.post(
|
|
||||||
"/tool-types",
|
|
||||||
json={
|
|
||||||
"name": "backward-compat-tool",
|
|
||||||
"display_name": "Backward Compat Tool",
|
|
||||||
"default_port": 8080,
|
|
||||||
"definition_type": "compose",
|
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
|
||||||
"required_variables": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
tool_id = tool_response.json()["id"]
|
|
||||||
|
|
||||||
# Create config without new fields (simulating old client)
|
|
||||||
response = authenticated_client.post(
|
|
||||||
"/tool-configs",
|
|
||||||
json={
|
|
||||||
"tool_type_id": tool_id,
|
|
||||||
"key": "OLD_STYLE",
|
|
||||||
"value": "value",
|
|
||||||
"config_type": "env",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["key"] == "OLD_STYLE"
|
|
||||||
# New fields should have default values
|
|
||||||
assert data["port_override"] is None
|
|
||||||
assert data["start_command"] is None
|
|
||||||
assert data["working_directory"] is None
|
|
||||||
assert data["environment_variables"] is None
|
|
||||||
assert data["volumes"] is None
|
|
||||||
@@ -292,24 +292,41 @@ class TestComputeImageTag:
|
|||||||
|
|
||||||
|
|
||||||
class TestMergeWithConfig:
|
class TestMergeWithConfig:
|
||||||
"""Tests for merge_with_config."""
|
"""Tests for merge_with_config (ConfigProfile only)."""
|
||||||
|
|
||||||
def test_applies_tool_config_env(self) -> None:
|
def test_no_profile_returns_manifest_unchanged(self) -> None:
|
||||||
manifest = {"name": "test"}
|
manifest = {"name": "test"}
|
||||||
configs = [
|
result = merge_with_config(manifest)
|
||||||
{"config_type": "env", "key": "FOO", "value": "bar"},
|
assert result["name"] == "test"
|
||||||
]
|
assert result["_extra_env"] == {}
|
||||||
result = merge_with_config(manifest, configs)
|
assert result["_extra_volumes"] == []
|
||||||
|
|
||||||
|
def test_profile_env_vars(self) -> None:
|
||||||
|
manifest = {"name": "test"}
|
||||||
|
profile = {"environment_variables": {"FOO": "bar"}}
|
||||||
|
result = merge_with_config(manifest, profile)
|
||||||
assert result["_extra_env"]["FOO"] == "bar"
|
assert result["_extra_env"]["FOO"] == "bar"
|
||||||
|
|
||||||
def test_applies_port_override(self) -> None:
|
def test_profile_mounts(self) -> None:
|
||||||
|
manifest = {"name": "test"}
|
||||||
|
profile = {"mounts": [{"source": "/host", "target": "/container"}]}
|
||||||
|
result = merge_with_config(manifest, profile)
|
||||||
|
assert len(result["_extra_volumes"]) == 1
|
||||||
|
|
||||||
|
def test_profile_port_override(self) -> None:
|
||||||
manifest = {"name": "test", "default_port": 8080}
|
manifest = {"name": "test", "default_port": 8080}
|
||||||
configs = [{"port_override": 3000}]
|
profile = {"hints": {"port_override": 3000}}
|
||||||
result = merge_with_config(manifest, configs)
|
result = merge_with_config(manifest, profile)
|
||||||
assert result["default_port"] == 3000
|
assert result["default_port"] == 3000
|
||||||
|
|
||||||
def test_applies_start_command(self) -> None:
|
def test_profile_start_command(self) -> None:
|
||||||
manifest = {"name": "test", "runtime": {"command": ["/bin/bash"]}}
|
manifest = {"name": "test", "runtime": {"command": ["/bin/bash"]}}
|
||||||
configs = [{"start_command": "/bin/sh"}]
|
profile = {"hints": {"start_command": "/bin/sh"}}
|
||||||
result = merge_with_config(manifest, configs)
|
result = merge_with_config(manifest, profile)
|
||||||
assert result["runtime"]["command"] == ["/bin/sh"]
|
assert result["runtime"]["command"] == ["/bin/sh"]
|
||||||
|
|
||||||
|
def test_profile_working_directory(self) -> None:
|
||||||
|
manifest = {"name": "test"}
|
||||||
|
profile = {"hints": {"working_directory": "/workspace"}}
|
||||||
|
result = merge_with_config(manifest, profile)
|
||||||
|
assert result["runtime"]["working_dir"] == "/workspace"
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createConfigFolder,
|
|
||||||
deleteConfigFolder,
|
|
||||||
listConfigFolders,
|
|
||||||
updateConfigFolder,
|
|
||||||
} from "../api/config_folders";
|
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
|
||||||
const mockPost = vi.fn();
|
|
||||||
const mockPut = vi.fn();
|
|
||||||
const mockDelete = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../api/client", () => ({
|
|
||||||
apiClient: {
|
|
||||||
get: (...args: unknown[]) => mockGet(...args),
|
|
||||||
post: (...args: unknown[]) => mockPost(...args),
|
|
||||||
put: (...args: unknown[]) => mockPut(...args),
|
|
||||||
delete: (...args: unknown[]) => mockDelete(...args),
|
|
||||||
interceptors: {
|
|
||||||
response: {
|
|
||||||
use: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shouldSkipAuthRedirect: vi.fn(() => false),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("config_folders API", () => {
|
|
||||||
describe("listConfigFolders", () => {
|
|
||||||
it("returns folders with files and overrides", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "folder-1",
|
|
||||||
name: "my-dotfiles",
|
|
||||||
description: "My personal config files",
|
|
||||||
mount_path: "/home/user",
|
|
||||||
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
|
||||||
project_overrides: {},
|
|
||||||
is_active: true,
|
|
||||||
user_id: "user-1",
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
mockGet.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const result = await listConfigFolders();
|
|
||||||
|
|
||||||
expect(result[0].name).toBe("my-dotfiles");
|
|
||||||
expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" });
|
|
||||||
expect(mockGet).toHaveBeenCalledWith("/config-folders");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createConfigFolder", () => {
|
|
||||||
it("creates folder with files", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
id: "folder-new",
|
|
||||||
name: "new-folder",
|
|
||||||
mount_path: "/workspace",
|
|
||||||
files: { ".env": "API_URL=http://localhost" },
|
|
||||||
is_active: true,
|
|
||||||
user_id: "user-1",
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
mockPost.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const result = await createConfigFolder({
|
|
||||||
name: "new-folder",
|
|
||||||
mount_path: "/workspace",
|
|
||||||
files: { ".env": "API_URL=http://localhost" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.name).toBe("new-folder");
|
|
||||||
expect(mockPost).toHaveBeenCalledWith(
|
|
||||||
"/config-folders",
|
|
||||||
expect.objectContaining({
|
|
||||||
name: "new-folder",
|
|
||||||
mount_path: "/workspace",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("updateConfigFolder", () => {
|
|
||||||
it("updates folder files", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
id: "folder-1",
|
|
||||||
name: "updated-folder",
|
|
||||||
mount_path: "/home/user",
|
|
||||||
files: { ".bashrc": "alias ll='ls -la'" },
|
|
||||||
is_active: true,
|
|
||||||
user_id: "user-1",
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
mockPut.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const result = await updateConfigFolder("folder-1", {
|
|
||||||
files: { ".bashrc": "alias ll='ls -la'" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" });
|
|
||||||
expect(mockPut).toHaveBeenCalledWith(
|
|
||||||
"/config-folders/folder-1",
|
|
||||||
expect.objectContaining({
|
|
||||||
files: { ".bashrc": "alias ll='ls -la'" },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deleteConfigFolder", () => {
|
|
||||||
it("deletes folder", async () => {
|
|
||||||
mockDelete.mockResolvedValue({ data: undefined });
|
|
||||||
|
|
||||||
await deleteConfigFolder("folder-1");
|
|
||||||
|
|
||||||
expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { apiClient } from "./client";
|
|
||||||
|
|
||||||
export interface ConfigFolder {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
mount_path: string;
|
|
||||||
files: Record<string, string>;
|
|
||||||
project_overrides: Record<string, { mount_path?: string; files?: Record<string, string> }> | null;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateConfigFolderRequest {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
mount_path: string;
|
|
||||||
files?: Record<string, string>;
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateConfigFolderRequest {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
mount_path?: string;
|
|
||||||
files?: Record<string, string>;
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectOverrideRequest {
|
|
||||||
mount_path?: string;
|
|
||||||
files?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
|
|
||||||
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
|
|
||||||
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createConfigFolder = async (
|
|
||||||
data: CreateConfigFolderRequest
|
|
||||||
): Promise<ConfigFolder> => {
|
|
||||||
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateConfigFolder = async (
|
|
||||||
id: string,
|
|
||||||
data: UpdateConfigFolderRequest
|
|
||||||
): Promise<ConfigFolder> => {
|
|
||||||
const response = await apiClient.put<ConfigFolder>(`/config-folders/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteConfigFolder = async (id: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/config-folders/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const addProjectOverride = async (
|
|
||||||
id: string,
|
|
||||||
projectId: string,
|
|
||||||
data: ProjectOverrideRequest
|
|
||||||
): Promise<ConfigFolder> => {
|
|
||||||
const response = await apiClient.post<ConfigFolder>(
|
|
||||||
`/config-folders/${id}/overrides/${projectId}`,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateProjectOverride = async (
|
|
||||||
id: string,
|
|
||||||
projectId: string,
|
|
||||||
data: ProjectOverrideRequest
|
|
||||||
): Promise<ConfigFolder> => {
|
|
||||||
const response = await apiClient.put<ConfigFolder>(
|
|
||||||
`/config-folders/${id}/overrides/${projectId}`,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteProjectOverride = async (
|
|
||||||
id: string,
|
|
||||||
projectId: string
|
|
||||||
): Promise<void> => {
|
|
||||||
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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;
|
|
||||||
port_override: number | null;
|
|
||||||
start_command: string | null;
|
|
||||||
working_directory: string | null;
|
|
||||||
environment_variables: Record<string, string> | null;
|
|
||||||
volumes: Array<{ source: string; target: string; type?: string }> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateToolConfigRequest {
|
|
||||||
tool_type_id: string;
|
|
||||||
project_id?: string;
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
config_type?: string;
|
|
||||||
file_path?: string;
|
|
||||||
port_override?: number;
|
|
||||||
start_command?: string;
|
|
||||||
working_directory?: string;
|
|
||||||
environment_variables?: Record<string, string>;
|
|
||||||
volumes?: Array<{ source: string; target: string; type?: 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}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getToolConfigDefaults = async (
|
|
||||||
toolTypeId: string
|
|
||||||
): Promise<ToolConfig> => {
|
|
||||||
const response = await apiClient.get<ToolConfig>(
|
|
||||||
`/tool-configs/defaults/${toolTypeId}`
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { ToolWorkshopPage } from "./tool-workshop";
|
|
||||||
import * as toolTypesApi from "../api/tool_types";
|
|
||||||
import * as toolConfigsApi from "../api/tool_configs";
|
|
||||||
import * as configFoldersApi from "../api/config_folders";
|
|
||||||
|
|
||||||
const mockToolTypes = [
|
|
||||||
{
|
|
||||||
id: "type-1",
|
|
||||||
name: "code-server",
|
|
||||||
display_name: "VS Code Server",
|
|
||||||
description: "VS Code in browser",
|
|
||||||
category: "editor",
|
|
||||||
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",
|
|
||||||
dockerfile_template: null,
|
|
||||||
build_context: null,
|
|
||||||
readiness_probe: null,
|
|
||||||
required_variables: ["REPO_PATH"],
|
|
||||||
is_builtin: true,
|
|
||||||
created_by_id: null,
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "type-2",
|
|
||||||
name: "custom-tool",
|
|
||||||
display_name: "Custom Tool",
|
|
||||||
description: "My custom tool",
|
|
||||||
category: "utility",
|
|
||||||
interface_type: "terminal",
|
|
||||||
requires_port: false,
|
|
||||||
default_port: 8080,
|
|
||||||
definition_type: "dockerfile",
|
|
||||||
compose_template: null,
|
|
||||||
dockerfile_template: "FROM python:3.11",
|
|
||||||
build_context: null,
|
|
||||||
readiness_probe: {
|
|
||||||
command: "python --version",
|
|
||||||
timeout: 30,
|
|
||||||
interval: 2,
|
|
||||||
},
|
|
||||||
required_variables: [],
|
|
||||||
is_builtin: false,
|
|
||||||
created_by_id: "user-1",
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockConfigs = [
|
|
||||||
{
|
|
||||||
id: "config-1",
|
|
||||||
tool_type_id: "type-1",
|
|
||||||
project_id: null,
|
|
||||||
key: "OPENAI_API_KEY",
|
|
||||||
value: "sk-test123",
|
|
||||||
config_type: "env",
|
|
||||||
file_path: null,
|
|
||||||
port_override: null,
|
|
||||||
start_command: null,
|
|
||||||
working_directory: null,
|
|
||||||
environment_variables: {},
|
|
||||||
volumes: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "config-2",
|
|
||||||
tool_type_id: "type-2",
|
|
||||||
project_id: null,
|
|
||||||
key: "advanced-config",
|
|
||||||
value: "test-value",
|
|
||||||
config_type: "env",
|
|
||||||
file_path: null,
|
|
||||||
port_override: 9090,
|
|
||||||
start_command: "python app.py",
|
|
||||||
working_directory: "/app",
|
|
||||||
environment_variables: { DEBUG: "true" },
|
|
||||||
volumes: [{ source: "data", target: "/data", type: "bind" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockFolders = [
|
|
||||||
{
|
|
||||||
id: "folder-1",
|
|
||||||
user_id: "user-1",
|
|
||||||
name: "my-dotfiles",
|
|
||||||
description: "My personal config files",
|
|
||||||
mount_path: "/home/user",
|
|
||||||
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
|
||||||
project_overrides: {},
|
|
||||||
is_active: true,
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "folder-2",
|
|
||||||
user_id: "user-1",
|
|
||||||
name: "project-configs",
|
|
||||||
description: "Project specific configs",
|
|
||||||
mount_path: "/workspace",
|
|
||||||
files: { ".env": "API_URL=http://localhost:8080" },
|
|
||||||
project_overrides: {
|
|
||||||
"proj-1": {
|
|
||||||
mount_path: "/app",
|
|
||||||
files: { ".env": "API_URL=http://prod.api" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
is_active: false,
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ToolWorkshopPage", () => {
|
|
||||||
it("renders loading state initially", () => {
|
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {}));
|
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {}));
|
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {}));
|
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
|
||||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders tool types tab by default", 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(<ToolWorkshopPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches to configs 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(<ToolWorkshopPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
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(() => {
|
|
||||||
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText("e.g., OPENAI_API_KEY")).toBeInTheDocument();
|
|
||||||
expect(screen.getByPlaceholderText(/Enter value/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates config with advanced fields", async () => {
|
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
|
||||||
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
|
||||||
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
|
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
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(() => {
|
|
||||||
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("e.g., OPENAI_API_KEY"), {
|
|
||||||
target: { value: "MY_CONFIG" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByPlaceholderText(/Enter value/i), {
|
|
||||||
target: { value: "my-value" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("e.g., 8080"), {
|
|
||||||
target: { value: "9090" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("e.g., npm start"), {
|
|
||||||
target: { value: "python app.py" },
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add$/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(createMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
key: "MY_CONFIG",
|
|
||||||
value: "my-value",
|
|
||||||
port_override: 9090,
|
|
||||||
start_command: "python app.py",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
expect(configsListMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens folder 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(<ToolWorkshopPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
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(() => {
|
|
||||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText("e.g., my-dotfiles")).toBeInTheDocument();
|
|
||||||
expect(screen.getByPlaceholderText("e.g., /home/user")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates config folder successfully", async () => {
|
|
||||||
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
|
||||||
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
|
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
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(() => {
|
|
||||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("e.g., my-dotfiles"), {
|
|
||||||
target: { value: "new-folder" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("e.g., /home/user"), {
|
|
||||||
target: { value: "/home/dev" },
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(createMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: "new-folder",
|
|
||||||
mount_path: "/home/dev",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
expect(foldersListMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows folder active/inactive status", 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(<ToolWorkshopPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
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(() => {
|
|
||||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check that active folder shows Active badge
|
|
||||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles error state gracefully", async () => {
|
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error"));
|
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error"));
|
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error"));
|
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retries loading after error", async () => {
|
|
||||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
|
|
||||||
.mockRejectedValueOnce(new Error("Network error"))
|
|
||||||
.mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs")
|
|
||||||
.mockRejectedValueOnce(new Error("Network error"))
|
|
||||||
.mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders")
|
|
||||||
.mockRejectedValueOnce(new Error("Network error"))
|
|
||||||
.mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(listMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes tool type successfully", async () => {
|
|
||||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
|
||||||
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
|
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find and click delete button for custom tool (not built-in)
|
|
||||||
const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
|
|
||||||
screen.getByText("Custom Tool").parentElement;
|
|
||||||
if (customToolCard) {
|
|
||||||
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
|
|
||||||
if (deleteButton) {
|
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
||||||
fireEvent.click(deleteButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(deleteMock).toHaveBeenCalledWith("type-2");
|
|
||||||
});
|
|
||||||
expect(listMock).toHaveBeenCalledTimes(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -26,25 +26,6 @@ import {
|
|||||||
type ToolDefinitionManifest,
|
type ToolDefinitionManifest,
|
||||||
} from "../api/tool_definitions";
|
} from "../api/tool_definitions";
|
||||||
import { ManifestEditor } from "../components/manifest-editor";
|
import { ManifestEditor } from "../components/manifest-editor";
|
||||||
import {
|
|
||||||
createToolConfig,
|
|
||||||
deleteToolConfig,
|
|
||||||
listToolConfigs,
|
|
||||||
updateToolConfig,
|
|
||||||
type CreateToolConfigRequest,
|
|
||||||
type ToolConfig,
|
|
||||||
} from "../api/tool_configs";
|
|
||||||
import {
|
|
||||||
createConfigFolder,
|
|
||||||
deleteConfigFolder,
|
|
||||||
listConfigFolders,
|
|
||||||
updateConfigFolder,
|
|
||||||
type ConfigFolder,
|
|
||||||
type CreateConfigFolderRequest,
|
|
||||||
type UpdateConfigFolderRequest,
|
|
||||||
} from "../api/config_folders";
|
|
||||||
|
|
||||||
type RightPanelTab = "details" | "configs" | "folders";
|
|
||||||
type Status = "loading" | "ready" | "error";
|
type Status = "loading" | "ready" | "error";
|
||||||
|
|
||||||
type MobileView = "list" | "detail" | "edit";
|
type MobileView = "list" | "detail" | "edit";
|
||||||
@@ -54,8 +35,6 @@ export const ToolWorkshopPage = () => {
|
|||||||
const [mobileView, setMobileView] = useState<MobileView>("list");
|
const [mobileView, setMobileView] = useState<MobileView>("list");
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
|
||||||
const [folders, setFolders] = useState<ConfigFolder[]>([]);
|
|
||||||
const [baseDefinitions, setBaseDefinitions] = useState<
|
const [baseDefinitions, setBaseDefinitions] = useState<
|
||||||
ToolDefinitionManifest[]
|
ToolDefinitionManifest[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -72,7 +51,6 @@ export const ToolWorkshopPage = () => {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("details");
|
|
||||||
|
|
||||||
// Tool Type form state
|
// Tool Type form state
|
||||||
const [toolTypeForm, setToolTypeForm] = useState({
|
const [toolTypeForm, setToolTypeForm] = useState({
|
||||||
@@ -95,55 +73,18 @@ export const ToolWorkshopPage = () => {
|
|||||||
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
||||||
const [toolTypeDirty, setToolTypeDirty] = useState(false);
|
const [toolTypeDirty, setToolTypeDirty] = useState(false);
|
||||||
|
|
||||||
// Config form state
|
|
||||||
const [configForm, setConfigForm] = useState({
|
|
||||||
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 [selectedConfig, setSelectedConfig] = useState<ToolConfig | null>(null);
|
|
||||||
|
|
||||||
// Folder form state
|
|
||||||
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 [selectedFolder, setSelectedFolder] = useState<ConfigFolder | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedToolType =
|
const selectedToolType =
|
||||||
(toolTypes || []).find((t) => t.id === selectedToolTypeId) || null;
|
(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 () => {
|
const loadData = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
const [types, cfgs, fldrs, defs] = await Promise.all([
|
const [types, defs] = await Promise.all([
|
||||||
listToolTypes(),
|
listToolTypes(),
|
||||||
listToolConfigs(),
|
|
||||||
listConfigFolders(),
|
|
||||||
listToolDefinitions(),
|
listToolDefinitions(),
|
||||||
]);
|
]);
|
||||||
setToolTypes(types || []);
|
setToolTypes(types || []);
|
||||||
setConfigs(cfgs || []);
|
|
||||||
setFolders(fldrs || []);
|
|
||||||
setBaseDefinitions((defs || []).filter((d) => d.is_base));
|
setBaseDefinitions((defs || []).filter((d) => d.is_base));
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -219,9 +160,6 @@ export const ToolWorkshopPage = () => {
|
|||||||
} else {
|
} else {
|
||||||
setSelectedToolTypeId(null);
|
setSelectedToolTypeId(null);
|
||||||
}
|
}
|
||||||
setRightPanelTab("details");
|
|
||||||
setShowConfigForm(false);
|
|
||||||
setShowFolderForm(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateNew = () => {
|
const handleCreateNew = () => {
|
||||||
@@ -233,9 +171,6 @@ export const ToolWorkshopPage = () => {
|
|||||||
setSelectedToolTypeId(null);
|
setSelectedToolTypeId(null);
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
resetToolTypeForm();
|
resetToolTypeForm();
|
||||||
setRightPanelTab("details");
|
|
||||||
setShowConfigForm(false);
|
|
||||||
setShowFolderForm(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToolTypeSubmit = async (e: React.FormEvent) => {
|
const handleToolTypeSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -366,213 +301,6 @@ export const ToolWorkshopPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Config handlers
|
|
||||||
const resetConfigForm = () => {
|
|
||||||
setConfigForm({
|
|
||||||
key: "",
|
|
||||||
value: "",
|
|
||||||
config_type: "env",
|
|
||||||
file_path: "",
|
|
||||||
port_override: "",
|
|
||||||
start_command: "",
|
|
||||||
working_directory: "",
|
|
||||||
env_vars_json: "{}",
|
|
||||||
volumes_json: "[]",
|
|
||||||
});
|
|
||||||
setConfigError(null);
|
|
||||||
setSelectedConfig(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreateConfig = () => {
|
|
||||||
resetConfigForm();
|
|
||||||
setShowConfigForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditConfig = (config: ToolConfig) => {
|
|
||||||
setConfigForm({
|
|
||||||
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 (!selectedToolTypeId || !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: selectedToolTypeId,
|
|
||||||
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);
|
|
||||||
resetConfigForm();
|
|
||||||
await loadData();
|
|
||||||
} catch (err) {
|
|
||||||
setConfigError(extractErrorMessage(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfig = async (id: string) => {
|
|
||||||
if (!window.confirm("Delete this config?")) return;
|
|
||||||
try {
|
|
||||||
await deleteToolConfig(id);
|
|
||||||
await loadData();
|
|
||||||
} catch {
|
|
||||||
alert("Failed to delete config");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Folder handlers
|
|
||||||
const resetFolderForm = () => {
|
|
||||||
setFolderForm({
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
mount_path: "/home/user",
|
|
||||||
files_json: "{}",
|
|
||||||
is_active: true,
|
|
||||||
});
|
|
||||||
setFolderError(null);
|
|
||||||
setSelectedFolder(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreateFolder = () => {
|
|
||||||
resetFolderForm();
|
|
||||||
setShowFolderForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
resetFolderForm();
|
|
||||||
await loadData();
|
|
||||||
} catch (err) {
|
|
||||||
setFolderError(extractErrorMessage(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFolder = async (id: string) => {
|
|
||||||
if (!window.confirm("Delete this config folder?")) return;
|
|
||||||
try {
|
|
||||||
await deleteConfigFolder(id);
|
|
||||||
await loadData();
|
|
||||||
} catch {
|
|
||||||
alert("Failed to delete folder");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
@@ -1143,45 +871,7 @@ export const ToolWorkshopPage = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Panel Tabs */}
|
{(
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0",
|
|
||||||
marginBottom: "1.5rem",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(["details", "configs", "folders"] as RightPanelTab[]).map(
|
|
||||||
(tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setRightPanelTab(tab)}
|
|
||||||
style={{
|
|
||||||
padding: "0.625rem 1.25rem",
|
|
||||||
borderBottom:
|
|
||||||
rightPanelTab === tab
|
|
||||||
? "2px solid var(--brand)"
|
|
||||||
: "2px solid transparent",
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontWeight: rightPanelTab === tab ? 600 : 400,
|
|
||||||
color:
|
|
||||||
rightPanelTab === tab ? "var(--brand)" : "var(--muted)",
|
|
||||||
fontSize: "0.9375rem",
|
|
||||||
marginBottom: "-1px",
|
|
||||||
textTransform: "capitalize",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Details Tab */}
|
|
||||||
{rightPanelTab === "details" && (
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleToolTypeSubmit}
|
onSubmit={handleToolTypeSubmit}
|
||||||
className="stack"
|
className="stack"
|
||||||
@@ -1513,472 +1203,6 @@ export const ToolWorkshopPage = () => {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Configs Tab */}
|
|
||||||
{rightPanelTab === "configs" && selectedToolTypeId && (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 style={{ margin: 0 }}>
|
|
||||||
Configurations for {selectedToolType?.display_name}
|
|
||||||
</h3>
|
|
||||||
<button onClick={openCreateConfig}>
|
|
||||||
<Icon name="add" size="sm" /> Add Config
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showConfigForm && (
|
|
||||||
<div
|
|
||||||
className="card stack"
|
|
||||||
style={{ marginBottom: "1rem", padding: "1rem" }}
|
|
||||||
>
|
|
||||||
<h4>{selectedConfig ? "Edit" : "Add"} Config</h4>
|
|
||||||
<form
|
|
||||||
onSubmit={handleConfigSubmit}
|
|
||||||
className="stack"
|
|
||||||
style={{ gap: "0.75rem" }}
|
|
||||||
>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Key *</label>
|
|
||||||
<input
|
|
||||||
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>Config Type</label>
|
|
||||||
<select
|
|
||||||
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>Value</label>
|
|
||||||
<textarea
|
|
||||||
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>Port Override</label>
|
|
||||||
<input
|
|
||||||
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>Start Command</label>
|
|
||||||
<input
|
|
||||||
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={3}
|
|
||||||
style={{
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "0.8125rem",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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={3}
|
|
||||||
style={{
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "0.8125rem",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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);
|
|
||||||
resetConfigForm();
|
|
||||||
}}
|
|
||||||
className="button-secondary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
|
||||||
{toolConfigs.length === 0 ? (
|
|
||||||
<EmptyState message="No configurations for this tool type yet." />
|
|
||||||
) : (
|
|
||||||
toolConfigs.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
|
|
||||||
style={{
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
padding: "0.125rem 0.5rem",
|
|
||||||
borderRadius: "9999px",
|
|
||||||
background:
|
|
||||||
config.config_type === "env"
|
|
||||||
? "var(--info, #3b82f6)"
|
|
||||||
: "var(--warning, #f59e0b)",
|
|
||||||
color: "white",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Folders Tab */}
|
|
||||||
{rightPanelTab === "folders" && (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 style={{ margin: 0 }}>Config Folders</h3>
|
|
||||||
<button onClick={openCreateFolder}>
|
|
||||||
<Icon name="add" size="sm" /> Create Folder
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showFolderForm && (
|
|
||||||
<div
|
|
||||||
className="card stack"
|
|
||||||
style={{ marginBottom: "1rem", padding: "1rem" }}
|
|
||||||
>
|
|
||||||
<h4>{selectedFolder ? "Edit" : "Create"} Config Folder</h4>
|
|
||||||
<form
|
|
||||||
onSubmit={handleFolderSubmit}
|
|
||||||
className="stack"
|
|
||||||
style={{ gap: "0.75rem" }}
|
|
||||||
>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Name *</label>
|
|
||||||
<input
|
|
||||||
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>Description</label>
|
|
||||||
<input
|
|
||||||
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>Mount Path *</label>
|
|
||||||
<input
|
|
||||||
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>Files (JSON object)</label>
|
|
||||||
<textarea
|
|
||||||
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}
|
|
||||||
style={{
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "0.8125rem",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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);
|
|
||||||
resetFolderForm();
|
|
||||||
}}
|
|
||||||
className="button-secondary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card-grid">
|
|
||||||
{toolFolders.map((folder) => (
|
|
||||||
<div key={folder.id} className="card">
|
|
||||||
<div className="card-header">
|
|
||||||
<h4>{folder.name}</h4>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user