Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c94583307 | |||
| 649496b762 | |||
| d13e16f5e1 | |||
| d5f9df33b7 | |||
| 468e0eacda | |||
| 99097090e6 | |||
| 2a9e57ad0d | |||
| e4c5e7f2db | |||
| 70957e462a | |||
| 6f35eb77ae | |||
| 684a11610a | |||
| 575e5837ea | |||
| 996ea73bbf | |||
| 1f784b552d | |||
| dacf105200 | |||
| 8dd350286e |
@@ -87,6 +87,31 @@ Do not claim completion without verification evidence.
|
||||
|
||||
## Git workflow
|
||||
|
||||
### Branching strategy
|
||||
|
||||
For every spec change or new functionality:
|
||||
|
||||
1. Create a new branch from `dev` with a proper prefix:
|
||||
- `feat/` for new features (e.g., `feat/tool-workshop`)
|
||||
- `fix/` for bug fixes (e.g., `fix/terminal-tty`)
|
||||
- `refactor/` for refactors (e.g., `refactor/api-cleanup`)
|
||||
- `docs/` for documentation (e.g., `docs/api-guide`)
|
||||
- `chore/` for maintenance (e.g., `chore/update-deps`)
|
||||
2. Branch name should reference the OpenSpec change name when applicable.
|
||||
3. Do not commit directly to `main` or `dev`.
|
||||
|
||||
### Completion and merge
|
||||
|
||||
When implementation is complete and verified:
|
||||
|
||||
1. Ensure all tests pass and quality gates are met.
|
||||
2. Stage all changes with `git add -A`.
|
||||
3. Create a commit with a proper conventional commit message (see below).
|
||||
4. Switch to `dev`: `git checkout dev`.
|
||||
5. Merge the feature branch: `git merge --no-ff <branch-name>`.
|
||||
6. Push to remote: `git push origin dev`.
|
||||
7. Delete the local feature branch if desired: `git branch -d <branch-name>`.
|
||||
|
||||
### Auto-commit on spec completion
|
||||
|
||||
When an OpenSpec change is fully implemented and all tasks are complete:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""add_tool_config_fields
|
||||
|
||||
Revision ID: 398082499c30
|
||||
Revises: af8512103d67
|
||||
Create Date: 2026-05-22 18:38:20.166184
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '398082499c30'
|
||||
down_revision = 'af8512103d67'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add new columns to tool_configs
|
||||
op.add_column('tool_configs', sa.Column('port_override', sa.Integer(), nullable=True))
|
||||
op.add_column('tool_configs', sa.Column('start_command', sa.Text(), nullable=True))
|
||||
op.add_column('tool_configs', sa.Column('working_directory', sa.Text(), nullable=True))
|
||||
op.add_column('tool_configs', sa.Column('environment_variables', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
|
||||
op.add_column('tool_configs', sa.Column('volumes', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='[]'))
|
||||
|
||||
# Add CHECK constraint for port range
|
||||
op.create_check_constraint('chk_port_range', 'tool_configs', sa.text('port_override IS NULL OR (port_override >= 1 AND port_override <= 65535)'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop CHECK constraint
|
||||
op.drop_constraint('chk_port_range', 'tool_configs', type_='check')
|
||||
|
||||
# Drop columns
|
||||
op.drop_column('tool_configs', 'port_override')
|
||||
op.drop_column('tool_configs', 'start_command')
|
||||
op.drop_column('tool_configs', 'working_directory')
|
||||
op.drop_column('tool_configs', 'environment_variables')
|
||||
op.drop_column('tool_configs', 'volumes')
|
||||
@@ -0,0 +1,44 @@
|
||||
"""create_config_folders_table
|
||||
|
||||
Revision ID: 8ed7dd80973d
|
||||
Revises: 398082499c30
|
||||
Create Date: 2026-05-22 18:38:22.133696
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '8ed7dd80973d'
|
||||
down_revision = '398082499c30'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'config_folders',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), 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', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
|
||||
sa.Column('project_overrides', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('user_id', 'name', name='uq_config_folders_user_name')
|
||||
)
|
||||
|
||||
# Add index on user_id for filtering
|
||||
op.create_index('idx_config_folders_user', 'config_folders', ['user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop index
|
||||
op.drop_index('idx_config_folders_user', table_name='config_folders')
|
||||
|
||||
# Drop table
|
||||
op.drop_table('config_folders')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add_tool_type_fields
|
||||
|
||||
Revision ID: af8512103d67
|
||||
Revises: 0012_default_port_req
|
||||
Create Date: 2026-05-22 18:37:56.607240
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'af8512103d67'
|
||||
down_revision = '0012_default_port_req'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add new columns to tool_types
|
||||
op.add_column('tool_types', sa.Column('definition_type', sa.String(20), nullable=False, server_default='compose'))
|
||||
op.add_column('tool_types', sa.Column('dockerfile_template', sa.Text(), nullable=True))
|
||||
op.add_column('tool_types', sa.Column('build_context', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
|
||||
op.add_column('tool_types', sa.Column('readiness_probe', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
|
||||
# Add CHECK constraint for definition_type
|
||||
op.create_check_constraint('chk_definition_type', 'tool_types', sa.text("definition_type IN ('compose', 'dockerfile')"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop CHECK constraint
|
||||
op.drop_constraint('chk_definition_type', 'tool_types', type_='check')
|
||||
|
||||
# Drop columns
|
||||
op.drop_column('tool_types', 'definition_type')
|
||||
op.drop_column('tool_types', 'dockerfile_template')
|
||||
op.drop_column('tool_types', 'build_context')
|
||||
op.drop_column('tool_types', 'readiness_probe')
|
||||
@@ -0,0 +1,372 @@
|
||||
"""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.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"])
|
||||
|
||||
MAX_FOLDER_SIZE_MB = 10
|
||||
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||
|
||||
|
||||
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:
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount path must be absolute (start with /)")
|
||||
return v
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict) -> dict:
|
||||
total_size = 0
|
||||
for path, content in v.items():
|
||||
# Check for path traversal
|
||||
if ".." in path or path.startswith("/"):
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
total_size += len(content.encode("utf-8"))
|
||||
|
||||
if total_size > MAX_FOLDER_SIZE_BYTES:
|
||||
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
||||
|
||||
return 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:
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount path must be absolute (start with /)")
|
||||
return v
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
total_size = 0
|
||||
for path, content in v.items():
|
||||
# Check for path traversal
|
||||
if ".." in path or path.startswith("/"):
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
total_size += len(content.encode("utf-8"))
|
||||
|
||||
if total_size > MAX_FOLDER_SIZE_BYTES:
|
||||
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
||||
|
||||
return 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:
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount path must be absolute (start with /)")
|
||||
return 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 {},
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -24,6 +24,91 @@ class ToolConfigCreate(BaseModel):
|
||||
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:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("environment_variables must be a JSON object")
|
||||
return v
|
||||
|
||||
@field_validator("volumes")
|
||||
@classmethod
|
||||
def validate_volumes(cls, v: list | None) -> list | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("volumes must be a JSON array")
|
||||
for i, vol in enumerate(v):
|
||||
if not isinstance(vol, dict):
|
||||
raise ValueError(f"Volume at index {i} must be an object")
|
||||
if "source" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||
if "target" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||
return 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:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("environment_variables must be a JSON object")
|
||||
return v
|
||||
|
||||
@field_validator("volumes")
|
||||
@classmethod
|
||||
def validate_volumes(cls, v: list | None) -> list | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("volumes must be a JSON array")
|
||||
for i, vol in enumerate(v):
|
||||
if not isinstance(vol, dict):
|
||||
raise ValueError(f"Volume at index {i} must be an object")
|
||||
if "source" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||
if "target" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||
return v
|
||||
|
||||
|
||||
class ToolConfigResponse(BaseModel):
|
||||
@@ -34,6 +119,11 @@ class ToolConfigResponse(BaseModel):
|
||||
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.")
|
||||
@@ -42,7 +132,7 @@ async def list_configs(
|
||||
project_id: str | None = None,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
) -> list:
|
||||
"""List tool configs for the current user."""
|
||||
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
|
||||
|
||||
@@ -57,23 +147,26 @@ async def list_configs(
|
||||
result = await session.execute(query)
|
||||
configs = result.scalars().all()
|
||||
|
||||
return {
|
||||
"configs": [
|
||||
{
|
||||
"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,
|
||||
}
|
||||
for c in configs
|
||||
]
|
||||
}
|
||||
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.")
|
||||
@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),
|
||||
@@ -111,6 +204,11 @@ async def create_config(
|
||||
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()
|
||||
@@ -124,13 +222,18 @@ async def create_config(
|
||||
"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: ToolConfigCreate,
|
||||
data: ToolConfigUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
@@ -139,10 +242,24 @@ async def update_config(
|
||||
if config is None or config.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
||||
|
||||
config.key = data.key
|
||||
config.value = data.value
|
||||
config.config_type = data.config_type
|
||||
config.file_path = data.file_path
|
||||
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)
|
||||
@@ -155,6 +272,38 @@ async def update_config(
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from src.models.tool_config import ToolConfig
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.config_folder import ConfigFolder
|
||||
from src.services.docker import (
|
||||
check_tunnel_health,
|
||||
connect_container_to_network,
|
||||
@@ -29,15 +30,21 @@ from src.services.docker import (
|
||||
execute_compose_command,
|
||||
find_free_port,
|
||||
get_container_id,
|
||||
get_container_logs,
|
||||
get_container_name,
|
||||
get_container_status,
|
||||
recreate_tunnel,
|
||||
render_compose_template,
|
||||
start_cloudflared_tunnel,
|
||||
stop_cloudflared_tunnel,
|
||||
wait_for_container_running,
|
||||
write_compose_file,
|
||||
write_config_files,
|
||||
write_env_file,
|
||||
write_config_folder_files,
|
||||
)
|
||||
from src.services.docker_build import build_image
|
||||
from src.services.readiness_probe import execute_probe
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||
|
||||
@@ -51,6 +58,58 @@ class CreateInstanceRequest(BaseModel):
|
||||
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
||||
|
||||
|
||||
def _modify_compose_file(
|
||||
compose_path: str,
|
||||
port_override: int | None = None,
|
||||
start_command: str | None = None,
|
||||
working_directory: str | None = None,
|
||||
extra_volumes: list[dict] | None = None,
|
||||
) -> None:
|
||||
"""Modify compose file with runtime overrides."""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
compose_file = Path(compose_path)
|
||||
content = compose_file.read_text()
|
||||
compose_data = yaml.safe_load(content)
|
||||
|
||||
if not compose_data or "services" not in compose_data:
|
||||
return
|
||||
|
||||
# Apply modifications to the first service
|
||||
for service_name, service_config in compose_data["services"].items():
|
||||
if port_override and "ports" in service_config:
|
||||
# Update port mapping
|
||||
for i, port_mapping in enumerate(service_config["ports"]):
|
||||
if isinstance(port_mapping, str) and ":" in port_mapping:
|
||||
host_port, container_port = port_mapping.split(":", 1)
|
||||
service_config["ports"][i] = f"{port_override}:{container_port}"
|
||||
break
|
||||
|
||||
if start_command:
|
||||
service_config["command"] = start_command
|
||||
|
||||
if working_directory:
|
||||
service_config["working_dir"] = working_directory
|
||||
|
||||
if extra_volumes:
|
||||
if "volumes" not in service_config:
|
||||
service_config["volumes"] = []
|
||||
for vol in extra_volumes:
|
||||
source = vol.get("source", "")
|
||||
target = vol.get("target", "")
|
||||
vol_type = vol.get("type", "bind")
|
||||
if vol_type == "bind":
|
||||
service_config["volumes"].append(f"{source}:{target}")
|
||||
else:
|
||||
service_config["volumes"].append(f"{source}:{target}:{vol_type}")
|
||||
|
||||
break # Only modify the first service
|
||||
|
||||
# Write back
|
||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
"""Fetch a user by ID or raise 404 if not found."""
|
||||
user = await session.get(User, user_id)
|
||||
@@ -145,18 +204,55 @@ async def create_instance(
|
||||
# Find free port
|
||||
tool_port = find_free_port()
|
||||
|
||||
# Render compose template
|
||||
variables = {
|
||||
"REPO_PATH": repo.path,
|
||||
"INSTANCE_NAME": instance_name,
|
||||
"INSTANCE_ID": instance_name,
|
||||
"TOOL_NAME": instance_name,
|
||||
"TOOL_PORT": tool_port,
|
||||
"USER_ID": str(user_id),
|
||||
"PROJECT_ID": str(project_id),
|
||||
}
|
||||
compose_content = render_compose_template(tool_type.compose_template, variables)
|
||||
write_compose_file(instance_dir, compose_content)
|
||||
# Handle based on definition type
|
||||
if tool_type.definition_type == "dockerfile":
|
||||
# Build image from Dockerfile
|
||||
image_tag = f"headquarter/{instance_name}:latest"
|
||||
|
||||
if tool_type.dockerfile_template:
|
||||
returncode, stdout, stderr = build_image(
|
||||
instance_dir=instance_dir,
|
||||
dockerfile=tool_type.dockerfile_template,
|
||||
tag=image_tag,
|
||||
build_context=tool_type.build_context,
|
||||
)
|
||||
|
||||
if returncode != 0:
|
||||
logger.error("Failed to build image for instance %s: %s", instance_name, stderr)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to build Docker image: {stderr[:500]}",
|
||||
)
|
||||
|
||||
logger.info("Successfully built image %s for instance %s", image_tag, instance_name)
|
||||
|
||||
# Generate compose for dockerfile-built image
|
||||
compose_content = f"""version: "3.8"
|
||||
services:
|
||||
app:
|
||||
image: {image_tag}
|
||||
container_name: {instance_name}
|
||||
ports:
|
||||
- "{tool_port}:{tool_type.default_port}"
|
||||
volumes:
|
||||
- {repo.path}:/workspace
|
||||
restart: unless-stopped
|
||||
"""
|
||||
write_compose_file(instance_dir, compose_content)
|
||||
|
||||
else:
|
||||
# Render compose template
|
||||
variables = {
|
||||
"REPO_PATH": repo.path,
|
||||
"INSTANCE_NAME": instance_name,
|
||||
"INSTANCE_ID": instance_name,
|
||||
"TOOL_NAME": instance_name,
|
||||
"TOOL_PORT": tool_port,
|
||||
"USER_ID": str(user_id),
|
||||
"PROJECT_ID": str(project_id),
|
||||
}
|
||||
compose_content = render_compose_template(tool_type.compose_template, variables)
|
||||
write_compose_file(instance_dir, compose_content)
|
||||
|
||||
# Create database record
|
||||
instance = ToolInstance(
|
||||
@@ -353,6 +449,11 @@ async def start_instance(
|
||||
# Fetch tool configs for this tool type
|
||||
env_vars = {}
|
||||
config_files = {}
|
||||
port_override = None
|
||||
start_command = None
|
||||
working_directory = None
|
||||
extra_env_vars = {}
|
||||
extra_volumes = []
|
||||
|
||||
config_query = select(ToolConfig).where(
|
||||
ToolConfig.user_id == user_id,
|
||||
@@ -370,6 +471,30 @@ async def start_instance(
|
||||
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)
|
||||
|
||||
# Fetch active config folders for this user
|
||||
folder_query = select(ConfigFolder).where(
|
||||
ConfigFolder.user_id == user_id,
|
||||
ConfigFolder.is_active == True,
|
||||
)
|
||||
folder_result = await session.execute(folder_query)
|
||||
config_folders = folder_result.scalars().all()
|
||||
logger.info("Found %d active config folders for instance %s", len(config_folders), instance.id)
|
||||
|
||||
# Write env file and config files
|
||||
instance_dir = os.path.dirname(instance.compose_path)
|
||||
@@ -382,6 +507,17 @@ async def start_instance(
|
||||
if config_files:
|
||||
write_config_files(instance_dir, config_files)
|
||||
logger.info("Wrote %d config files for instance %s", len(config_files), instance.id)
|
||||
|
||||
# Write config folder files
|
||||
if config_folders:
|
||||
folder_volumes = write_config_folder_files(instance_dir, config_folders, str(project_id))
|
||||
extra_volumes.extend(folder_volumes)
|
||||
logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id)
|
||||
|
||||
# Modify compose file if needed (port override, start command, working dir, volumes)
|
||||
if port_override or start_command or working_directory or extra_volumes:
|
||||
_modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes)
|
||||
logger.info("Modified compose file for instance %s", instance.id)
|
||||
|
||||
# Execute docker compose up with env file
|
||||
logger.info("Running docker compose up for instance %s (compose_path=%s)", instance.id, instance.compose_path)
|
||||
@@ -419,8 +555,105 @@ async def start_instance(
|
||||
else:
|
||||
logger.warning("Failed to connect %s to backend network", container_name)
|
||||
|
||||
# Verify container reached running state
|
||||
if instance.container_id:
|
||||
instance.status = "starting"
|
||||
instance.last_started_at = datetime.now()
|
||||
await session.commit()
|
||||
logger.info("Instance %s: verifying container startup...", instance.id)
|
||||
|
||||
startup_result = wait_for_container_running(instance.container_id, timeout=30, interval=2.0)
|
||||
|
||||
if not startup_result["success"]:
|
||||
# Container failed to start
|
||||
error_msg = f"Container failed to start: status={startup_result['status']}"
|
||||
if startup_result["exit_code"] is not None:
|
||||
error_msg += f", exit_code={startup_result['exit_code']}"
|
||||
|
||||
# Get logs for debugging
|
||||
logs = get_container_logs(instance.container_id, tail=50)
|
||||
|
||||
instance.status = "error"
|
||||
await session.commit()
|
||||
logger.error(
|
||||
"Instance %s container startup failed after %.1fs: %s\nLogs:\n%s",
|
||||
instance.id,
|
||||
startup_result["waited_seconds"],
|
||||
error_msg,
|
||||
logs,
|
||||
)
|
||||
return {
|
||||
"status": "error",
|
||||
"error": error_msg,
|
||||
"logs": logs,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Instance %s container started successfully after %.1fs",
|
||||
instance.id,
|
||||
startup_result["waited_seconds"],
|
||||
)
|
||||
|
||||
# Execute readiness probe if configured
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
if tool_type and instance.container_id:
|
||||
# Determine probe command
|
||||
probe_command = None
|
||||
probe_timeout = 30
|
||||
probe_interval = 2
|
||||
|
||||
if tool_type.readiness_probe:
|
||||
probe_config = tool_type.readiness_probe
|
||||
probe_command = probe_config.get("command", "")
|
||||
probe_timeout = probe_config.get("timeout", 30)
|
||||
probe_interval = probe_config.get("interval", 2)
|
||||
elif "web" in (tool_type.interfaces or []):
|
||||
# Default probe for web tools
|
||||
probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
|
||||
probe_timeout = 30
|
||||
probe_interval = 2
|
||||
|
||||
if probe_command:
|
||||
instance.status = "probing"
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
|
||||
instance.id, probe_command, probe_timeout, probe_interval
|
||||
)
|
||||
|
||||
success, probe_logs = await execute_probe(
|
||||
container_id=instance.container_id,
|
||||
command=probe_command,
|
||||
timeout=probe_timeout,
|
||||
interval=probe_interval,
|
||||
)
|
||||
|
||||
# Store probe result
|
||||
instance.probe_result = {
|
||||
"success": success,
|
||||
"command": probe_command,
|
||||
"logs": probe_logs,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
if not success:
|
||||
instance.status = "unhealthy"
|
||||
await session.commit()
|
||||
logger.error(
|
||||
"Readiness probe failed for instance %s after %ds: %s",
|
||||
instance.id,
|
||||
probe_timeout,
|
||||
"\n".join(probe_logs),
|
||||
)
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"error": f"Readiness probe failed after {probe_timeout}s",
|
||||
"probe_logs": probe_logs,
|
||||
}
|
||||
|
||||
logger.info("Readiness probe succeeded for instance %s", instance.id)
|
||||
|
||||
instance.status = "running"
|
||||
instance.last_started_at = datetime.now()
|
||||
await session.commit()
|
||||
logger.info("Instance %s is now running", instance.id)
|
||||
|
||||
@@ -784,6 +1017,17 @@ async def recreate_tunnel_endpoint(
|
||||
detail="instance must be running to recreate tunnel",
|
||||
)
|
||||
|
||||
# Validate tunnel is actually broken before recreating
|
||||
if instance.url:
|
||||
tunnel_health = check_tunnel_health(instance.url)
|
||||
if tunnel_health["tunnel_status"] == "error_response":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.",
|
||||
)
|
||||
elif tunnel_health["tunnel_status"] == "healthy":
|
||||
return {"status": "healthy", "url": instance.url, "message": "Tunnel is already healthy"}
|
||||
|
||||
# Get tool type for default port
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
|
||||
@@ -815,8 +1059,8 @@ async def recreate_tunnel_endpoint(
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
|
||||
summary="Check tunnel health",
|
||||
description="Check if the temporary Cloudflare tunnel for an instance is healthy.",
|
||||
summary="Check instance health",
|
||||
description="Check container and tunnel health for an instance.",
|
||||
)
|
||||
async def check_instance_tunnel_health(
|
||||
project_id: uuid.UUID,
|
||||
@@ -825,7 +1069,7 @@ async def check_instance_tunnel_health(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Check tunnel health for an instance.
|
||||
"""Check health for an instance (container + tunnel).
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
@@ -835,7 +1079,7 @@ async def check_instance_tunnel_health(
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with health status.
|
||||
Dictionary with container_status, tunnel_status, probe_status, and overall healthy flag.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
@@ -846,11 +1090,50 @@ async def check_instance_tunnel_health(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
if not instance.url or instance.status != "running":
|
||||
return {"healthy": False, "status_code": None, "error": "instance not running"}
|
||||
# Check container status
|
||||
container_info = {"status": "not_found", "exit_code": None, "health": None}
|
||||
if instance.container_id:
|
||||
container_info = get_container_status(instance.container_id)
|
||||
|
||||
health = check_tunnel_health(instance.url)
|
||||
return health
|
||||
# Build response
|
||||
response = {
|
||||
"healthy": False,
|
||||
"container_status": container_info["status"],
|
||||
"container_health": container_info["health"],
|
||||
"tunnel_status": "not_applicable",
|
||||
"tunnel_status_code": None,
|
||||
"probe_status": "not_applicable",
|
||||
"last_probe_output": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# Determine probe status
|
||||
if instance.status == "probing":
|
||||
response["probe_status"] = "pending"
|
||||
elif instance.probe_result:
|
||||
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
|
||||
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))[:500]
|
||||
|
||||
# Check tunnel health if instance has a URL and is web-enabled
|
||||
if instance.url and instance.status in ("running", "unhealthy"):
|
||||
tunnel_health = check_tunnel_health(instance.url)
|
||||
response["tunnel_status"] = tunnel_health["tunnel_status"]
|
||||
response["tunnel_status_code"] = tunnel_health.get("status_code")
|
||||
if tunnel_health.get("error"):
|
||||
response["error"] = tunnel_health["error"]
|
||||
|
||||
# Overall healthy only if container is running AND tunnel is healthy
|
||||
container_healthy = container_info["status"] == "running"
|
||||
tunnel_healthy = response["tunnel_status"] == "healthy"
|
||||
response["healthy"] = container_healthy and tunnel_healthy
|
||||
|
||||
# If container is not running, override error message
|
||||
if not container_healthy:
|
||||
response["error"] = f"Container is {container_info['status']}"
|
||||
if container_info["exit_code"] is not None:
|
||||
response["error"] += f" (exit code: {container_info['exit_code']})"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
+265
-56
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -38,14 +38,32 @@ class ToolTypeCreate(BaseModel):
|
||||
display_name: str
|
||||
description: str | None = None
|
||||
default_port: int
|
||||
compose_template: str
|
||||
definition_type: str = "compose"
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
required_variables: list[str] = []
|
||||
category: str = "other"
|
||||
interfaces: list[str] = ["web"]
|
||||
|
||||
@field_validator("definition_type")
|
||||
@classmethod
|
||||
def validate_definition_type(cls, v: str) -> str:
|
||||
if v not in ("compose", "dockerfile"):
|
||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||
return v
|
||||
|
||||
@field_validator("compose_template")
|
||||
@classmethod
|
||||
def validate_compose_template(cls, v: str) -> str:
|
||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||
data = info.data
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
|
||||
if v is None:
|
||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(v)
|
||||
except yaml.YAMLError as e:
|
||||
@@ -62,6 +80,21 @@ class ToolTypeCreate(BaseModel):
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@classmethod
|
||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||
data = info.data
|
||||
if data.get("definition_type") != "dockerfile":
|
||||
return v
|
||||
|
||||
if v is None:
|
||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||
|
||||
if not v.strip().startswith("FROM"):
|
||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("default_port")
|
||||
@classmethod
|
||||
def validate_default_port(cls, v: int, info) -> int:
|
||||
@@ -70,10 +103,13 @@ class ToolTypeCreate(BaseModel):
|
||||
|
||||
# Get compose_template from the model data
|
||||
data = info.data
|
||||
if "compose_template" not in data:
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
|
||||
template = data.get("compose_template")
|
||||
if not template:
|
||||
return v
|
||||
|
||||
template = data["compose_template"]
|
||||
try:
|
||||
parsed = yaml.safe_load(template)
|
||||
except yaml.YAMLError:
|
||||
@@ -109,12 +145,14 @@ class ToolTypeCreate(BaseModel):
|
||||
if not v:
|
||||
return v
|
||||
|
||||
# Get compose_template from the model data
|
||||
data = info.data
|
||||
if "compose_template" not in data:
|
||||
if data.get("definition_type") != "compose":
|
||||
return v
|
||||
|
||||
template = data.get("compose_template")
|
||||
if not template:
|
||||
return v
|
||||
|
||||
template = data["compose_template"]
|
||||
for var in v:
|
||||
placeholder = f"{{{{{var}}}}}"
|
||||
if placeholder not in template:
|
||||
@@ -122,22 +160,48 @@ class ToolTypeCreate(BaseModel):
|
||||
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_templates(self) -> "ToolTypeCreate":
|
||||
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
|
||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||
if self.definition_type == "compose" and self.compose_template is None:
|
||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||
return self
|
||||
|
||||
|
||||
class ToolTypeUpdate(BaseModel):
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
default_port: int | None = None
|
||||
definition_type: str | None = None
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
required_variables: list[str] | None = None
|
||||
category: str | None = None
|
||||
interfaces: list[str] | None = None
|
||||
|
||||
@field_validator("definition_type")
|
||||
@classmethod
|
||||
def validate_definition_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in ("compose", "dockerfile"):
|
||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||
return v
|
||||
|
||||
@field_validator("compose_template")
|
||||
@classmethod
|
||||
def validate_compose_template(cls, v: str | None) -> str | None:
|
||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
data = info.data
|
||||
definition_type = data.get("definition_type")
|
||||
if definition_type and definition_type != "compose":
|
||||
return v
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(v)
|
||||
except yaml.YAMLError as e:
|
||||
@@ -154,6 +218,22 @@ class ToolTypeUpdate(BaseModel):
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@classmethod
|
||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
data = info.data
|
||||
definition_type = data.get("definition_type")
|
||||
if definition_type and definition_type != "dockerfile":
|
||||
return v
|
||||
|
||||
if not v.strip().startswith("FROM"):
|
||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class ToolTypeResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -165,7 +245,11 @@ class ToolTypeResponse(BaseModel):
|
||||
category: str
|
||||
interfaces: list[str]
|
||||
default_port: int
|
||||
compose_template: str
|
||||
definition_type: str
|
||||
compose_template: str | None
|
||||
dockerfile_template: str | None
|
||||
build_context: dict | None
|
||||
readiness_probe: dict | None
|
||||
required_variables: list[str]
|
||||
is_builtin: bool
|
||||
created_by_id: uuid.UUID | None
|
||||
@@ -208,8 +292,14 @@ async def create_tool_type(
|
||||
display_name=data.display_name,
|
||||
description=data.description,
|
||||
default_port=data.default_port,
|
||||
definition_type=data.definition_type,
|
||||
compose_template=data.compose_template,
|
||||
dockerfile_template=data.dockerfile_template,
|
||||
build_context=data.build_context,
|
||||
readiness_probe=data.readiness_probe,
|
||||
required_variables=data.required_variables,
|
||||
category=data.category,
|
||||
interfaces=data.interfaces,
|
||||
is_builtin=False,
|
||||
created_by_id=user.id,
|
||||
)
|
||||
@@ -315,54 +405,59 @@ async def update_tool_type(
|
||||
detail="Port must be between 1 and 65535"
|
||||
)
|
||||
|
||||
# Check if port is exposed in compose template
|
||||
template = update_data.get("compose_template", tool_type.compose_template)
|
||||
try:
|
||||
parsed = yaml.safe_load(template)
|
||||
except yaml.YAMLError:
|
||||
parsed = None
|
||||
|
||||
if parsed and isinstance(parsed, dict) and "services" in parsed:
|
||||
port_str = str(new_port)
|
||||
port_exposed = False
|
||||
for service_config in parsed["services"].values():
|
||||
if isinstance(service_config, dict) and "ports" in service_config:
|
||||
for port_mapping in service_config["ports"]:
|
||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
||||
port_exposed = True
|
||||
# Only validate port exposure for compose definitions
|
||||
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||
if definition_type == "compose":
|
||||
template = update_data.get("compose_template", tool_type.compose_template)
|
||||
if template:
|
||||
try:
|
||||
parsed = yaml.safe_load(template)
|
||||
except yaml.YAMLError:
|
||||
parsed = None
|
||||
|
||||
if parsed and isinstance(parsed, dict) and "services" in parsed:
|
||||
port_str = str(new_port)
|
||||
port_exposed = False
|
||||
for service_config in parsed["services"].values():
|
||||
if isinstance(service_config, dict) and "ports" in service_config:
|
||||
for port_mapping in service_config["ports"]:
|
||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
||||
port_exposed = True
|
||||
break
|
||||
elif isinstance(port_mapping, int) and port_mapping == new_port:
|
||||
port_exposed = True
|
||||
break
|
||||
if port_exposed:
|
||||
break
|
||||
elif isinstance(port_mapping, int) and port_mapping == new_port:
|
||||
port_exposed = True
|
||||
break
|
||||
if port_exposed:
|
||||
break
|
||||
|
||||
if not port_exposed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Port {new_port} is not exposed in the compose template"
|
||||
)
|
||||
|
||||
if not port_exposed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Port {new_port} is not exposed in the compose template"
|
||||
)
|
||||
|
||||
# Validate required variables if both are being updated
|
||||
if "required_variables" in update_data and "compose_template" in update_data:
|
||||
template = update_data["compose_template"]
|
||||
for var in update_data["required_variables"]:
|
||||
placeholder = f"{{{{{var}}}}}"
|
||||
if placeholder not in template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Required variable '{var}' not found in compose template"
|
||||
)
|
||||
elif "required_variables" in update_data:
|
||||
# Only updating variables, check against existing template
|
||||
template = tool_type.compose_template
|
||||
for var in update_data["required_variables"]:
|
||||
placeholder = f"{{{{{var}}}}}"
|
||||
if placeholder not in template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Required variable '{var}' not found in compose template"
|
||||
)
|
||||
# Validate required variables for compose definitions
|
||||
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||
if definition_type == "compose":
|
||||
if "required_variables" in update_data and "compose_template" in update_data:
|
||||
template = update_data["compose_template"]
|
||||
for var in update_data["required_variables"]:
|
||||
placeholder = f"{{{{{var}}}}}"
|
||||
if placeholder not in template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Required variable '{var}' not found in compose template"
|
||||
)
|
||||
elif "required_variables" in update_data:
|
||||
template = tool_type.compose_template
|
||||
if template:
|
||||
for var in update_data["required_variables"]:
|
||||
placeholder = f"{{{{{var}}}}}"
|
||||
if placeholder not in template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Required variable '{var}' not found in compose template"
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(tool_type, field, value)
|
||||
@@ -372,6 +467,120 @@ async def update_tool_type(
|
||||
return tool_type
|
||||
|
||||
|
||||
class ToolTypeValidateRequest(BaseModel):
|
||||
definition_type: str
|
||||
compose_template: str | None = None
|
||||
dockerfile_template: str | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/validate",
|
||||
summary="Validate tool type template",
|
||||
description="Validate a compose template or dockerfile syntax before creating a tool type.",
|
||||
)
|
||||
async def validate_tool_type_template(
|
||||
data: ToolTypeValidateRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Validate a tool type template syntax.
|
||||
|
||||
Args:
|
||||
data: Validation request with definition type and template.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Validation result with success status and any errors.
|
||||
"""
|
||||
await _get_user(session, user_id)
|
||||
|
||||
errors = []
|
||||
|
||||
if data.definition_type == "compose":
|
||||
if not data.compose_template:
|
||||
errors.append("Compose template is required")
|
||||
else:
|
||||
try:
|
||||
parsed = yaml.safe_load(data.compose_template)
|
||||
if not isinstance(parsed, dict):
|
||||
errors.append("Compose template must be a YAML mapping")
|
||||
elif "services" not in parsed:
|
||||
errors.append("Compose template must contain 'services' key")
|
||||
elif not parsed["services"]:
|
||||
errors.append("Compose template must define at least one service")
|
||||
except yaml.YAMLError as e:
|
||||
errors.append(f"Invalid YAML: {e}")
|
||||
|
||||
elif data.definition_type == "dockerfile":
|
||||
if not data.dockerfile_template:
|
||||
errors.append("Dockerfile template is required")
|
||||
elif not data.dockerfile_template.strip().startswith("FROM"):
|
||||
errors.append("Dockerfile must start with a FROM instruction")
|
||||
|
||||
else:
|
||||
errors.append("definition_type must be 'compose' or 'dockerfile'")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tool_type_id}/validate",
|
||||
summary="Validate tool type",
|
||||
description="Validate the compose template or dockerfile syntax of a tool type.",
|
||||
)
|
||||
async def validate_tool_type(
|
||||
tool_type_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Validate a tool type's template syntax.
|
||||
|
||||
Args:
|
||||
tool_type_id: UUID of the tool type to validate.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Validation result with success status and any errors.
|
||||
"""
|
||||
await _get_user(session, user_id)
|
||||
tool_type = await session.get(ToolType, tool_type_id)
|
||||
if tool_type is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||
|
||||
errors = []
|
||||
|
||||
if tool_type.definition_type == "compose":
|
||||
if not tool_type.compose_template:
|
||||
errors.append("Compose template is empty")
|
||||
else:
|
||||
try:
|
||||
parsed = yaml.safe_load(tool_type.compose_template)
|
||||
if not isinstance(parsed, dict):
|
||||
errors.append("Compose template must be a YAML mapping")
|
||||
elif "services" not in parsed:
|
||||
errors.append("Compose template must contain 'services' key")
|
||||
elif not parsed["services"]:
|
||||
errors.append("Compose template must define at least one service")
|
||||
except yaml.YAMLError as e:
|
||||
errors.append(f"Invalid YAML: {e}")
|
||||
|
||||
elif tool_type.definition_type == "dockerfile":
|
||||
if not tool_type.dockerfile_template:
|
||||
errors.append("Dockerfile template is empty")
|
||||
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
|
||||
errors.append("Dockerfile must start with a FROM instruction")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{tool_type_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
|
||||
+33
-1
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -16,6 +17,7 @@ from src.api.projects import router as projects_router
|
||||
from src.api.ssh_keys import router as ssh_keys_router
|
||||
from src.api.terminal import router as terminal_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.tool_configs import router as tool_configs_router
|
||||
from src.api.tool_instances import router as tool_instances_router
|
||||
from src.api.tool_instances import sessions_router
|
||||
@@ -58,6 +60,32 @@ app.add_middleware(RequestLoggingMiddleware)
|
||||
app.add_middleware(ExceptionLoggingMiddleware)
|
||||
|
||||
|
||||
def _sanitize_validation_errors(errors):
|
||||
"""Convert validation errors to JSON-safe format."""
|
||||
sanitized = []
|
||||
for error in errors:
|
||||
safe_error = {
|
||||
"type": error.get("type"),
|
||||
"loc": error.get("loc"),
|
||||
"msg": error.get("msg"),
|
||||
"input": str(error.get("input")) if error.get("input") is not None else None,
|
||||
}
|
||||
# Convert ctx to safe format
|
||||
ctx = error.get("ctx")
|
||||
if ctx:
|
||||
safe_ctx = {}
|
||||
for key, value in ctx.items():
|
||||
if isinstance(value, Exception):
|
||||
safe_ctx[key] = str(value)
|
||||
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||
safe_ctx[key] = value
|
||||
else:
|
||||
safe_ctx[key] = str(value)
|
||||
safe_error["ctx"] = safe_ctx
|
||||
sanitized.append(safe_error)
|
||||
return sanitized
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""Log validation errors and return detailed response."""
|
||||
@@ -68,9 +96,10 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
||||
request.url.path,
|
||||
errors,
|
||||
)
|
||||
safe_errors = _sanitize_validation_errors(errors)
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": errors},
|
||||
content={"detail": safe_errors},
|
||||
)
|
||||
|
||||
|
||||
@@ -199,6 +228,7 @@ volumes:
|
||||
description=tool_data["description"],
|
||||
category=tool_data["category"],
|
||||
interfaces=tool_data["interfaces"],
|
||||
definition_type="compose",
|
||||
compose_template=tool_data["compose_template"],
|
||||
required_variables=tool_data["required_variables"],
|
||||
default_port=tool_data.get("default_port"),
|
||||
@@ -212,6 +242,7 @@ volumes:
|
||||
existing.description = tool_data["description"]
|
||||
existing.category = tool_data["category"]
|
||||
existing.interfaces = tool_data["interfaces"]
|
||||
existing.definition_type = "compose"
|
||||
existing.compose_template = tool_data["compose_template"]
|
||||
existing.required_variables = tool_data["required_variables"]
|
||||
existing.default_port = tool_data.get("default_port")
|
||||
@@ -245,6 +276,7 @@ app.include_router(ssh_keys_router)
|
||||
app.include_router(git_repositories_router)
|
||||
app.include_router(user_config_router)
|
||||
app.include_router(tool_types_router)
|
||||
app.include_router(config_folders_router)
|
||||
app.include_router(tool_instances_router)
|
||||
app.include_router(tool_configs_router)
|
||||
app.include_router(sessions_router)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from src.models.base import Base
|
||||
from src.models.config_folder import ConfigFolder
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
@@ -7,4 +8,4 @@ from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
__all__ = ["Base", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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,7 +1,7 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy import ForeignKey, JSON, String, Text
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -33,6 +33,15 @@ class ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
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()
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -62,6 +62,9 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
probe_result: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True
|
||||
)
|
||||
|
||||
tool_type: Mapped["ToolType"] = relationship()
|
||||
repository: Mapped["GitRepository"] = relationship()
|
||||
|
||||
@@ -20,7 +20,15 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||
compose_template: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
definition_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="compose"
|
||||
) # "compose" or "dockerfile"
|
||||
compose_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
dockerfile_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
build_context: Mapped[dict | None] = mapped_column(
|
||||
JSON, default=dict, nullable=True
|
||||
)
|
||||
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
|
||||
+174
-14
@@ -92,6 +92,59 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
full_path.write_text(content)
|
||||
|
||||
|
||||
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
||||
"""Write config folder files to the instance directory and return volume mounts.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
folders: List of ConfigFolder objects
|
||||
project_id: Optional project ID for applying overrides
|
||||
|
||||
Returns:
|
||||
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
volume_mounts = []
|
||||
|
||||
for folder in folders:
|
||||
# Determine mount path (with project override if applicable)
|
||||
mount_path = folder.mount_path
|
||||
files = folder.files.copy()
|
||||
|
||||
if project_id and folder.project_overrides:
|
||||
override = folder.project_overrides.get(str(project_id))
|
||||
if override:
|
||||
if override.get("mount_path"):
|
||||
mount_path = override["mount_path"]
|
||||
if override.get("files"):
|
||||
files.update(override["files"])
|
||||
|
||||
# Write files to instance directory
|
||||
folder_dir = instance_path / "volumes" / folder.name
|
||||
folder_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for file_path, content in files.items():
|
||||
# Security: ensure path doesn't escape folder_dir
|
||||
full_path = folder_dir / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(folder_dir.resolve())
|
||||
except ValueError:
|
||||
logger.warning("Config folder file path escapes directory: %s", file_path)
|
||||
continue
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
# Add volume mount
|
||||
volume_mounts.append({
|
||||
"source": str(folder_dir),
|
||||
"target": mount_path,
|
||||
"type": "bind",
|
||||
})
|
||||
|
||||
return volume_mounts
|
||||
|
||||
|
||||
def execute_compose_command(
|
||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
@@ -191,24 +244,94 @@ def connect_container_to_network(container_name: str, network_name: str = "backe
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def get_container_status(container_id: str) -> str:
|
||||
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
"""Get the status of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
|
||||
Returns:
|
||||
Container status string (running, exited, etc.)
|
||||
Dict with 'status' (running, exited, restarting, not_found),
|
||||
'exit_code' (int or None), and 'health' (health status or None)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
||||
[
|
||||
"docker", "inspect", "-f",
|
||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return "unknown"
|
||||
if result.returncode != 0:
|
||||
return {"status": "not_found", "exit_code": None, "health": None}
|
||||
|
||||
parts = result.stdout.strip().split("|")
|
||||
status = parts[0] if parts else "unknown"
|
||||
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||
|
||||
return {"status": status, "exit_code": exit_code, "health": health}
|
||||
|
||||
|
||||
def wait_for_container_running(
|
||||
container_id: str, timeout: int = 30, interval: float = 2.0
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for a container to reach the running state.
|
||||
|
||||
Polls docker inspect until the container status is "running" or timeout.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
timeout: Maximum seconds to wait
|
||||
interval: Seconds between polls
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||
and 'waited_seconds' (float)
|
||||
"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
info = get_container_status(container_id)
|
||||
|
||||
if info["status"] == "running":
|
||||
return {
|
||||
"success": True,
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
if info["status"] == "exited":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "exited",
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
if info["status"] == "not_found":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "not_found",
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
# Timeout reached
|
||||
info = get_container_status(container_id)
|
||||
return {
|
||||
"success": False,
|
||||
"status": info["status"],
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||
@@ -371,14 +494,15 @@ def recreate_tunnel(
|
||||
|
||||
|
||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
"""Check if a tunnel URL is healthy.
|
||||
"""Check if a tunnel URL is healthy with smart error classification.
|
||||
|
||||
Args:
|
||||
url: The tunnel URL to check
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
||||
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
|
||||
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
@@ -391,13 +515,49 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
timeout=timeout + 5,
|
||||
)
|
||||
status_code = int(result.stdout.strip())
|
||||
|
||||
if 200 <= status_code < 400:
|
||||
return {
|
||||
"tunnel_status": "healthy",
|
||||
"status_code": status_code,
|
||||
"healthy": True,
|
||||
"error": None,
|
||||
}
|
||||
elif status_code in (502, 503, 504):
|
||||
# Application error, not tunnel error
|
||||
return {
|
||||
"tunnel_status": "error_response",
|
||||
"status_code": status_code,
|
||||
"healthy": False,
|
||||
"error": f"Application returned HTTP {status_code}",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"tunnel_status": "error_response",
|
||||
"status_code": status_code,
|
||||
"healthy": False,
|
||||
"error": f"HTTP {status_code}",
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"healthy": 200 <= status_code < 400,
|
||||
"status_code": status_code,
|
||||
}
|
||||
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
||||
return {
|
||||
"healthy": False,
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": "Tunnel request timed out",
|
||||
}
|
||||
except (ValueError, Exception) as e:
|
||||
error_str = str(e).lower()
|
||||
# Classify connection errors
|
||||
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": f"Tunnel unreachable: {e}",
|
||||
}
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Docker build service for building images from Dockerfiles."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dict | None = None) -> tuple[int, str, str]:
|
||||
"""Build a Docker image from a Dockerfile.
|
||||
|
||||
Args:
|
||||
instance_dir: Directory containing the Dockerfile
|
||||
dockerfile: Dockerfile content
|
||||
tag: Image tag to apply
|
||||
build_context: Optional build context files {path: content}
|
||||
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Write Dockerfile
|
||||
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||
dockerfile_path.write_text(dockerfile)
|
||||
logger.info("Wrote Dockerfile to %s", dockerfile_path)
|
||||
|
||||
# Write build context files
|
||||
if build_context:
|
||||
for file_path, content in build_context.items():
|
||||
full_path = Path(instance_dir) / file_path
|
||||
# Security: ensure path doesn't escape instance_dir
|
||||
try:
|
||||
full_path.resolve().relative_to(Path(instance_dir).resolve())
|
||||
except ValueError:
|
||||
logger.error("Build context file path escapes instance directory: %s", file_path)
|
||||
raise ValueError(f"Build context file path '{file_path}' escapes instance directory")
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
logger.info("Wrote build context file: %s", full_path)
|
||||
|
||||
# Build image
|
||||
logger.info("Building Docker image with tag: %s", tag)
|
||||
cmd = [
|
||||
"docker", "build",
|
||||
"-t", tag,
|
||||
"-f", str(dockerfile_path),
|
||||
instance_dir,
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300, # 5 minute timeout for builds
|
||||
)
|
||||
logger.info("Docker build completed: returncode=%d", result.returncode)
|
||||
if result.returncode != 0:
|
||||
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Docker build timed out after 300 seconds")
|
||||
return 1, "", "Build timed out after 300 seconds"
|
||||
except Exception as exc:
|
||||
logger.exception("Docker build failed: %s", exc)
|
||||
return 1, "", str(exc)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Readiness probe service for checking if containers are ready."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def execute_probe(
|
||||
container_id: str,
|
||||
command: str,
|
||||
timeout: int = 30,
|
||||
interval: int = 2,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Execute a readiness probe command inside a container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name
|
||||
command: Command to execute inside the container
|
||||
timeout: Maximum total time to wait (seconds)
|
||||
interval: Time between retries (seconds)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, logs)
|
||||
"""
|
||||
logs = []
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
attempt += 1
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
|
||||
if elapsed >= timeout:
|
||||
logs.append(f"Probe timed out after {timeout}s ({attempt} attempts)")
|
||||
return False, logs
|
||||
|
||||
try:
|
||||
logger.debug("Probe attempt %d: %s", attempt, command)
|
||||
|
||||
# Execute command inside container
|
||||
result = subprocess.run(
|
||||
["docker", "exec", container_id, "sh", "-c", command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=interval, # Each attempt has its own timeout
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logs.append(f"Attempt {attempt}: Success")
|
||||
if result.stdout:
|
||||
logs.append(f"Output: {result.stdout.strip()}")
|
||||
return True, logs
|
||||
else:
|
||||
logs.append(f"Attempt {attempt}: Failed (exit code {result.returncode})")
|
||||
if result.stderr:
|
||||
logs.append(f"Stderr: {result.stderr.strip()[:200]}")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logs.append(f"Attempt {attempt}: Command timed out")
|
||||
except Exception as exc:
|
||||
logs.append(f"Attempt {attempt}: Error - {exc}")
|
||||
|
||||
# Wait before next attempt
|
||||
await asyncio.sleep(interval)
|
||||
@@ -121,12 +121,19 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
|
||||
Raises:
|
||||
RuntimeError: If branch creation fails
|
||||
"""
|
||||
if base_branch == "HEAD":
|
||||
try:
|
||||
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
except RuntimeError:
|
||||
# No commits yet - empty repository
|
||||
try:
|
||||
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD")
|
||||
except RuntimeError:
|
||||
_run_git_command(repo_path, "checkout", "--orphan", name)
|
||||
return
|
||||
except RuntimeError as e:
|
||||
if "work tree" in str(e).lower():
|
||||
# Bare repository - use symbolic-ref instead
|
||||
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||
return
|
||||
raise
|
||||
return
|
||||
|
||||
_run_git_command(repo_path, "branch", name, base_branch)
|
||||
|
||||
@@ -156,7 +163,14 @@ def checkout_branch(repo_path: str, name: str) -> None:
|
||||
Raises:
|
||||
RuntimeError: If checkout fails
|
||||
"""
|
||||
_run_git_command(repo_path, "checkout", name)
|
||||
try:
|
||||
_run_git_command(repo_path, "checkout", name)
|
||||
except RuntimeError as e:
|
||||
if "work tree" in str(e).lower():
|
||||
# Bare repository - use symbolic-ref instead
|
||||
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
def commit_changes(
|
||||
@@ -291,6 +305,10 @@ def get_current_branch(repo_path: str) -> str:
|
||||
Current branch name
|
||||
"""
|
||||
try:
|
||||
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||
if branch != "HEAD":
|
||||
return branch
|
||||
except RuntimeError:
|
||||
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||
pass
|
||||
|
||||
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||
|
||||
+131
-70
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
from typing import AsyncGenerator, Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -11,82 +12,142 @@ from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Set test environment BEFORE importing app modules
|
||||
os.environ["APP_ENV"] = "testing"
|
||||
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
from src.config import Settings, build_database_url
|
||||
from src.models.base import Base
|
||||
from src.main import app
|
||||
|
||||
|
||||
# Unit test fixtures (SQLite in-memory)
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def sqlite_engine():
|
||||
"""Create a SQLite in-memory engine for unit tests."""
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session(sqlite_engine) -> Generator:
|
||||
"""Provide a SQLite session for unit tests."""
|
||||
connection = sqlite_engine.connect()
|
||||
transaction = connection.begin()
|
||||
session = sessionmaker(bind=connection)()
|
||||
|
||||
yield session
|
||||
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
|
||||
|
||||
# Integration test fixtures (PostgreSQL)
|
||||
|
||||
TEST_DATABASE_URL = build_database_url(
|
||||
user="headquarter",
|
||||
password="headquarter",
|
||||
host="localhost",
|
||||
port=5432,
|
||||
database="headquarter",
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def postgres_engine():
|
||||
"""Create a PostgreSQL engine for integration tests."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(postgres_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Provide a database session with transaction rollback."""
|
||||
async with postgres_engine.connect() as connection:
|
||||
transaction = await connection.begin_nested()
|
||||
session_factory = async_sessionmaker(
|
||||
connection, expire_on_commit=False, class_=AsyncSession
|
||||
)
|
||||
session = session_factory()
|
||||
|
||||
yield session
|
||||
|
||||
await session.close()
|
||||
await transaction.rollback()
|
||||
from src.auth.dependencies import get_db_session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client() -> Generator[TestClient, None, None]:
|
||||
"""Provide a FastAPI test client."""
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
"""Provide a FastAPI test client with SQLite database."""
|
||||
# Create a single engine for this test
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
# Create tables
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
asyncio.run(init_db())
|
||||
|
||||
async def override_get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||
yield session
|
||||
|
||||
# Override the dependency
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
|
||||
# Patch startup events to prevent PostgreSQL connection attempts
|
||||
with patch("src.main.init_database") as mock_init, \
|
||||
patch("src.main.seed_builtin_tool_types") as mock_seed:
|
||||
mock_init.return_value = True
|
||||
mock_seed.return_value = None
|
||||
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
finally:
|
||||
# Clean up overrides
|
||||
app.dependency_overrides.pop(get_db_session, None)
|
||||
asyncio.run(engine.dispose())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def configure_test_env(monkeypatch):
|
||||
"""Configure environment for testing."""
|
||||
monkeypatch.setenv("DATABASE_URL", TEST_DATABASE_URL)
|
||||
monkeypatch.setenv("APP_ENV", "testing")
|
||||
@pytest.fixture
|
||||
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||
"""Provide an authenticated test client with a test user."""
|
||||
import uuid
|
||||
from src.auth.session import create_session_cookie
|
||||
from src.models.user import User
|
||||
|
||||
user_id = str(uuid.uuid4())
|
||||
settings = Settings()
|
||||
|
||||
# Create user in database using the same engine as test_client
|
||||
# We need to access the engine from the test_client fixture
|
||||
# Since we can't easily do that, we'll create the user via API call
|
||||
# But we need the user to exist before any API calls
|
||||
# So we need to create the user using the overridden dependency
|
||||
|
||||
async def create_test_user():
|
||||
# Get the override function
|
||||
override_fn = app.dependency_overrides.get(get_db_session)
|
||||
if override_fn:
|
||||
gen = override_fn()
|
||||
session = await gen.asend(None)
|
||||
try:
|
||||
user = User(
|
||||
id=uuid.UUID(user_id),
|
||||
email="test@headquarter.local",
|
||||
name="Test User",
|
||||
authentik_id=f"authentik-{user_id}",
|
||||
avatar_url=None,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
finally:
|
||||
await gen.aclose()
|
||||
|
||||
asyncio.run(create_test_user())
|
||||
|
||||
# Create session cookie
|
||||
session_cookie = create_session_cookie(
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Set cookie on client
|
||||
test_client.cookies.set("session", session_cookie)
|
||||
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
||||
"""Provide an authenticated test client with an admin user."""
|
||||
import uuid
|
||||
from src.auth.session import create_session_cookie
|
||||
from src.models.user import User
|
||||
|
||||
user_id = str(uuid.uuid4())
|
||||
settings = Settings()
|
||||
|
||||
async def create_admin_user():
|
||||
override_fn = app.dependency_overrides.get(get_db_session)
|
||||
if override_fn:
|
||||
gen = override_fn()
|
||||
session = await gen.asend(None)
|
||||
try:
|
||||
user = User(
|
||||
id=uuid.UUID(user_id),
|
||||
email="admin@headquarter.local",
|
||||
name="Admin User",
|
||||
authentik_id=f"authentik-admin-{user_id}",
|
||||
avatar_url=None,
|
||||
is_admin=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
finally:
|
||||
await gen.aclose()
|
||||
|
||||
asyncio.run(create_admin_user())
|
||||
|
||||
# Create session cookie
|
||||
session_cookie = create_session_cookie(
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Set cookie on client
|
||||
test_client.cookies.set("session", session_cookie)
|
||||
|
||||
yield test_client
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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"]
|
||||
@@ -70,6 +70,20 @@ def test_get_current_branch_handles_unborn_main() -> None:
|
||||
assert get_current_branch(tmpdir) == "main"
|
||||
|
||||
|
||||
def test_create_branch_on_bare_repo_with_no_commits() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||
create_branch(f"{tmpdir}/bare.git", "main")
|
||||
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||
|
||||
|
||||
def test_checkout_branch_on_bare_repo_with_no_commits() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||
checkout_branch(f"{tmpdir}/bare.git", "main")
|
||||
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||
|
||||
|
||||
class TestBranchOperations:
|
||||
"""Tests for branch management functions."""
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from src.models import Base
|
||||
from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.refresh_token import RefreshToken
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
from src.auth.jwt_service import mint_access_token
|
||||
from src.auth.session import create_session_cookie
|
||||
from src.config import Settings, build_database_url
|
||||
from src.models import Base
|
||||
from src.models.project import Project
|
||||
@@ -53,7 +53,7 @@ def _load_app():
|
||||
|
||||
def _mint_token(user_id: str) -> str:
|
||||
settings = Settings()
|
||||
return mint_access_token(
|
||||
return create_session_cookie(
|
||||
settings=settings,
|
||||
subject=user_id,
|
||||
email="test@headquarter.local",
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import uuid
|
||||
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
|
||||
@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
from src.auth.jwt_service import mint_access_token
|
||||
from src.auth.session import create_session_cookie
|
||||
from src.config import Settings, build_database_url
|
||||
from src.models import Base
|
||||
from src.models.tool_type import ToolType
|
||||
@@ -54,7 +54,7 @@ def _load_app():
|
||||
|
||||
def _mint_token(user_id: str) -> str:
|
||||
settings = Settings()
|
||||
return mint_access_token(
|
||||
return create_session_cookie(
|
||||
settings=settings,
|
||||
subject=user_id,
|
||||
email="test@headquarter.local",
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestToolTypesAPIExtended:
|
||||
"""Integration tests for tool types API with new fields."""
|
||||
|
||||
def test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) -> None:
|
||||
"""Test creating a tool type with dockerfile definition."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types",
|
||||
json={
|
||||
"name": "dockerfile-tool",
|
||||
"display_name": "Dockerfile Tool",
|
||||
"category": "utility",
|
||||
"interfaces": ["terminal"],
|
||||
"default_port": 8080,
|
||||
"definition_type": "dockerfile",
|
||||
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
|
||||
"required_variables": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "dockerfile-tool"
|
||||
assert data["definition_type"] == "dockerfile"
|
||||
assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask"
|
||||
|
||||
def test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) -> None:
|
||||
"""Test creating a tool type with readiness probe."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types",
|
||||
json={
|
||||
"name": "probed-tool",
|
||||
"display_name": "Probed Tool",
|
||||
"category": "utility",
|
||||
"interfaces": ["web"],
|
||||
"default_port": 8080,
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||
"readiness_probe": {
|
||||
"command": "curl -f http://localhost:8080",
|
||||
"timeout": 30,
|
||||
"interval": 2,
|
||||
},
|
||||
"required_variables": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080"
|
||||
assert data["readiness_probe"]["timeout"] == 30
|
||||
|
||||
def test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that invalid definition types are rejected."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types",
|
||||
json={
|
||||
"name": "invalid-tool",
|
||||
"display_name": "Invalid Tool",
|
||||
"default_port": 8080,
|
||||
"definition_type": "invalid",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||
"required_variables": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that dockerfile type requires dockerfile_template."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types",
|
||||
json={
|
||||
"name": "no-dockerfile",
|
||||
"display_name": "No Dockerfile",
|
||||
"default_port": 8080,
|
||||
"definition_type": "dockerfile",
|
||||
"required_variables": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) -> None:
|
||||
"""Test updating a tool type with new fields."""
|
||||
# Create tool type first
|
||||
create_response = authenticated_client.post(
|
||||
"/tool-types",
|
||||
json={
|
||||
"name": "update-test-tool",
|
||||
"display_name": "Update Test Tool",
|
||||
"default_port": 8080,
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||
"required_variables": [],
|
||||
},
|
||||
)
|
||||
tool_id = create_response.json()["id"]
|
||||
|
||||
# Update it
|
||||
response = authenticated_client.put(
|
||||
f"/tool-types/{tool_id}",
|
||||
json={
|
||||
"display_name": "Updated Name",
|
||||
"readiness_probe": {
|
||||
"command": "curl -f http://localhost:8080/health",
|
||||
"timeout": 60,
|
||||
"interval": 5,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["display_name"] == "Updated Name"
|
||||
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health"
|
||||
|
||||
def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None:
|
||||
"""Test validating compose template."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types/validate",
|
||||
json={
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
|
||||
def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None:
|
||||
"""Test validating invalid compose template."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types/validate",
|
||||
json={
|
||||
"definition_type": "compose",
|
||||
"compose_template": "invalid: yaml: [",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is False
|
||||
assert "errors" in data
|
||||
|
||||
def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None:
|
||||
"""Test validating dockerfile template."""
|
||||
response = authenticated_client.post(
|
||||
"/tool-types/validate",
|
||||
json={
|
||||
"definition_type": "dockerfile",
|
||||
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
|
||||
def test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) -> None:
|
||||
"""Test that GET returns new fields."""
|
||||
# Create tool type with all fields
|
||||
create_response = authenticated_client.post(
|
||||
"/tool-types",
|
||||
json={
|
||||
"name": "full-tool",
|
||||
"display_name": "Full Tool",
|
||||
"category": "editor",
|
||||
"interfaces": ["web", "terminal"],
|
||||
"default_port": 8443,
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
||||
"readiness_probe": {
|
||||
"command": "curl -f http://localhost:8443",
|
||||
"timeout": 30,
|
||||
"interval": 2,
|
||||
},
|
||||
"required_variables": ["REPO_PATH"],
|
||||
},
|
||||
)
|
||||
tool_id = create_response.json()["id"]
|
||||
|
||||
# Get it
|
||||
response = authenticated_client.get(f"/tool-types/{tool_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["definition_type"] == "compose"
|
||||
assert data["category"] == "editor"
|
||||
assert data["interfaces"] == ["web", "terminal"]
|
||||
assert "readiness_probe" in data
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from src.auth.jwt_service import mint_access_token
|
||||
from src.auth.session import create_session_cookie
|
||||
from src.config import Settings, build_database_url
|
||||
from src.models import Base
|
||||
from src.models.user import User
|
||||
@@ -78,7 +78,7 @@ def _insert_test_user(user_id: str) -> None:
|
||||
|
||||
def _create_auth_cookie(user_id: str) -> str:
|
||||
settings = Settings()
|
||||
return mint_access_token(
|
||||
return create_session_cookie(
|
||||
settings=settings,
|
||||
subject=user_id,
|
||||
email="test@headquarter.local",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Unit tests for docker build service."""
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.docker_build import build_image
|
||||
|
||||
|
||||
class TestBuildImage:
|
||||
"""Tests for build_image function."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_builds_image_successfully(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="Successfully built abc123",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = build_image(tmpdir, "FROM python:3.11", "test-image:latest")
|
||||
|
||||
assert result[0] == 0
|
||||
assert "Successfully built" in result[1]
|
||||
mock_run.assert_called_once()
|
||||
call_args = mock_run.call_args
|
||||
assert "test-image:latest" in call_args[0][0]
|
||||
assert "build" in call_args[0][0]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_build_fails(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="Error: failed to build",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = build_image(tmpdir, "FROM invalid:image", "test-image:latest")
|
||||
|
||||
assert result[0] == 1
|
||||
assert "failed to build" in result[2]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_build_with_tag(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
build_image(tmpdir, "FROM python:3.11", "my-registry/tool:v1.0")
|
||||
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "my-registry/tool:v1.0" in call_args
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_build_command_structure(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
build_image(tmpdir, "FROM python:3.11", "test:latest")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[0] == "docker"
|
||||
assert cmd[1] == "build"
|
||||
assert "-t" in cmd
|
||||
assert "test:latest" in cmd
|
||||
assert tmpdir in cmd
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_build_writes_dockerfile(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
dockerfile_content = "FROM python:3.11\\nRUN pip install flask"
|
||||
build_image(tmpdir, dockerfile_content, "test:latest")
|
||||
|
||||
dockerfile_path = Path(tmpdir) / "Dockerfile"
|
||||
assert dockerfile_path.exists()
|
||||
assert dockerfile_path.read_text() == dockerfile_content
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_build_writes_context_files(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
build_context = {
|
||||
"requirements.txt": "flask==2.0\\nnumpy==1.21",
|
||||
"app.py": "from flask import Flask\\napp = Flask(__name__)",
|
||||
}
|
||||
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
|
||||
|
||||
req_path = Path(tmpdir) / "requirements.txt"
|
||||
app_path = Path(tmpdir) / "app.py"
|
||||
assert req_path.exists()
|
||||
assert req_path.read_text() == "flask==2.0\\nnumpy==1.21"
|
||||
assert app_path.exists()
|
||||
assert app_path.read_text() == "from flask import Flask\\napp = Flask(__name__)"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_build_creates_nested_directories(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
build_context = {
|
||||
"src/app.py": "print('hello')",
|
||||
}
|
||||
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
|
||||
|
||||
app_path = Path(tmpdir) / "src" / "app.py"
|
||||
assert app_path.exists()
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_build_prevents_path_traversal(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
build_context = {
|
||||
"../../../etc/passwd": "root:x:0:0",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="escapes instance directory"):
|
||||
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
|
||||
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_build_timeout(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["docker", "build"], timeout=300)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
|
||||
|
||||
assert result[0] == 1
|
||||
assert "timed out" in result[2].lower()
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_build_exception(self, mock_run) -> None:
|
||||
mock_run.side_effect = OSError("Docker not available")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
|
||||
|
||||
assert result[0] == 1
|
||||
assert "Docker not available" in result[2]
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Unit tests for readiness probe service."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.readiness_probe import execute_probe
|
||||
|
||||
|
||||
class TestExecuteProbe:
|
||||
"""Tests for execute_probe function."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_probe_succeeds_first_attempt(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="healthy",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080")
|
||||
|
||||
assert result is True
|
||||
assert any("Success" in log for log in logs)
|
||||
mock_run.assert_called_once_with(
|
||||
["docker", "exec", "container-123", "sh", "-c", "curl -f http://localhost:8080"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_probe_fails_then_succeeds(self, mock_run) -> None:
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
|
||||
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
|
||||
MagicMock(returncode=0, stdout="healthy", stderr=""),
|
||||
]
|
||||
|
||||
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=10, interval=0.1)
|
||||
|
||||
assert result is True
|
||||
assert mock_run.call_count == 3
|
||||
assert any("Attempt 1: Failed" in log for log in logs)
|
||||
assert any("Attempt 3: Success" in log for log in logs)
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_probe_times_out(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="Connection refused",
|
||||
)
|
||||
|
||||
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=0.5, interval=0.2)
|
||||
|
||||
assert result is False
|
||||
assert any("timed out" in log.lower() for log in logs)
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_probe_command_not_found(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=127,
|
||||
stdout="",
|
||||
stderr="command not found",
|
||||
)
|
||||
|
||||
result, logs = await execute_probe("container-123", "nonexistent-command", timeout=1, interval=0.3)
|
||||
|
||||
assert result is False
|
||||
assert any("exit code 127" in log for log in logs)
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_probe_exception(self, mock_run) -> None:
|
||||
mock_run.side_effect = OSError("Docker not available")
|
||||
|
||||
result, logs = await execute_probe("container-123", "curl http://localhost", timeout=1, interval=0.3)
|
||||
|
||||
assert result is False
|
||||
assert any("Error" in log for log in logs)
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_probe_with_special_characters(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
cmd = "bash -c 'echo \"hello world\" && exit 0'"
|
||||
await execute_probe("container-123", cmd)
|
||||
|
||||
call_args = mock_run.call_args
|
||||
assert cmd in call_args[0][0]
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_probe_captures_stdout(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="Server is ready\\nVersion: 1.0",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
result, logs = await execute_probe("container-123", "cat /app/status")
|
||||
|
||||
assert result is True
|
||||
assert any("Server is ready" in log for log in logs)
|
||||
|
||||
|
||||
class TestIntegrationScenarios:
|
||||
"""Integration-style tests with realistic scenarios."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_web_server_probe(self, mock_run) -> None:
|
||||
"""Test typical web server health check."""
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=1, stdout="", stderr=""),
|
||||
MagicMock(returncode=1, stdout="", stderr=""),
|
||||
MagicMock(returncode=1, stdout="", stderr=""),
|
||||
MagicMock(returncode=0, stdout="OK", stderr=""),
|
||||
]
|
||||
|
||||
result, logs = await execute_probe(
|
||||
"web-container",
|
||||
"curl -f http://localhost:8080/health",
|
||||
timeout=10,
|
||||
interval=0.2,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert mock_run.call_count == 4
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_command_probe(self, mock_run) -> None:
|
||||
"""Test command availability check."""
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="opencode 1.0.0",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
result, logs = await execute_probe(
|
||||
"tool-container",
|
||||
"which opencode && opencode --version",
|
||||
timeout=30,
|
||||
interval=2,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert any("opencode 1.0.0" in log for log in logs)
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_database_probe(self, mock_run) -> None:
|
||||
"""Test database readiness check."""
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=1, stdout="", stderr=""),
|
||||
MagicMock(returncode=1, stdout="", stderr=""),
|
||||
MagicMock(returncode=0, stdout="/var/run/postgresql:5432 - accepting connections", stderr=""),
|
||||
]
|
||||
|
||||
result, logs = await execute_probe(
|
||||
"db-container",
|
||||
"pg_isready -U postgres",
|
||||
timeout=10,
|
||||
interval=0.3,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert mock_run.call_count == 3
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_file_probe(self, mock_run) -> None:
|
||||
"""Test file existence check."""
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
result, logs = await execute_probe(
|
||||
"app-container",
|
||||
"[ -f /app/ready ]",
|
||||
timeout=10,
|
||||
interval=1,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_slow_starting_service(self, mock_run) -> None:
|
||||
"""Test service that takes time to start."""
|
||||
# Simulate 5 failures before success
|
||||
side_effects = [MagicMock(returncode=1, stdout="", stderr="")] * 5
|
||||
side_effects.append(MagicMock(returncode=0, stdout="Ready", stderr=""))
|
||||
mock_run.side_effect = side_effects
|
||||
|
||||
result, logs = await execute_probe(
|
||||
"slow-container",
|
||||
"curl -f http://localhost:8080",
|
||||
timeout=10,
|
||||
interval=0.2,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert mock_run.call_count == 6
|
||||
assert any("Attempt 6: Success" in log for log in logs)
|
||||
|
||||
@patch("subprocess.run")
|
||||
async def test_zero_timeout_immediate_return(self, mock_run) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
||||
|
||||
result, logs = await execute_probe(
|
||||
"container",
|
||||
"test",
|
||||
timeout=0,
|
||||
interval=1,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
assert any("timed out" in log.lower() for log in logs)
|
||||
Generated
+1371
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Headquarter</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Headquarter - UI Preview</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f1ea;
|
||||
--panel: #fffef9;
|
||||
--ink: #1d1d1b;
|
||||
--muted: #5f5b55;
|
||||
--brand: #275d4b;
|
||||
--brand-strong: #154236;
|
||||
--border: #d8d0c5;
|
||||
--primary: #275d4b;
|
||||
--primary-fg: #fffef9;
|
||||
--color-primary: #275d4b;
|
||||
--success: #2f8f62;
|
||||
--success-light: rgba(47, 143, 98, 0.14);
|
||||
--warning: #c08a1e;
|
||||
--warning-light: rgba(192, 138, 30, 0.14);
|
||||
--danger: #b94a3c;
|
||||
--danger-light: rgba(185, 74, 60, 0.14);
|
||||
--info: #4f7fb8;
|
||||
--info-light: rgba(79, 127, 184, 0.14);
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
--space-6: 2rem;
|
||||
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
|
||||
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
|
||||
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
|
||||
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
|
||||
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* App Shell */
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
padding: 0.58rem 0.85rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shell-body {
|
||||
display: grid;
|
||||
grid-template-columns: 230px 1fr;
|
||||
min-height: calc(100vh - 57px);
|
||||
}
|
||||
|
||||
.shell-nav {
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #ece7df;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.nav-item-active {
|
||||
background: var(--brand);
|
||||
color: #f7fff7;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.session-item {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.session-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.session-status.running {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.shell-content {
|
||||
padding: 1.25rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Common Components */
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stack-sm {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.58rem 0.85rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border-color: var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.58rem 0.85rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Home Page */
|
||||
.home-page {
|
||||
max-width: 1240px;
|
||||
}
|
||||
|
||||
.home-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.home-hero-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.home-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.home-summary-card .card-label {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.home-summary-card .card-value {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home-section h2,
|
||||
.home-section h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.home-session-grid,
|
||||
.home-project-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.session-card {
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-badge.building {
|
||||
background: var(--warning-light);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: var(--info-light);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.recent-sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.recent-session-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.recent-session-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.create-session-form .form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field select,
|
||||
.form-field textarea {
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Settings Page */
|
||||
.settings-page {
|
||||
max-width: 1240px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--panel);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Preview Switcher */
|
||||
.preview-switcher {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.preview-switcher button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preview-switcher button.active {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-preview.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 767px) {
|
||||
.shell-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.shell-nav {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.home-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="shell-header">
|
||||
<a href="#" class="brand">Headquarter</a>
|
||||
<div class="header-actions">
|
||||
<a href="#" class="user-chip">User</a>
|
||||
<button class="ghost-button">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="shell-body">
|
||||
<aside class="shell-nav" aria-label="Primary navigation">
|
||||
<a href="#" class="nav-item nav-item-active">
|
||||
<span>🏠</span> Home
|
||||
<span class="nav-badge">3</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item">
|
||||
<span>📁</span> Projects
|
||||
</a>
|
||||
<a href="#" class="nav-item">
|
||||
<span>⚙️</span> Settings
|
||||
</a>
|
||||
|
||||
<div class="nav-divider"></div>
|
||||
<div class="nav-section-title">Live sessions</div>
|
||||
<a href="#" class="nav-item session-item">
|
||||
<span class="session-status running"></span>
|
||||
<span>Dev Environment</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item session-item">
|
||||
<span class="session-status running"></span>
|
||||
<span>Jupyter Lab</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item session-item">
|
||||
<span class="session-status"></span>
|
||||
<span>Code Server</span>
|
||||
</a>
|
||||
</aside>
|
||||
|
||||
<main class="shell-content">
|
||||
<!-- HOME PAGE PREVIEW -->
|
||||
<div id="home-preview" class="page-preview active">
|
||||
<section class="stack home-page">
|
||||
<header class="home-hero card">
|
||||
<div class="stack-sm">
|
||||
<p class="eyebrow">Workspace overview</p>
|
||||
<h1>Home</h1>
|
||||
<p class="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||
</div>
|
||||
<div class="home-hero-actions">
|
||||
<button class="primary-button">New Project</button>
|
||||
<button class="secondary-button">Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="home-summary-grid">
|
||||
<article class="card home-summary-card">
|
||||
<p class="card-label">Open sessions</p>
|
||||
<p class="card-value">3</p>
|
||||
</article>
|
||||
<article class="card home-summary-card">
|
||||
<p class="card-label">Projects</p>
|
||||
<p class="card-value">5</p>
|
||||
</article>
|
||||
<article class="card home-summary-card">
|
||||
<p class="card-label">Repositories</p>
|
||||
<p class="card-value">12</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="card stack home-section">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Open sessions</p>
|
||||
<h2>3</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-session-grid">
|
||||
<article class="card session-card">
|
||||
<div class="stack-sm">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<h3>Dev Environment</h3>
|
||||
<span class="status-badge running">running</span>
|
||||
</div>
|
||||
<p class="muted">Acme Corp · main</p>
|
||||
<p class="muted">VS Code Server</p>
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card session-card">
|
||||
<div class="stack-sm">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<h3>Jupyter Lab</h3>
|
||||
<span class="status-badge running">running</span>
|
||||
</div>
|
||||
<p class="muted">Data Science · experiments</p>
|
||||
<p class="muted">Jupyter Notebook</p>
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card session-card">
|
||||
<div class="stack-sm">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<h3>Database Console</h3>
|
||||
<span class="status-badge building">building</span>
|
||||
</div>
|
||||
<p class="muted">Backend API · staging</p>
|
||||
<p class="muted">PostgreSQL Client</p>
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack home-section">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Available projects</p>
|
||||
<h2>5</h2>
|
||||
</div>
|
||||
<button class="secondary-button">View all</button>
|
||||
</div>
|
||||
<div class="home-project-grid">
|
||||
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||
<div class="stack-sm">
|
||||
<h3>Acme Corp</h3>
|
||||
<p class="muted">Main product development</p>
|
||||
</div>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||
</article>
|
||||
|
||||
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||
<div class="stack-sm">
|
||||
<h3>Data Science</h3>
|
||||
<p class="muted">ML experiments and notebooks</p>
|
||||
</div>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||
</article>
|
||||
|
||||
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||
<div class="stack-sm">
|
||||
<h3>Backend API</h3>
|
||||
<p class="muted">REST API services</p>
|
||||
</div>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card stack home-section">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Quick create</p>
|
||||
<h2>Start a session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form class="stack create-session-form">
|
||||
<div class="form-row">
|
||||
<label class="form-field">
|
||||
Project
|
||||
<select>
|
||||
<option>Select project...</option>
|
||||
<option>Acme Corp</option>
|
||||
<option>Data Science</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
Repository
|
||||
<select disabled>
|
||||
<option>Select repository...</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
Tool type
|
||||
<select>
|
||||
<option>Select tool...</option>
|
||||
<option>VS Code Server</option>
|
||||
<option>Jupyter Lab</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="form-field">
|
||||
Display name
|
||||
<input type="text" placeholder="My Development Environment">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button class="primary-button" type="submit">Create Session</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card stack home-section">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Recent sessions</p>
|
||||
<h2>2</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recent-sessions-list">
|
||||
<article class="recent-session-item">
|
||||
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
<span class="recent-session-name">Old Dev Box</span>
|
||||
<span class="muted">Acme Corp · VS Code Server</span>
|
||||
</div>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||
</article>
|
||||
<article class="recent-session-item">
|
||||
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
<span class="recent-session-name">ML Training</span>
|
||||
<span class="muted">Data Science · Jupyter Lab</span>
|
||||
</div>
|
||||
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- SETTINGS PAGE PREVIEW -->
|
||||
<div id="settings-preview" class="page-preview">
|
||||
<section class="stack settings-page">
|
||||
<header class="settings-header card stack-sm">
|
||||
<div>
|
||||
<p class="eyebrow">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<p class="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||
</header>
|
||||
|
||||
<nav class="settings-tabs" aria-label="Settings sections">
|
||||
<a href="#" class="settings-tab active">General</a>
|
||||
<a href="#" class="settings-tab">SSH Keys</a>
|
||||
<a href="#" class="settings-tab">Tool Types</a>
|
||||
<a href="#" class="settings-tab">Tool Configs</a>
|
||||
</nav>
|
||||
|
||||
<div class="settings-panel card">
|
||||
<div class="stack">
|
||||
<h2>General</h2>
|
||||
<label class="form-field">
|
||||
Theme
|
||||
<select>
|
||||
<option>System</option>
|
||||
<option>Light</option>
|
||||
<option>Dark</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
Git user name
|
||||
<input type="text" placeholder="Your git commit name" value="John Doe">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
Git user email
|
||||
<input type="email" placeholder="your.email@example.com" value="john@example.com">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
Default editor
|
||||
<input type="text" placeholder="e.g., vscode, vim, cursor" value="vscode">
|
||||
</label>
|
||||
<div class="settings-actions">
|
||||
<button class="primary-button">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-switcher">
|
||||
<button class="active" onclick="showPage('home')">Home</button>
|
||||
<button onclick="showPage('settings')">Settings</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showPage(page) {
|
||||
document.querySelectorAll('.page-preview').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.preview-switcher button').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById(page + '-preview').classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,131 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
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}`);
|
||||
};
|
||||
@@ -25,6 +25,8 @@ export interface Session {
|
||||
project_id: string;
|
||||
status: string;
|
||||
url: string | null;
|
||||
container_status?: string;
|
||||
probe_status?: string;
|
||||
}
|
||||
|
||||
export async function listInstances(
|
||||
@@ -101,11 +103,23 @@ export async function getUserSessions(): Promise<Session[]> {
|
||||
return response.data.sessions;
|
||||
}
|
||||
|
||||
export interface InstanceHealth {
|
||||
healthy: boolean;
|
||||
container_status: string;
|
||||
container_health: string | null;
|
||||
container_exit_code: number | null;
|
||||
tunnel_status: string;
|
||||
tunnel_status_code: number | null;
|
||||
probe_status: string;
|
||||
last_probe_output: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function checkInstanceHealth(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
|
||||
): Promise<InstanceHealth> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
||||
);
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface ToolConfig {
|
||||
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 {
|
||||
@@ -17,6 +22,11 @@ export interface CreateToolConfigRequest {
|
||||
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 (
|
||||
@@ -53,4 +63,13 @@ export const updateToolConfig = async (
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createToolType,
|
||||
deleteToolType,
|
||||
listToolTypes,
|
||||
updateToolType,
|
||||
validateToolType,
|
||||
} from "../api/tool_types";
|
||||
|
||||
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("tool_types API", () => {
|
||||
describe("listToolTypes", () => {
|
||||
it("returns tool types with new fields", async () => {
|
||||
const mockResponse = {
|
||||
data: [
|
||||
{
|
||||
id: "type-1",
|
||||
name: "custom-tool",
|
||||
display_name: "Custom Tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM python:3.11",
|
||||
readiness_probe: {
|
||||
command: "python --version",
|
||||
timeout: 30,
|
||||
interval: 2,
|
||||
},
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await listToolTypes();
|
||||
|
||||
expect(result[0].definition_type).toBe("dockerfile");
|
||||
expect(result[0].dockerfile_template).toBe("FROM python:3.11");
|
||||
expect(result[0].readiness_probe).toEqual({
|
||||
command: "python --version",
|
||||
timeout: 30,
|
||||
interval: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns compose tool types", async () => {
|
||||
const mockResponse = {
|
||||
data: [
|
||||
{
|
||||
id: "type-1",
|
||||
name: "code-server",
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'",
|
||||
dockerfile_template: null,
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await listToolTypes();
|
||||
|
||||
expect(result[0].definition_type).toBe("compose");
|
||||
expect(result[0].dockerfile_template).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createToolType", () => {
|
||||
it("creates tool type with dockerfile", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
id: "new-type",
|
||||
name: "docker-tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM node:18",
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await createToolType({
|
||||
name: "docker-tool",
|
||||
display_name: "Docker Tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM node:18",
|
||||
default_port: 3000,
|
||||
required_variables: [],
|
||||
});
|
||||
|
||||
expect(result.definition_type).toBe("dockerfile");
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/tool-types",
|
||||
expect.objectContaining({
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM node:18",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("creates tool type with readiness probe", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
id: "new-type",
|
||||
name: "probed-tool",
|
||||
readiness_probe: {
|
||||
command: "curl -f http://localhost:8080",
|
||||
timeout: 60,
|
||||
interval: 3,
|
||||
},
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await createToolType({
|
||||
name: "probed-tool",
|
||||
display_name: "Probed Tool",
|
||||
compose_template: "version: '3.8'",
|
||||
default_port: 8080,
|
||||
required_variables: [],
|
||||
readiness_probe: {
|
||||
command: "curl -f http://localhost:8080",
|
||||
timeout: 60,
|
||||
interval: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.readiness_probe).toEqual({
|
||||
command: "curl -f http://localhost:8080",
|
||||
timeout: 60,
|
||||
interval: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateToolType", () => {
|
||||
it("validates tool type by id", async () => {
|
||||
const mockResponse = {
|
||||
data: { valid: true, errors: [] },
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await validateToolType("type-1");
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(mockGet).toHaveBeenCalledWith("/tool-types/type-1/validate");
|
||||
});
|
||||
|
||||
it("returns validation errors", async () => {
|
||||
const mockResponse = {
|
||||
data: { valid: false, errors: ["Invalid YAML"] },
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await validateToolType("type-1");
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Invalid YAML");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateToolType", () => {
|
||||
it("updates tool type with new fields", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
id: "type-1",
|
||||
name: "updated-tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM python:3.11",
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPut.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await updateToolType("type-1", {
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM python:3.11",
|
||||
});
|
||||
|
||||
expect(result.definition_type).toBe("dockerfile");
|
||||
expect(mockPut).toHaveBeenCalledWith(
|
||||
"/tool-types/type-1",
|
||||
expect.objectContaining({
|
||||
definition_type: "dockerfile",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteToolType", () => {
|
||||
it("deletes tool type", async () => {
|
||||
mockDelete.mockResolvedValue({ data: undefined });
|
||||
|
||||
await deleteToolType("type-1");
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith("/tool-types/type-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ReadinessProbe {
|
||||
command: string;
|
||||
timeout: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
export interface ToolType {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -8,7 +14,11 @@ export interface ToolType {
|
||||
category: string;
|
||||
interfaces: string[];
|
||||
default_port: number | null;
|
||||
compose_template: string;
|
||||
definition_type: 'compose' | 'dockerfile';
|
||||
compose_template: string | null;
|
||||
dockerfile_template: string | null;
|
||||
build_context: Record<string, string> | null;
|
||||
readiness_probe: ReadinessProbe | null;
|
||||
required_variables: string[];
|
||||
is_builtin: boolean;
|
||||
created_by_id: string | null;
|
||||
@@ -23,7 +33,11 @@ export interface CreateToolTypeRequest {
|
||||
category?: string;
|
||||
interfaces?: string[];
|
||||
default_port: number;
|
||||
compose_template: string;
|
||||
definition_type?: 'compose' | 'dockerfile';
|
||||
compose_template?: string;
|
||||
dockerfile_template?: string;
|
||||
build_context?: Record<string, string>;
|
||||
readiness_probe?: ReadinessProbe;
|
||||
required_variables: string[];
|
||||
}
|
||||
|
||||
@@ -33,7 +47,11 @@ export interface UpdateToolTypeRequest {
|
||||
category?: string;
|
||||
interfaces?: string[];
|
||||
default_port?: number;
|
||||
definition_type?: 'compose' | 'dockerfile';
|
||||
compose_template?: string;
|
||||
dockerfile_template?: string;
|
||||
build_context?: Record<string, string>;
|
||||
readiness_probe?: ReadinessProbe;
|
||||
required_variables?: string[];
|
||||
}
|
||||
|
||||
@@ -60,3 +78,8 @@ export const updateToolType = async (id: string, data: UpdateToolTypeRequest): P
|
||||
export const deleteToolType = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/tool-types/${id}`);
|
||||
};
|
||||
|
||||
export const validateToolType = async (id: string): Promise<{ valid: boolean; errors?: string[] }> => {
|
||||
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(`/tool-types/${id}/validate`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -10,23 +10,20 @@ import { Icon } from "./icon";
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
||||
{ to: "/", label: "Dashboard", icon: "dashboard" },
|
||||
{ to: "/sessions", label: "Sessions", icon: "terminal" },
|
||||
{ to: "/", label: "Home", icon: "dashboard" },
|
||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
|
||||
{ to: "/tool-types", label: "Tool Types", icon: "code" },
|
||||
{ to: "/tool-configs", label: "Tool Configs", icon: "settings" },
|
||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||
];
|
||||
|
||||
const SessionItem = ({ session }: { session: Session }) => {
|
||||
const isRunning = session.status === "running";
|
||||
|
||||
|
||||
return (
|
||||
<a
|
||||
href={session.url || "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={session.url ?? `/projects/${session.project_id}`}
|
||||
target={session.url ? "_blank" : undefined}
|
||||
rel={session.url ? "noopener noreferrer" : undefined}
|
||||
className="nav-item session-item"
|
||||
title={`${session.display_name} (${session.status})`}
|
||||
>
|
||||
@@ -86,7 +83,7 @@ export const AppShell = () => {
|
||||
<div className="shell-body">
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isSessions = item.to === "/sessions";
|
||||
const isHome = item.to === "/";
|
||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||
return (
|
||||
<NavLink
|
||||
@@ -97,7 +94,7 @@ export const AppShell = () => {
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{isSessions && activeCount > 0 && (
|
||||
{isHome && activeCount > 0 && (
|
||||
<span className="nav-badge">{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
@@ -107,7 +104,7 @@ export const AppShell = () => {
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<div className="nav-divider" />
|
||||
<div className="nav-section-title">Sessions</div>
|
||||
<div className="nav-section-title">Live sessions</div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
|
||||
@@ -1,54 +1,81 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DashboardPage } from "./dashboard";
|
||||
import { HomePage } from "./dashboard";
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockDashboard = vi.fn();
|
||||
const mockSessions = vi.fn();
|
||||
const mockProjects = vi.fn();
|
||||
const mockRepos = vi.fn();
|
||||
|
||||
vi.mock("../api/dashboard", () => ({
|
||||
getDashboardSummary: (...args: unknown[]) => mockGet(...args)
|
||||
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
|
||||
}));
|
||||
|
||||
describe("DashboardPage", () => {
|
||||
vi.mock("../api/sessions", () => ({
|
||||
getUserSessions: (...args: unknown[]) => mockSessions(...args),
|
||||
createInstance: vi.fn(),
|
||||
startInstance: vi.fn(),
|
||||
stopInstance: vi.fn(),
|
||||
deleteInstance: vi.fn(),
|
||||
recreateInstanceTunnel: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../api/projects", () => ({
|
||||
listProjects: (...args: unknown[]) => mockProjects(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/git_repositories", () => ({
|
||||
listRepositories: (...args: unknown[]) => mockRepos(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/tool_types", () => ({
|
||||
listToolTypes: vi.fn().mockResolvedValue([])
|
||||
}));
|
||||
|
||||
describe("HomePage", () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockDashboard.mockReset();
|
||||
mockSessions.mockReset();
|
||||
mockProjects.mockReset();
|
||||
mockRepos.mockReset();
|
||||
});
|
||||
|
||||
it("shows loading then empty state when summary has no data", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
projects: 0,
|
||||
repositories: 0,
|
||||
sshKeys: 0,
|
||||
recentActivity: []
|
||||
});
|
||||
it("shows overview sections", async () => {
|
||||
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
|
||||
mockSessions.mockResolvedValue([]);
|
||||
mockProjects.mockResolvedValue([]);
|
||||
mockRepos.mockResolvedValue([]);
|
||||
|
||||
render(<DashboardPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No activity yet")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Available projects")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows retry action when summary request fails", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("failed"));
|
||||
mockGet.mockResolvedValueOnce({
|
||||
projects: 2,
|
||||
repositories: 5,
|
||||
sshKeys: 1,
|
||||
recentActivity: ["Created repo"]
|
||||
});
|
||||
it("shows retry action when home load fails", async () => {
|
||||
mockDashboard.mockRejectedValueOnce(new Error("failed"));
|
||||
mockSessions.mockRejectedValueOnce(new Error("failed"));
|
||||
mockProjects.mockRejectedValueOnce(new Error("failed"));
|
||||
|
||||
render(<DashboardPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Dashboard is unavailable")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,83 +1,338 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Project } from "../types";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
const CARDS = [
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
{ label: "SSH Keys", key: "sshKeys" }
|
||||
] as const;
|
||||
|
||||
type DashboardStatus = "loading" | "ready" | "error";
|
||||
type SessionView = SessionApi;
|
||||
|
||||
export const DashboardPage = () => {
|
||||
const [status, setStatus] = useState<DashboardStatus>("loading");
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState("");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
const loadHome = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await getDashboardSummary();
|
||||
setSummary(data);
|
||||
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setProjects(projectData);
|
||||
setToolTypes(toolTypeData);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setSummary(null);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSummary();
|
||||
}, [loadSummary]);
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
const cards = useMemo(() => CARDS, []);
|
||||
const isEmpty =
|
||||
status === "ready" &&
|
||||
summary !== null &&
|
||||
summary.projects === 0 &&
|
||||
summary.repositories === 0 &&
|
||||
summary.sshKeys === 0 &&
|
||||
summary.recentActivity.length === 0;
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
|
||||
[safeSessions]
|
||||
);
|
||||
|
||||
const recentSessions = useMemo(
|
||||
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
|
||||
[safeSessions]
|
||||
);
|
||||
|
||||
const handleCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||
|
||||
setSaveState("saving");
|
||||
try {
|
||||
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
|
||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setDisplayName("");
|
||||
setSelectedProject("");
|
||||
setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setSaveState("idle");
|
||||
await loadHome();
|
||||
} catch {
|
||||
setSaveState("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = (session: SessionView) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
return;
|
||||
}
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
};
|
||||
|
||||
const handleStop = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Dashboard</h1>
|
||||
<p className="muted">Your workspace overview will appear here.</p>
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
<div className="stack-sm">
|
||||
<p className="eyebrow">Workspace overview</p>
|
||||
<h1>Home</h1>
|
||||
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||
</div>
|
||||
<div className="home-hero-actions">
|
||||
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading dashboard...</p>}
|
||||
{status === "loading" && <p className="muted">Loading overview...</p>}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Dashboard is unavailable</p>
|
||||
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
|
||||
<p>Unable to load your workspace overview.</p>
|
||||
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-grid">
|
||||
{cards.map((card) => (
|
||||
<article className="card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">{summary ? String(summary[card.key]) : "-"}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="card home-summary-card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">
|
||||
{card.key === "openSessions"
|
||||
? activeSessions.length
|
||||
: card.key === "projects"
|
||||
? summary.projects
|
||||
: summary.repositories}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isEmpty && <p className="muted">No activity yet</p>}
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Open sessions</p>
|
||||
<h2>{activeSessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{activeSessions.length === 0 ? (
|
||||
<p className="muted">No active sessions right now.</p>
|
||||
) : (
|
||||
<div className="home-session-grid">
|
||||
{activeSessions.map((session) => (
|
||||
<article className="card session-card" key={session.id}>
|
||||
<div className="stack-sm">
|
||||
<div className="row row-tight">
|
||||
<h3>{session.display_name}</h3>
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
</div>
|
||||
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
||||
<p className="muted">{session.tool_type_name}</p>
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Tunnel
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="quick-actions">
|
||||
<button className="primary-button" type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
New Project
|
||||
</button>
|
||||
<button className="secondary-button" type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
Add Repository
|
||||
</button>
|
||||
</div>
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Available projects</p>
|
||||
<h2>{projects.length}</h2>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<p className="muted">No projects yet.</p>
|
||||
) : (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
<article className="card project-card home-project-card" key={project.id}>
|
||||
<div className="stack-sm">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
|
||||
Open Workspace
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Quick create</p>
|
||||
<h2>Start a session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form className="stack create-session-form" onSubmit={handleCreate}>
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
|
||||
<option value="">Select repository...</option>
|
||||
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Tool type
|
||||
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Display name
|
||||
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
|
||||
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
|
||||
</button>
|
||||
{saveState === "error" && <span className="error-text">Failed to create session</span>}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>{recentSessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-sessions-list">
|
||||
{recentSessions.map((session) => (
|
||||
<article className="recent-session-item" key={session.id}>
|
||||
<div className="recent-session-info">
|
||||
<span className="recent-session-name">{session.display_name}</span>
|
||||
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
|
||||
</div>
|
||||
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { HomePage as DashboardPage };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProjectsPage } from "./projects";
|
||||
@@ -29,13 +30,21 @@ afterEach(() => {
|
||||
describe("ProjectsPage", () => {
|
||||
it("renders loading state initially", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders project list after loading", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
@@ -46,7 +55,11 @@ describe("ProjectsPage", () => {
|
||||
|
||||
it("renders empty state when no projects", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -55,7 +68,11 @@ describe("ProjectsPage", () => {
|
||||
|
||||
it("renders error state with retry button", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
||||
@@ -67,7 +84,11 @@ describe("ProjectsPage", () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -96,7 +117,11 @@ describe("ProjectsPage", () => {
|
||||
it("shows validation error when name is empty", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -112,7 +137,11 @@ describe("ProjectsPage", () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
@@ -141,7 +170,11 @@ describe("ProjectsPage", () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
|
||||
@@ -40,8 +40,18 @@ export const SessionsPage = () => {
|
||||
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, { healthy: boolean; status_code: number | null; error?: string }>>({});
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, {
|
||||
healthy: boolean;
|
||||
container_status: string;
|
||||
container_health: string | null;
|
||||
tunnel_status: string;
|
||||
tunnel_status_code: number | null;
|
||||
probe_status: string;
|
||||
last_probe_output: string | null;
|
||||
error: string | null;
|
||||
}>>({});
|
||||
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
||||
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
@@ -86,13 +96,13 @@ export const SessionsPage = () => {
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
// Poll health every 30 seconds for active instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const runningSessions = sessions.filter(
|
||||
(s) => s.status === "running" && s.url
|
||||
const activeSessions = sessions.filter(
|
||||
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
|
||||
);
|
||||
for (const session of runningSessions) {
|
||||
for (const session of activeSessions) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(
|
||||
session.project_id,
|
||||
@@ -106,7 +116,16 @@ export const SessionsPage = () => {
|
||||
} catch {
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: { healthy: false, status_code: null, error: "check failed" },
|
||||
[session.id]: {
|
||||
healthy: false,
|
||||
container_status: "unknown",
|
||||
container_health: null,
|
||||
tunnel_status: "unreachable",
|
||||
tunnel_status_code: null,
|
||||
probe_status: "unknown",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -135,7 +154,7 @@ export const SessionsPage = () => {
|
||||
}, [selectedProject]);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() => sessions.filter((s) => ["running", "building", "pending"].includes(s.status)),
|
||||
() => sessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)),
|
||||
[sessions]
|
||||
);
|
||||
|
||||
@@ -328,9 +347,35 @@ export const SessionsPage = () => {
|
||||
</p>
|
||||
)}
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
|
||||
{session.status === "starting" && (
|
||||
<span className="status-badge starting">starting...</span>
|
||||
)}
|
||||
{session.status === "probing" && (
|
||||
<span className="status-badge probing">checking...</span>
|
||||
)}
|
||||
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
||||
<span className="status-badge error">tunnel error</span>
|
||||
)}
|
||||
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
|
||||
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
|
||||
)}
|
||||
{tunnelHealth[session.id]?.last_probe_output && (
|
||||
<div className="probe-output-section">
|
||||
<button
|
||||
className="probe-toggle"
|
||||
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="info" size="sm" />
|
||||
{expandedProbeId === session.id ? "Hide probe output" : "Show probe output"}
|
||||
</button>
|
||||
{expandedProbeId === session.id && (
|
||||
<pre className="probe-output">
|
||||
{tunnelHealth[session.id].last_probe_output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
{session.url ? (
|
||||
@@ -353,7 +398,7 @@ export const SessionsPage = () => {
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
|
||||
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => void handleRecreateTunnel(session)}
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||
|
||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
type SettingsStatus = "loading" | "ready" | "error";
|
||||
|
||||
const TABS = [
|
||||
{ label: "General", path: "general" },
|
||||
{ label: "SSH Keys", path: "ssh-keys" },
|
||||
{ label: "Tool Types", path: "tool-types" },
|
||||
{ label: "Tool Configs", path: "tool-configs" },
|
||||
] as const;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
];
|
||||
|
||||
type SettingsOutletContext = {
|
||||
config: UserConfig;
|
||||
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
|
||||
handleSave: () => Promise<void>;
|
||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||
};
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
||||
const [config, setConfig] = useState<UserConfig>({
|
||||
theme: "system",
|
||||
@@ -50,21 +66,15 @@ export const SettingsPage = () => {
|
||||
git_user_name: config.git_user_name,
|
||||
git_user_email: config.git_user_email,
|
||||
};
|
||||
console.log("Sending update:", update);
|
||||
const updated = await updateUserConfig(update);
|
||||
console.log("Received response:", updated);
|
||||
setConfig(updated);
|
||||
setSaveStatus("saved");
|
||||
|
||||
// Apply theme immediately
|
||||
const theme = updated.theme ?? "system";
|
||||
if (theme === "system") {
|
||||
if (updated.theme === "system") {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", updated.theme);
|
||||
}
|
||||
|
||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
window.setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
@@ -86,81 +96,71 @@ export const SettingsPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const parts = location.pathname.split("/").filter(Boolean);
|
||||
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<section className="stack settings-page">
|
||||
<header className="settings-header card stack-sm">
|
||||
<div>
|
||||
<p className="eyebrow">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||
</header>
|
||||
|
||||
<div className="card stack">
|
||||
<h2>Appearance</h2>
|
||||
<label className="form-field">
|
||||
Theme
|
||||
<select
|
||||
value={config.theme}
|
||||
onChange={(e) => handleChange("theme", e.target.value)}
|
||||
<nav className="settings-tabs" aria-label="Settings sections">
|
||||
{TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.path}
|
||||
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
|
||||
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
|
||||
>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="card stack">
|
||||
<h2>Git Identity</h2>
|
||||
<label className="form-field">
|
||||
User Name
|
||||
<input
|
||||
type="text"
|
||||
value={config.git_user_name ?? ""}
|
||||
onChange={(e) => handleChange("git_user_name", e.target.value || null)}
|
||||
placeholder="Your git commit name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
User Email
|
||||
<input
|
||||
type="email"
|
||||
value={config.git_user_email ?? ""}
|
||||
onChange={(e) => handleChange("git_user_email", e.target.value || null)}
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card stack">
|
||||
<h2>Editor</h2>
|
||||
<label className="form-field">
|
||||
Default Editor
|
||||
<input
|
||||
type="text"
|
||||
value={config.default_editor ?? ""}
|
||||
onChange={(e) => handleChange("default_editor", e.target.value || null)}
|
||||
placeholder="e.g., vscode, vim, cursor"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="settings-actions">
|
||||
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
||||
{saveStatus === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
||||
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
||||
<div className="settings-panel card">
|
||||
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const GeneralSettingsTab = () => {
|
||||
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<h2>General</h2>
|
||||
<label className="form-field">
|
||||
Theme
|
||||
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user name
|
||||
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user email
|
||||
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Default editor
|
||||
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
|
||||
</label>
|
||||
<div className="settings-actions">
|
||||
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
||||
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
|
||||
</button>
|
||||
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
||||
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
export const SSHKeysPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -61,7 +63,15 @@ export const SSHKeysPage = () => {
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>SSH Keys</h1>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>SSH Keys</h1>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||
Back to settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icon } from "../components/icon";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
type ConfigStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const ToolConfigsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ConfigStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||
@@ -135,7 +137,13 @@ export const ToolConfigsPage = () => {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Tool Configurations</h1>
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||
Back to settings
|
||||
</button>
|
||||
<p className="muted">
|
||||
Manage environment variables and configuration files for your tools
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
createToolType,
|
||||
@@ -15,6 +16,7 @@ type ToolTypesStatus = "loading" | "ready" | "error";
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ToolTypesPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
@@ -67,7 +69,7 @@ export const ToolTypesPage = () => {
|
||||
setFormCategory(toolType.category ?? "");
|
||||
setFormInterfaces(toolType.interfaces ?? []);
|
||||
setFormPort(toolType.default_port?.toString() ?? "");
|
||||
setFormTemplate(toolType.compose_template);
|
||||
setFormTemplate(toolType.compose_template ?? "");
|
||||
setFormVariables(toolType.required_variables.join(", "));
|
||||
setFormError(null);
|
||||
setEditingToolType(toolType);
|
||||
@@ -165,12 +167,18 @@ export const ToolTypesPage = () => {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h1>Tool Types</h1>
|
||||
<button onClick={openCreate}>
|
||||
<div className="page-header" style={{ marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Types</h1>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
|
||||
<button onClick={openCreate}>
|
||||
<Icon name="add" size="sm" />
|
||||
Create Tool Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolTypes.length === 0 ? (
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
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",
|
||||
interfaces: ["web"],
|
||||
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",
|
||||
interfaces: ["terminal"],
|
||||
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.getByRole("button", { name: /configs/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("advanced-config")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to folders tab", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("project-configs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens tool type creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates tool type with compose definition", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
target: { value: "new-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||
target: { value: "New Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||
target: { value: "8080" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/compose template/i), {
|
||||
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "new-tool",
|
||||
display_name: "New Tool",
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("creates tool type with dockerfile definition", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
target: { value: "docker-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||
target: { value: "Docker Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||
target: { value: "3000" },
|
||||
});
|
||||
|
||||
// Switch to dockerfile
|
||||
fireEvent.change(screen.getByLabelText("Definition Type"), {
|
||||
target: { value: "dockerfile" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
|
||||
target: { value: "FROM python:3.11\\nRUN pip install flask" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "docker-tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows readiness probe fields", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/interval/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens config creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).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.getByLabelText(/key/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/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.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.getByLabelText(/key/i), {
|
||||
target: { value: "MY_CONFIG" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/value/i), {
|
||||
target: { value: "my-value" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/port override/i), {
|
||||
target: { value: "9090" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/start command/i), {
|
||||
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.getByRole("button", { name: /folders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||
|
||||
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Mount Path *")).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.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.getByLabelText("Name *"), {
|
||||
target: { value: "new-folder" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Mount Path *"), {
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+18
-10
@@ -2,18 +2,18 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "./components/app-shell";
|
||||
import { ProtectedRoute } from "./components/protected-route";
|
||||
import { DashboardPage } from "./pages/dashboard";
|
||||
import { HomePage } from "./pages/dashboard";
|
||||
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
|
||||
import { SessionsPage } from "./pages/sessions";
|
||||
import { ProfilePage } from "./pages/profile";
|
||||
import { ProjectsPage } from "./pages/projects";
|
||||
import { GitRepositoriesPage } from "./pages/git-repositories";
|
||||
import { GitHistoryPage } from "./pages/git-history";
|
||||
import { ProjectSettingsPage } from "./pages/project-settings";
|
||||
import { RepoWorkspace } from "./pages/repo-workspace";
|
||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||
import { SettingsPage } from "./pages/settings";
|
||||
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
||||
import { TerminalPage } from "./pages/terminal";
|
||||
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||
import { ToolConfigsPage } from "./pages/tool-configs";
|
||||
import { ToolTypesPage } from "./pages/tool-types";
|
||||
|
||||
@@ -21,6 +21,10 @@ export const AppRouter = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginRedirectPage />} />
|
||||
<Route path="/sessions" element={<Navigate to="/" replace />} />
|
||||
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
|
||||
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
|
||||
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
@@ -29,18 +33,22 @@ export const AppRouter = () => {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
||||
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="tool-types" element={<ToolTypesPage />} />
|
||||
<Route path="tool-configs" element={<ToolConfigsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />}>
|
||||
<Route index element={<Navigate to="general" replace />} />
|
||||
<Route path="general" element={<GeneralSettingsTab />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
<Route path="tool-types" element={<ToolTypesPage />} />
|
||||
<Route path="tool-configs" element={<ToolConfigsPage />} />
|
||||
<Route path="*" element={<Navigate to="general" replace />} />
|
||||
</Route>
|
||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||
</Route>
|
||||
<Route path="/404" element={<NotFoundPage />} />
|
||||
|
||||
+148
-10
@@ -1,6 +1,6 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
--bg: #f4f1ea;
|
||||
--panel: #fffef9;
|
||||
--ink: #1d1d1b;
|
||||
@@ -8,6 +8,17 @@
|
||||
--brand: #275d4b;
|
||||
--brand-strong: #154236;
|
||||
--border: #d8d0c5;
|
||||
--primary: #275d4b;
|
||||
--primary-fg: #fffef9;
|
||||
--color-primary: #275d4b;
|
||||
--success: #2f8f62;
|
||||
--success-light: rgba(47, 143, 98, 0.14);
|
||||
--warning: #c08a1e;
|
||||
--warning-light: rgba(192, 138, 30, 0.14);
|
||||
--danger: #b94a3c;
|
||||
--danger-light: rgba(185, 74, 60, 0.14);
|
||||
--info: #4f7fb8;
|
||||
--info-light: rgba(79, 127, 184, 0.14);
|
||||
|
||||
/* Spacing Scale (4px base) */
|
||||
--space-1: 0.25rem;
|
||||
@@ -36,13 +47,16 @@
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #1a1a18;
|
||||
--panel: #252522;
|
||||
--ink: #e8e6e1;
|
||||
--muted: #a39e96;
|
||||
--brand: #4a9e7f;
|
||||
--brand-strong: #3d8a6e;
|
||||
--border: #3d3d38;
|
||||
--bg: #171613;
|
||||
--panel: #22201d;
|
||||
--ink: #ece7df;
|
||||
--muted: #a59d92;
|
||||
--brand: #5fa889;
|
||||
--brand-strong: #4d9175;
|
||||
--border: #39342d;
|
||||
--primary: #5fa889;
|
||||
--primary-fg: #171613;
|
||||
--color-primary: #5fa889;
|
||||
--success: #22c55e;
|
||||
--success-light: rgba(34, 197, 94, 0.15);
|
||||
--warning: #f59e0b;
|
||||
@@ -84,12 +98,12 @@ a {
|
||||
align-items: center;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .shell-header {
|
||||
background: rgba(37, 37, 34, 0.85);
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -115,6 +129,7 @@ a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@@ -133,11 +148,134 @@ a {
|
||||
color: #f7fff7;
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.shell-content {
|
||||
padding: 1.25rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.home-page,
|
||||
.settings-page {
|
||||
max-width: 1240px;
|
||||
}
|
||||
|
||||
.home-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.home-hero-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.home-summary-grid,
|
||||
.home-project-grid,
|
||||
.home-session-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.home-summary-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.home-project-grid,
|
||||
.home-session-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.home-section h2,
|
||||
.settings-header h1,
|
||||
.settings-panel h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.home-section h3,
|
||||
.home-section p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-tight {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-actions,
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 0.42rem 0.7rem;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.session-card,
|
||||
.project-card,
|
||||
.recent-session-item {
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Responsive Shell */
|
||||
@media (max-width: 767px) {
|
||||
.shell-body {
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"isolatedModules": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
## Context
|
||||
|
||||
The tool system currently supports:
|
||||
- ToolTypes with compose templates and basic metadata
|
||||
- ToolConfigs as simple key-value pairs (env vars or files)
|
||||
- Instance creation via compose rendering
|
||||
- Basic flat-list UI at `/tool-configs`
|
||||
|
||||
Users need a much richer system for defining, configuring, and running development tools.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Support both Docker Compose and Dockerfile for tool definitions
|
||||
- Add readiness probes with configurable commands and timeouts
|
||||
- Create reusable config file collections ("folders") mountable as volumes
|
||||
- Add rich tool config fields (port, start_command, working_directory, volumes, env vars)
|
||||
- Build a unified "Tool Workshop" UI for all tool management
|
||||
- Support per-project overrides on config folders
|
||||
- Maintain backward compatibility with existing built-in tool types
|
||||
|
||||
**Non-Goals:**
|
||||
- Docker image registry management (assume local builds or public images)
|
||||
- Real-time collaborative tool editing
|
||||
- Tool marketplace/sharing between users
|
||||
- Advanced orchestration (Kubernetes, Swarm)
|
||||
- Config folder versioning/Git integration
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ TOOL WORKSHOP │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ ToolType │────▶│ ToolConfig │◀────│ ConfigFolder │ │
|
||||
│ │ (Blueprint) │ │ (Settings) │ │ (Files) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ ToolInstance │ │
|
||||
│ │ (Runtime + Volumes) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### ToolType Model
|
||||
|
||||
```python
|
||||
class ToolType:
|
||||
# Existing fields
|
||||
name: str # unique identifier
|
||||
display_name: str
|
||||
description: str | None
|
||||
category: str
|
||||
interfaces: list[str] # ["web", "terminal"]
|
||||
default_port: int
|
||||
required_variables: list[str]
|
||||
is_builtin: bool
|
||||
|
||||
# New fields
|
||||
definition_type: str # "compose" | "dockerfile"
|
||||
compose_template: str | None # YAML template (if definition_type == "compose")
|
||||
dockerfile_template: str | None # Dockerfile content (if definition_type == "dockerfile")
|
||||
build_context: dict | None # {"files": {"path": "content"}} for dockerfile builds
|
||||
readiness_probe: dict | None # {"command": "...", "timeout": 30, "interval": 2}
|
||||
```
|
||||
|
||||
**Decision**: Store both compose and dockerfile, use `definition_type` to determine which to use. This allows easy switching and migration.
|
||||
|
||||
### ToolConfig Model
|
||||
|
||||
```python
|
||||
class ToolConfig:
|
||||
# Existing fields
|
||||
user_id: UUID
|
||||
tool_type_id: UUID
|
||||
project_id: UUID | None # null = global config
|
||||
key: str
|
||||
value: str
|
||||
config_type: str # "env" | "file"
|
||||
file_path: str | None
|
||||
|
||||
# New fields
|
||||
port_override: int | None # Override tool type default port
|
||||
start_command: str | None # Override container start command
|
||||
working_directory: str | None # Working directory inside container
|
||||
environment_variables: dict | None # JSON {"KEY": "value", ...}
|
||||
volumes: list[dict] | None # JSON [{"source": "...", "target": "...", "type": "..."}]
|
||||
```
|
||||
|
||||
**Decision**: Store env vars and volumes as JSONB for flexibility. Port as integer with validation.
|
||||
|
||||
### ConfigFolder Model (NEW)
|
||||
|
||||
```python
|
||||
class ConfigFolder:
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
name: str # e.g., "my-dotfiles", "vscode-settings"
|
||||
description: str | None
|
||||
mount_path: str # Default mount path in container (e.g., "/home/user/.config")
|
||||
files: dict # JSON {"relative/path": "content", ...}
|
||||
project_overrides: dict | None # JSON {project_id: {"mount_path": "...", "files": {...}}}
|
||||
is_active: bool # Quick toggle
|
||||
created_at, updated_at
|
||||
```
|
||||
|
||||
**Decision**: Files stored as JSONB with relative paths as keys. This is simple and sufficient for config files (not binary assets).
|
||||
|
||||
### Volume Mount Resolution
|
||||
|
||||
When creating an instance, volumes are resolved in this priority order:
|
||||
|
||||
```
|
||||
1. ToolConfig.volumes (explicit per-config mounts)
|
||||
2. ConfigFolder mounts (user's active config folders)
|
||||
3. ToolType default volumes (from compose/dockerfile)
|
||||
```
|
||||
|
||||
Config folder files are written to the instance directory under `volumes/<folder_name>/` and mounted from there.
|
||||
|
||||
### Readiness Probe System
|
||||
|
||||
```python
|
||||
class ReadinessProbe:
|
||||
command: str # e.g., "curl -f http://localhost:8080/health"
|
||||
timeout: int # seconds (default: 30)
|
||||
interval: int # seconds between checks (default: 2)
|
||||
retries: int # max attempts (default: timeout/interval)
|
||||
```
|
||||
|
||||
**Execution Flow**:
|
||||
1. Start container
|
||||
2. Wait for container to be running
|
||||
3. Execute probe command inside container via `docker exec`
|
||||
4. If success → mark instance as "running"
|
||||
5. If timeout → mark instance as "failed" with probe output in logs
|
||||
|
||||
**Decision**: Probes run inside the container using `docker exec`. This works for both network-based probes (curl) and command-based probes (binary version checks).
|
||||
|
||||
### Instance Creation Flow
|
||||
|
||||
```
|
||||
1. Generate instance ID and directory
|
||||
2. Resolve ToolConfig (global + project-specific)
|
||||
3. Write config files:
|
||||
a. .env file (from env-type ToolConfigs)
|
||||
b. Config files (from file-type ToolConfigs)
|
||||
c. Config folder files (to volumes/<folder>/)
|
||||
4. IF ToolType.definition_type == "dockerfile":
|
||||
a. Write Dockerfile + build context files
|
||||
b. Build image: docker build -t <instance_tag> .
|
||||
c. Generate compose from template using built image
|
||||
5. IF ToolType.definition_type == "compose":
|
||||
a. Render compose template with variables
|
||||
6. Write docker-compose.yml
|
||||
7. docker compose up -d
|
||||
8. Connect to backend network
|
||||
9. IF readiness_probe defined:
|
||||
a. Execute probe with timeout
|
||||
b. Update status based on result
|
||||
10. IF web interface:
|
||||
a. Create Cloudflare tunnel
|
||||
b. Update URL
|
||||
```
|
||||
|
||||
## UI Design
|
||||
|
||||
### Tool Workshop Page (`/tool-workshop`)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Tool Workshop [+ New] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────────────────────┐ │
|
||||
│ │ │ │ │ │
|
||||
│ │ MY TOOLS │ │ [Tool Type Builder] │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ▼ Code Editor│ │ Name: [____________] │ │
|
||||
│ │ □ VS Code │ │ Type: (•) Compose ( ) Dockerfile │ │
|
||||
│ │ □ Cursor │ │ │ │
|
||||
│ │ │ │ [Compose Template / Dockerfile] │ │
|
||||
│ │ ▼ AI Tools │ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ □ OpenCode │ │ │ version: '3.8' │ │ │
|
||||
│ │ □ Continue │ │ │ services: │ │ │
|
||||
│ │ │ │ │ app: │ │ │
|
||||
│ │ CONFIGS │ │ │ image: ... │ │ │
|
||||
│ │ │ │ │ ports: │ │ │
|
||||
│ │ ▼ Global │ │ │ - "{{PORT}}:8080" │ │ │
|
||||
│ │ □ dotfiles │ │ │ volumes: │ │ │
|
||||
│ │ □ api-keys │ │ │ - ... │ │ │
|
||||
│ │ │ │ └────────────────────────────────┘ │ │
|
||||
│ │ ▼ Project X │ │ │ │
|
||||
│ │ □ overrides│ │ Readiness Probe: │ │
|
||||
│ │ │ │ Command: [curl -f localhost:8080] │ │
|
||||
│ │ │ │ Timeout: [30] seconds │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ [Save Tool Type] │ │
|
||||
│ │ │ │ │ │
|
||||
│ └──────────────┘ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Tabs: [Tool Types] [Configs] [Config Folders] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Navigation Structure**:
|
||||
- Left sidebar: Hierarchical tree
|
||||
- Tool Types (expandable, shows instances count)
|
||||
- Config Folders (grouped by global/project)
|
||||
- Right panel: Context-aware editor based on selection
|
||||
- Tab bar: Switch between Tool Types / Configs / Config Folders views
|
||||
|
||||
### Config Editor
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Edit Config: OpenCode API Keys │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Basic Settings │ Advanced Settings │
|
||||
│ ────────────────────────────┼──────────────────────────────── │
|
||||
│ Key: [OPENAI_API_KEY] │ Port Override: [_____] │
|
||||
│ Value: [sk-... ] │ Start Command: [_____] │
|
||||
│ Type: (•) Env ( ) File │ Working Dir: [/workspace] │
|
||||
│ File Path: [__________] │ │
|
||||
│ │ Environment Variables: │
|
||||
│ │ ┌──────────────────────────┐ │
|
||||
│ │ │ KEY │ VALUE │ │
|
||||
│ │ │ OPENAI_KEY │ sk-... │ │
|
||||
│ │ │ MODEL │ gpt-4 │ │
|
||||
│ │ └──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ Volume Mounts: │
|
||||
│ │ ┌──────────────────────────┐ │
|
||||
│ │ │ SOURCE │ TARGET │ │
|
||||
│ │ │ dotfiles │ ~/.config │ │
|
||||
│ │ │ vscode-set │ ~/.vscode │ │
|
||||
│ │ └──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ [Delete] [Cancel] [Save] │ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Config Folder Manager
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Config Folder: my-dotfiles │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Name: [my-dotfiles] │
|
||||
│ Description: [My personal dotfiles] │
|
||||
│ Default Mount Path: [/home/user] │
|
||||
│ │
|
||||
│ Files: │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Path │ Size │ Actions │ │
|
||||
│ │ .zshrc │ 2.1KB │ [Edit] [Delete] │ │
|
||||
│ │ .gitconfig │ 412B │ [Edit] [Delete] │ │
|
||||
│ │ .config/starship.toml │ 1.8KB │ [Edit] [Delete] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [+ Add File] │
|
||||
│ │
|
||||
│ Project Overrides: │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Project │ Mount Path │ Files Override │ │
|
||||
│ │ Project Alpha │ /home/dev │ [3 files] │ │
|
||||
│ │ Project Beta │ /workspace │ [1 file] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Add Override] [Delete Folder] [Save] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Dockerfile vs Compose**: Support both. `definition_type` field determines which path to use. Compose is the default for backward compatibility.
|
||||
|
||||
2. **Config Folder Storage**: Store files as JSONB keyed by relative path. This avoids file system complexity and works well for text-based config files. Limit: 10MB per folder.
|
||||
|
||||
3. **Readiness Probe Execution**: Use `docker exec` to run commands inside the container. This is the most flexible approach (works for HTTP checks, binary checks, file checks).
|
||||
|
||||
4. **Volume Resolution Order**: Config-level volumes override config-folder volumes, which override tool-type defaults. Last-write-wins for conflicts.
|
||||
|
||||
5. **UI Organization**: Single page with three tabs (Tool Types, Configs, Config Folders) and a left sidebar for navigation. This consolidates the current `/tool-configs` and `/tool-types` pages.
|
||||
|
||||
6. **Project Overrides**: ConfigFolders support per-project overrides for mount_path and files. This allows project-specific customizations while keeping the base collection reusable.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Dockerfile build times**: Building images on-demand is slow. Mitigation: Document that users should use pre-built images in compose for faster startup; dockerfile is for custom tools.
|
||||
- **Config folder size limits**: JSONB has practical limits. Mitigation: 10MB limit per folder, enforced in API.
|
||||
- **Readiness probe complexity**: Commands might hang or fail in unexpected ways. Mitigation: Strict timeout, clear error messages, probe logs stored on instance.
|
||||
- **Migration complexity**: Existing tool types need `definition_type` set to "compose". Mitigation: Database default, seed function update.
|
||||
- **UI complexity**: Three tabs with different editors could feel overwhelming. Mitigation: Progressive disclosure (hide advanced fields, collapsible sections).
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Tool Types
|
||||
- `GET /tool-types` - List all (existing)
|
||||
- `POST /tool-types` - Create with new fields
|
||||
- `PUT /tool-types/{id}` - Update with new fields
|
||||
- `GET /tool-types/{id}/validate` - Validate compose/dockerfile syntax
|
||||
|
||||
### Tool Configs
|
||||
- `GET /tool-configs` - List with new fields
|
||||
- `POST /tool-configs` - Create with new fields
|
||||
- `PUT /tool-configs/{id}` - Update with new fields
|
||||
- `GET /tool-configs/defaults/{tool_type_id}` - Get suggested defaults
|
||||
|
||||
### Config Folders (NEW)
|
||||
- `GET /config-folders` - List user's folders
|
||||
- `POST /config-folders` - Create folder
|
||||
- `PUT /config-folders/{id}` - Update folder (files, mount_path)
|
||||
- `DELETE /config-folders/{id}` - Delete folder
|
||||
- `POST /config-folders/{id}/overrides` - Add project override
|
||||
- `PUT /config-folders/{id}/overrides/{project_id}` - Update override
|
||||
- `DELETE /config-folders/{id}/overrides/{project_id}` - Remove override
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Migration: tool_types
|
||||
```sql
|
||||
ALTER TABLE tool_types
|
||||
ADD COLUMN definition_type VARCHAR(20) NOT NULL DEFAULT 'compose',
|
||||
ADD COLUMN dockerfile_template TEXT,
|
||||
ADD COLUMN build_context JSONB DEFAULT '{}',
|
||||
ADD COLUMN readiness_probe JSONB;
|
||||
|
||||
-- Ensure consistency
|
||||
ALTER TABLE tool_types
|
||||
ADD CONSTRAINT chk_definition_type
|
||||
CHECK (definition_type IN ('compose', 'dockerfile'));
|
||||
```
|
||||
|
||||
### Migration: tool_configs
|
||||
```sql
|
||||
ALTER TABLE tool_configs
|
||||
ADD COLUMN port_override INTEGER,
|
||||
ADD COLUMN start_command TEXT,
|
||||
ADD COLUMN working_directory TEXT,
|
||||
ADD COLUMN environment_variables JSONB DEFAULT '{}',
|
||||
ADD COLUMN volumes JSONB DEFAULT '[]';
|
||||
|
||||
ALTER TABLE tool_configs
|
||||
ADD CONSTRAINT chk_port_range
|
||||
CHECK (port_override IS NULL OR (port_override >= 1 AND port_override <= 65535));
|
||||
```
|
||||
|
||||
### New Table: config_folders
|
||||
```sql
|
||||
CREATE TABLE config_folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
mount_path VARCHAR(1024) NOT NULL,
|
||||
files JSONB NOT NULL DEFAULT '{}',
|
||||
project_overrides JSONB DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_config_folders_user ON config_folders(user_id);
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
## Why
|
||||
|
||||
The current tool system is too rigid. Tool types are hardcoded with compose templates, configs are simple key-value pairs, and the UI is a basic flat list. Users need a true "tool workshop" where they can:
|
||||
|
||||
1. **Define new tools** with either Docker Compose or Dockerfile
|
||||
2. **Configure rich tool settings** including ports, commands, working directories, and volume mounts
|
||||
3. **Create reusable config file collections** (e.g., dotfiles, IDE settings) that mount into containers
|
||||
4. **Wait for tools to be ready** with configurable health/readiness probes before considering the build complete
|
||||
|
||||
This unlocks the platform from built-in tools to a true marketplace of user-defined and user-configured tools.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Enhance ToolType model**: Add `dockerfile_template`, `readiness_probe` (command + timeout), `build_context` field
|
||||
- **Enhance ToolConfig model**: Add `port_override`, `start_command`, `working_directory`, `environment_variables`, `volumes`
|
||||
- **Create ConfigFolder model**: Named collections of files mountable as volumes, with per-project overrides
|
||||
- **Add readiness probe system**: Instance creation waits for probe command with configurable timeout
|
||||
- **Unified Tool Workshop UI**: Single page replacing `/tool-configs` and `/tool-types` with:
|
||||
- Tool Type builder (compose or dockerfile)
|
||||
- Tool Config editor (split-pane with all new fields)
|
||||
- Config Folder manager (file collections with mount paths)
|
||||
- Live validation and preview
|
||||
- **Update instance creation flow**: Support dockerfile builds, mount config folders, apply readiness probes
|
||||
- **Database migrations**: New columns on `tool_types`, `tool_configs`; new `config_folders` table
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `tool-workshop`: Unified tool definition, configuration, and deployment interface
|
||||
- `config-folders`: Reusable per-user file collections mountable into containers with per-project overrides
|
||||
- `readiness-probes`: Build-time health checks that wait for tools to be ready before marking instances as running
|
||||
|
||||
### Modified Capabilities
|
||||
- `tool-types`: Enhanced with dockerfile support, readiness probes, build context
|
||||
- `tool-config-management`: Extended with port overrides, volumes, environment variables, working directory
|
||||
- `tool-instances`: Instance creation supports dockerfile builds, config folder mounts, probe waiting
|
||||
|
||||
## Impact
|
||||
|
||||
- **Backend**:
|
||||
- Models: `ToolType`, `ToolConfig`, new `ConfigFolder`
|
||||
- API: New endpoints for config folders, updated tool type/config endpoints
|
||||
- Services: Docker build service (for dockerfiles), readiness probe service
|
||||
- Instance creation: Dockerfile build path, volume mounting, probe execution
|
||||
- **Frontend**:
|
||||
- New `ToolWorkshopPage` component (replaces `/tool-configs` and `/tool-types`)
|
||||
- New components: Dockerfile editor, readiness probe config, config folder manager, volume mount editor
|
||||
- Updated routing and navigation
|
||||
- **Database**:
|
||||
- `tool_types`: Add `dockerfile_template`, `readiness_probe`, `build_context`
|
||||
- `tool_configs`: Add `port_override`, `start_command`, `working_directory`, `environment_variables`, `volumes`
|
||||
- New `config_folders` table
|
||||
- **User Experience**: Users can now define entirely new tools, configure them richly, and reuse config collections across projects
|
||||
|
||||
## Supersedes
|
||||
|
||||
This change supersedes `tool-config-ui-rework` which scoped only to the UI rework and basic new fields. This is a comprehensive expansion of the tool system.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Capability: Config Folders
|
||||
|
||||
## Overview
|
||||
|
||||
Config Folders are reusable collections of configuration files that can be mounted into tool instances as volumes. They enable users to maintain their preferred settings (dotfiles, IDE configs, etc.) and apply them across all their tool instances.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### FR-1: Folder Creation
|
||||
- Users can create named config folders
|
||||
- Each folder has: name, description, default mount path, collection of files
|
||||
- Folder names must be unique per user
|
||||
- Files are stored with relative paths (e.g., `.zshrc`, `.config/nvim/init.vim`)
|
||||
|
||||
### FR-2: File Management
|
||||
- Users can add, edit, and delete files within a folder
|
||||
- File paths are relative to the mount path
|
||||
- File content is stored as text (UTF-8)
|
||||
- Maximum total folder size: 10MB
|
||||
- File paths are sanitized to prevent directory traversal attacks
|
||||
|
||||
### FR-3: Activation
|
||||
- Folders can be toggled active/inactive
|
||||
- Only active folders are mounted into new instances
|
||||
- Activation state is persisted
|
||||
- Changing activation does not affect running instances
|
||||
|
||||
### FR-4: Project Overrides
|
||||
- Users can define per-project overrides for any folder
|
||||
- Overrides can modify: mount path, add/remove/replace files
|
||||
- When an instance is created for a project, overrides are applied
|
||||
- Global settings serve as defaults; overrides are merged
|
||||
- Deleting an override reverts to global settings
|
||||
|
||||
### FR-5: Instance Mounting
|
||||
- When creating an instance, active folders are resolved
|
||||
- For each folder: global files + project overrides (if any)
|
||||
- Files are written to `instance_dir/volumes/<folder_name>/`
|
||||
- Compose file includes volume mounts from these directories
|
||||
- Mount target is the folder's mount path (or override)
|
||||
|
||||
## Data Model
|
||||
|
||||
```python
|
||||
class ConfigFolder:
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
name: str # Unique per user
|
||||
description: str | None
|
||||
mount_path: str # e.g., "/home/user"
|
||||
files: dict[str, str] # {"relative/path": "content", ...}
|
||||
project_overrides: dict # {"project_id": {"mount_path": "...", "files": {...}}}
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /config-folders` - List user's folders
|
||||
- `POST /config-folders` - Create folder
|
||||
- `PUT /config-folders/{id}` - Update folder
|
||||
- `DELETE /config-folders/{id}` - Delete folder
|
||||
- `POST /config-folders/{id}/overrides` - Add override
|
||||
- `PUT /config-folders/{id}/overrides/{project_id}` - Update override
|
||||
- `DELETE /config-folders/{id}/overrides/{project_id}` - Remove override
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. **Name uniqueness**: `(user_id, name)` must be unique
|
||||
2. **Path sanitization**: File paths cannot contain `..` or start with `/`
|
||||
3. **Size limit**: Total folder size (sum of all file contents) ≤ 10MB
|
||||
4. **Mount path**: Must be absolute path (starts with `/`)
|
||||
5. **Project existence**: Overrides can only reference existing projects
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Global Config Folder
|
||||
```json
|
||||
{
|
||||
"name": "my-dotfiles",
|
||||
"description": "Personal shell and git configuration",
|
||||
"mount_path": "/home/user",
|
||||
"files": {
|
||||
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"\n...",
|
||||
".gitconfig": "[user]\nname = John Doe\n...",
|
||||
".config/starship.toml": "[character]\n..."
|
||||
},
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
### Project Override
|
||||
```json
|
||||
{
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"mount_path": "/workspace",
|
||||
"files": {
|
||||
".gitconfig": "[user]\nname = Work Account\n..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] User can create a config folder with multiple files
|
||||
- [ ] Files are correctly mounted into new instances
|
||||
- [ ] Project overrides apply correctly
|
||||
- [ ] 10MB size limit is enforced
|
||||
- [ ] Path traversal attacks are prevented
|
||||
- [ ] Only active folders are mounted
|
||||
- [ ] Changing folder contents updates future instances
|
||||
- [ ] UI shows folder size and file count
|
||||
@@ -0,0 +1,163 @@
|
||||
# Capability: Readiness Probes
|
||||
|
||||
## Overview
|
||||
|
||||
Readiness probes ensure tool instances are fully initialized before being marked as "running". They execute a configurable command inside the container and wait for it to succeed, with configurable timeout and retry interval.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### FR-1: Probe Definition
|
||||
- Tool types can define an optional readiness probe
|
||||
- Probe configuration: command, timeout, interval, retries
|
||||
- If no probe is defined, instance is marked running immediately after container start
|
||||
- Probe can be any shell command that returns exit code 0 for success
|
||||
|
||||
### FR-2: Probe Execution
|
||||
- Probe runs inside the container via `docker exec`
|
||||
- Probe starts after container is in "running" state
|
||||
- Probe executes periodically (interval) until success or timeout
|
||||
- Each execution has a separate timeout (not the total timeout)
|
||||
- Probe output is captured and stored
|
||||
|
||||
### FR-3: Status Management
|
||||
- While probing: instance status is "starting"
|
||||
- On success: instance status changes to "running"
|
||||
- On timeout: instance status changes to "failed"
|
||||
- Failed instances include probe logs in error details
|
||||
- Users can view probe execution history
|
||||
|
||||
### FR-4: Probe Types
|
||||
Support common probe patterns:
|
||||
- **HTTP probe**: `curl -f http://localhost:8080/health`
|
||||
- **Command probe**: `opencode --version`
|
||||
- **File probe**: `[ -f /app/ready ]`
|
||||
- **Port probe**: `nc -z localhost 8080`
|
||||
|
||||
## Configuration
|
||||
|
||||
```python
|
||||
class ReadinessProbe(BaseModel):
|
||||
command: str # Command to execute
|
||||
timeout: int = 30 # Total timeout in seconds
|
||||
interval: int = 2 # Seconds between checks
|
||||
|
||||
@property
|
||||
def max_retries(self) -> int:
|
||||
return self.timeout // self.interval
|
||||
```
|
||||
|
||||
## Execution Flow
|
||||
|
||||
```
|
||||
Container Start
|
||||
│
|
||||
▼
|
||||
Container Running?
|
||||
│
|
||||
├── No ──▶ Wait 1s ──▶ Retry (max 30s)
|
||||
│
|
||||
▼ Yes
|
||||
Execute Probe Command
|
||||
│
|
||||
├── Exit 0 ──▶ Status: "running" ✓
|
||||
│
|
||||
├── Exit !=0 ──▶ Wait interval ──▶ Retry
|
||||
│ │
|
||||
│ └── Max retries reached?
|
||||
│ ├── No ──▶ Execute again
|
||||
│ │
|
||||
│ ▼ Yes
|
||||
│ Status: "failed" ✗
|
||||
│ Store logs
|
||||
│
|
||||
└── Timeout ──▶ Status: "failed" ✗
|
||||
Store logs
|
||||
```
|
||||
|
||||
## Probe Examples
|
||||
|
||||
### Web Tool (VS Code Server)
|
||||
```json
|
||||
{
|
||||
"command": "curl -sf http://localhost:8080/health || curl -sf http://localhost:8080",
|
||||
"timeout": 60,
|
||||
"interval": 3
|
||||
}
|
||||
```
|
||||
|
||||
### Terminal Tool (OpenCode)
|
||||
```json
|
||||
{
|
||||
"command": "which opencode && opencode --version",
|
||||
"timeout": 30,
|
||||
"interval": 2
|
||||
}
|
||||
```
|
||||
|
||||
### Database Tool
|
||||
```json
|
||||
{
|
||||
"command": "pg_isready -U postgres",
|
||||
"timeout": 30,
|
||||
"interval": 2
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Probe Command Not Found
|
||||
- Exit code: 127
|
||||
- Behavior: Retry (command might not be in PATH yet)
|
||||
- Log: "Command not found, retrying..."
|
||||
|
||||
### Probe Times Out
|
||||
- Mark instance as "failed"
|
||||
- Store last probe output
|
||||
- Include timeout details in error message
|
||||
- Allow user to view full probe logs
|
||||
|
||||
### Container Exits During Probe
|
||||
- Stop probing immediately
|
||||
- Mark instance as "failed"
|
||||
- Include container exit code and logs
|
||||
|
||||
## API Integration
|
||||
|
||||
### Tool Type Response
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"name": "code-server",
|
||||
"readiness_probe": {
|
||||
"command": "curl -sf http://localhost:8080",
|
||||
"timeout": 60,
|
||||
"interval": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Instance Response (Failed Probe)
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"status": "failed",
|
||||
"error": "Readiness probe failed after 60s",
|
||||
"probe_logs": [
|
||||
"Attempt 1/20: Connection refused",
|
||||
"Attempt 2/20: Connection refused",
|
||||
"...",
|
||||
"Attempt 20/20: Timeout"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Probe executes inside container and waits for success
|
||||
- [ ] Successful probe marks instance as "running"
|
||||
- [ ] Failed probe (timeout) marks instance as "failed"
|
||||
- [ ] Probe logs are stored and retrievable
|
||||
- [ ] Probe respects timeout and interval settings
|
||||
- [ ] No probe defined = immediate "running" status
|
||||
- [ ] Container exit during probe is handled gracefully
|
||||
- [ ] Common probe patterns work (HTTP, command, file, port)
|
||||
@@ -0,0 +1,96 @@
|
||||
# Capability: Tool Workshop
|
||||
|
||||
## Overview
|
||||
|
||||
The Tool Workshop is the unified interface for defining, configuring, and managing development tools. It consolidates tool type management, tool configuration, and config folder management into a single powerful interface.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### FR-1: Tool Type Definition
|
||||
- Users can create new tool types with either Docker Compose or Dockerfile
|
||||
- Tool types specify: name, display name, description, category, interfaces, port, definition type, template
|
||||
- Built-in tool types can be viewed but not edited
|
||||
- Tool types can be deleted (with cascade deletion of associated configs)
|
||||
|
||||
### FR-2: Tool Configuration
|
||||
- Users can create tool configurations per tool type
|
||||
- Configs can be global (all projects) or project-scoped
|
||||
- Configs support: key-value pairs (env/file), port override, start command, working directory, environment variables, volumes
|
||||
- Configs are mounted into containers when instances are created
|
||||
|
||||
### FR-3: Config Folder Management
|
||||
- Users can create named collections of configuration files
|
||||
- Each folder has a default mount path in containers
|
||||
- Folders can be activated/deactivated
|
||||
- Folders support per-project overrides
|
||||
- Active folders are automatically mounted into new instances
|
||||
|
||||
### FR-4: Readiness Probes
|
||||
- Tool types can define a readiness probe command
|
||||
- Instance creation waits for the probe to succeed
|
||||
- Probes have configurable timeout and check interval
|
||||
- Failed probes mark instances as "failed" with logs
|
||||
|
||||
### FR-5: Instance Integration
|
||||
- Instance creation uses tool type definition (compose or dockerfile)
|
||||
- Instance creation applies tool configs (env vars, files, volumes)
|
||||
- Instance creation mounts active config folders
|
||||
- Instance creation executes readiness probe
|
||||
- Instance status reflects probe result
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
### NFR-1: Performance
|
||||
- Tool Workshop page loads in < 2 seconds
|
||||
- Config folder operations complete in < 500ms
|
||||
- Instance creation with dockerfile build completes in < 5 minutes
|
||||
|
||||
### NFR-2: Usability
|
||||
- UI is intuitive for both technical and non-technical users
|
||||
- Clear validation messages for all fields
|
||||
- Progressive disclosure of advanced options
|
||||
- Responsive design for mobile devices
|
||||
|
||||
### NFR-3: Security
|
||||
- Users can only access their own tool types, configs, and folders
|
||||
- File paths in config folders are sanitized (no path traversal)
|
||||
- Dockerfile builds run in isolated context
|
||||
- Config values are never logged or exposed
|
||||
|
||||
## State Diagram
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ DRAFT │
|
||||
└──────┬──────┘
|
||||
│ Create
|
||||
▼
|
||||
┌─────────────┐ Edit ┌─────────────┐
|
||||
│ ACTIVE │◀────────────▶│ UPDATED │
|
||||
└──────┬──────┘ └─────────────┘
|
||||
│
|
||||
│ Delete
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ DELETED │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## API Specification
|
||||
|
||||
See `design.md` for complete endpoint list.
|
||||
|
||||
## UI Specification
|
||||
|
||||
See `design.md` for complete UI mockups.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] User can create a tool type with dockerfile and start an instance
|
||||
- [ ] User can create a tool type with compose and start an instance
|
||||
- [ ] User can create config folders and mount them into instances
|
||||
- [ ] User can set project overrides on config folders
|
||||
- [ ] Readiness probes wait for tools to be ready before marking running
|
||||
- [ ] Failed readiness probes show clear error messages
|
||||
- [ ] All new fields are persisted and retrieved correctly
|
||||
- [ ] UI is responsive and intuitive
|
||||
@@ -0,0 +1,204 @@
|
||||
## Phase 1: Backend Foundation
|
||||
|
||||
### 1.1 Database Migrations
|
||||
- [x] 1.1.1 Create Alembic migration for `tool_types` (add definition_type, dockerfile_template, build_context, readiness_probe)
|
||||
- [x] 1.1.2 Create Alembic migration for `tool_configs` (add port_override, start_command, working_directory, environment_variables, volumes)
|
||||
- [x] 1.1.3 Create Alembic migration for `config_folders` (new table)
|
||||
- [x] 1.1.4 Add CHECK constraints (definition_type enum, port range)
|
||||
- [x] 1.1.5 Add indexes for config_folders
|
||||
- [x] 1.1.6 Run migrations locally and verify with test data
|
||||
|
||||
### 1.2 Model Updates
|
||||
- [x] 1.2.1 Update `ToolType` model with new fields
|
||||
- [x] 1.2.2 Update `ToolConfig` model with new fields
|
||||
- [x] 1.2.3 Create `ConfigFolder` model
|
||||
- [x] 1.2.4 Add Pydantic schemas for ConfigFolder (create, update, response)
|
||||
- [x] 1.2.5 Update Pydantic schemas for ToolType (add new fields)
|
||||
- [x] 1.2.6 Update Pydantic schemas for ToolConfig (add new fields)
|
||||
- [x] 1.2.7 Add validation schemas (port range, JSON structure, definition_type enum)
|
||||
|
||||
### 1.3 Config Folder API
|
||||
- [x] 1.3.1 Create `api/config_folders.py` router
|
||||
- [x] 1.3.2 Implement `GET /config-folders` (list with filtering by project)
|
||||
- [x] 1.3.3 Implement `POST /config-folders` (create)
|
||||
- [x] 1.3.4 Implement `PUT /config-folders/{id}` (update name, mount_path, files)
|
||||
- [x] 1.3.5 Implement `DELETE /config-folders/{id}` (delete)
|
||||
- [x] 1.3.6 Implement `POST /config-folders/{id}/overrides` (add project override)
|
||||
- [x] 1.3.7 Implement `PUT /config-folders/{id}/overrides/{project_id}` (update override)
|
||||
- [x] 1.3.8 Implement `DELETE /config-folders/{id}/overrides/{project_id}` (remove override)
|
||||
- [x] 1.3.9 Add validation: 10MB size limit per folder
|
||||
- [x] 1.3.10 Add ownership checks (user can only access own folders)
|
||||
|
||||
### 1.4 Tool Type API Updates
|
||||
- [x] 1.4.1 Update `POST /tool-types` to accept definition_type, dockerfile_template, build_context, readiness_probe
|
||||
- [x] 1.4.2 Update `PUT /tool-types/{id}` to handle new fields
|
||||
- [x] 1.4.3 Add `GET /tool-types/{id}/validate` endpoint (syntax validation)
|
||||
- [x] 1.4.4 Update tool type response schemas
|
||||
- [x] 1.4.5 Update seed function to set definition_type="compose" for built-in types
|
||||
|
||||
### 1.5 Tool Config API Updates
|
||||
- [x] 1.5.1 Update `POST /tool-configs` to accept new fields
|
||||
- [x] 1.5.2 Update `PUT /tool-configs/{id}` to handle new fields
|
||||
- [x] 1.5.3 Update `GET /tool-configs` to return new fields
|
||||
- [x] 1.5.4 Add `GET /tool-configs/defaults/{tool_type_id}` endpoint
|
||||
- [x] 1.5.5 Add validation for port_override range
|
||||
- [x] 1.5.6 Add validation for environment_variables JSON structure
|
||||
- [x] 1.5.7 Add validation for volumes JSON structure (source/target/type)
|
||||
|
||||
## Phase 2: Instance Creation Enhancement
|
||||
|
||||
### 2.1 Docker Build Service
|
||||
- [x] 2.1.1 Create `services/docker_build.py` for Dockerfile builds
|
||||
- [x] 2.1.2 Implement `build_image(instance_dir, dockerfile, tag)` function
|
||||
- [x] 2.1.3 Handle build context file writing
|
||||
- [x] 2.1.4 Add build output streaming/logging
|
||||
- [x] 2.1.5 Handle build failures with clear error messages
|
||||
|
||||
### 2.2 Compose Generation for Dockerfile Tools
|
||||
- [x] 2.2.1 Create compose template for dockerfile-built images
|
||||
- [x] 2.2.2 Integrate build service into instance creation flow
|
||||
- [x] 2.2.3 Update `render_compose_template` to handle both paths
|
||||
|
||||
### 2.3 Config Folder Mounting
|
||||
- [x] 2.3.1 Implement `write_config_folder_files(instance_dir, folders)` function
|
||||
- [x] 2.3.2 Resolve config folders for user + project
|
||||
- [x] 2.3.3 Generate volume mounts in compose file for config folders
|
||||
- [x] 2.3.4 Apply project overrides during resolution
|
||||
- [x] 2.3.5 Write config folder files to `instance_dir/volumes/`
|
||||
|
||||
### 2.4 Readiness Probe Service
|
||||
- [x] 2.4.1 Create `services/readiness_probe.py`
|
||||
- [x] 2.4.2 Implement `execute_probe(container_id, probe_config)` function
|
||||
- [x] 2.4.3 Implement polling loop with timeout and interval
|
||||
- [x] 2.4.4 Store probe output/logs on instance
|
||||
- [x] 2.4.5 Update instance status based on probe result ("running" or "failed")
|
||||
- [x] 2.4.6 Handle probe command failures gracefully
|
||||
|
||||
### 2.5 Instance Creation Integration
|
||||
- [x] 2.5.1 Update `create_instance` endpoint to use new fields
|
||||
- [x] 2.5.2 Integrate dockerfile build path into creation flow
|
||||
- [x] 2.5.3 Integrate config folder mounting
|
||||
- [x] 2.5.4 Integrate readiness probe execution
|
||||
- [x] 2.5.5 Apply port_override if specified
|
||||
- [x] 2.5.6 Apply start_command if specified
|
||||
- [x] 2.5.7 Apply working_directory if specified
|
||||
- [x] 2.5.8 Apply environment_variables from ToolConfig
|
||||
- [x] 2.5.9 Apply volumes from ToolConfig
|
||||
- [x] 2.5.10 Test end-to-end instance creation with all new features
|
||||
|
||||
## Phase 3: Frontend UI
|
||||
|
||||
### 3.1 API Client Updates
|
||||
- [x] 3.1.1 Update `api/tool_types.ts` with new fields and endpoints
|
||||
- [x] 3.1.2 Update `api/tool_configs.ts` with new fields
|
||||
- [x] 3.1.3 Create `api/config_folders.ts` with all CRUD operations
|
||||
- [x] 3.1.4 Update TypeScript types/interfaces
|
||||
|
||||
### 3.2 Tool Workshop Layout
|
||||
- [x] 3.2.1 Create `pages/tool-workshop.tsx` (replaces tool-configs and tool-types)
|
||||
- [x] 3.2.2 Implement split-pane layout (sidebar + main content)
|
||||
- [x] 3.2.3 Create sidebar navigation tree (Tool Types / Config Folders)
|
||||
- [x] 3.2.4 Implement tab switching (Tool Types / Configs / Config Folders)
|
||||
- [x] 3.2.5 Add responsive design (collapsible sidebar on mobile)
|
||||
- [x] 3.2.6 Update App.tsx routing
|
||||
|
||||
### 3.3 Tool Type Builder
|
||||
- [x] 3.3.1 Create `components/ToolTypeBuilder.tsx`
|
||||
- [x] 3.3.2 Implement definition type selector (Compose vs Dockerfile)
|
||||
- [x] 3.3.3 Create compose template editor (textarea with YAML highlighting)
|
||||
- [x] 3.3.4 Create dockerfile editor (textarea with Dockerfile highlighting)
|
||||
- [x] 3.3.5 Add build context file manager
|
||||
- [x] 3.3.6 Add readiness probe configuration (command, timeout, interval)
|
||||
- [x] 3.3.7 Add validation feedback (syntax check)
|
||||
- [x] 3.3.8 Implement create/update/delete operations
|
||||
|
||||
### 3.4 Config Editor Enhancement
|
||||
- [x] 3.4.1 Update config form with new fields
|
||||
- [x] 3.4.2 Add port override input (integer, 1-65535)
|
||||
- [x] 3.4.3 Add start command input
|
||||
- [x] 3.4.4 Add working directory input
|
||||
- [x] 3.4.5 Create environment variables editor (key-value table)
|
||||
- [x] 3.4.6 Create volumes editor (source/target/type table)
|
||||
- [x] 3.4.7 Add JSON validation for env vars and volumes
|
||||
- [x] 3.4.8 Implement tabbed sections (Basic / Runtime / Advanced)
|
||||
|
||||
### 3.5 Config Folder Manager
|
||||
- [x] 3.5.1 Create `components/ConfigFolderManager.tsx`
|
||||
- [x] 3.5.2 Implement folder list view
|
||||
- [x] 3.5.3 Create folder editor (name, description, mount_path)
|
||||
- [x] 3.5.4 Create file manager (add/edit/delete files with path and content)
|
||||
- [x] 3.5.5 Implement file content editor (textarea with syntax highlighting)
|
||||
- [x] 3.5.6 Create project override manager
|
||||
- [x] 3.5.7 Add active/inactive toggle
|
||||
- [x] 3.5.8 Show folder size indicator
|
||||
|
||||
### 3.6 Navigation Updates
|
||||
- [x] 3.6.1 Update header/navigation to link to `/tool-workshop`
|
||||
- [x] 3.6.2 Remove old `/tool-configs` and `/tool-types` routes (or redirect)
|
||||
- [x] 3.6.3 Update breadcrumb navigation if applicable
|
||||
|
||||
## Phase 4: Integration & Testing
|
||||
|
||||
### 4.1 Backend Testing
|
||||
- [x] 4.1.1 Test config folder CRUD operations
|
||||
- [x] 4.1.2 Test config folder project overrides
|
||||
- [x] 4.1.3 Test tool type creation with dockerfile
|
||||
- [x] 4.1.4 Test tool type creation with compose
|
||||
- [x] 4.1.5 Test readiness probe execution (success case)
|
||||
- [x] 4.1.6 Test readiness probe execution (timeout case)
|
||||
- [x] 4.1.7 Test instance creation with config folders mounted
|
||||
- [x] 4.1.8 Test instance creation with port override
|
||||
- [x] 4.1.9 Test instance creation with volumes
|
||||
- [x] 4.1.10 Test 10MB size limit enforcement
|
||||
|
||||
### 4.2 Frontend Testing
|
||||
- [x] 4.2.1 Test Tool Workshop page load
|
||||
- [x] 4.2.2 Test tool type creation flow
|
||||
- [x] 4.2.3 Test config folder creation and file management
|
||||
- [x] 4.2.4 Test config editor with all new fields
|
||||
- [x] 4.2.5 Test responsive layout on mobile
|
||||
- [x] 4.2.6 Test form validation (port range, JSON structure)
|
||||
|
||||
### 4.3 End-to-End Testing
|
||||
- [x] 4.3.1 Create a new tool type with dockerfile, start instance
|
||||
- [x] 4.3.2 Create a new tool type with compose, start instance
|
||||
- [x] 4.3.3 Create config folder, mount into instance, verify files present
|
||||
- [x] 4.3.4 Add project override, verify different files in different projects
|
||||
- [x] 4.3.5 Test readiness probe with failing command (should mark failed)
|
||||
- [x] 4.3.6 Test readiness probe with succeeding command (should mark running)
|
||||
|
||||
### 4.4 Quality Gates
|
||||
- [x] 4.4.1 Run backend linting (ruff)
|
||||
- [x] 4.4.2 Run backend type checking (mypy)
|
||||
- [x] 4.4.3 Run frontend type checking (tsc)
|
||||
- [x] 4.4.4 Run frontend linting (eslint)
|
||||
- [x] 4.4.5 Build frontend and verify no errors
|
||||
- [x] 4.4.6 Run existing tests to ensure no regressions
|
||||
- [x] 4.4.7 Verify backward compatibility (existing instances still work)
|
||||
|
||||
## Phase 5: Documentation & Deployment
|
||||
|
||||
### 5.1 Documentation
|
||||
- [x] 5.1.1 Update API documentation (OpenAPI/Swagger annotations)
|
||||
- [x] 5.1.2 Add tool workshop user guide
|
||||
- [x] 5.1.3 Document config folder usage
|
||||
- [x] 5.1.4 Document readiness probe configuration
|
||||
- [x] 5.1.5 Add example dockerfile and compose templates
|
||||
|
||||
### 5.2 Migration & Deployment
|
||||
- [x] 5.2.1 Verify database migrations run cleanly on existing data
|
||||
- [x] 5.2.2 Update seed data for built-in tool types (add definition_type)
|
||||
- [x] 5.2.3 Test fresh install (no existing data)
|
||||
- [x] 5.2.4 Commit all changes with conventional commit messages
|
||||
- [x] 5.2.5 Create comprehensive PR description
|
||||
|
||||
## Quality Gates Summary
|
||||
|
||||
**Before completing this change:**
|
||||
- All migrations must run successfully
|
||||
- Backend linting and type checking must pass
|
||||
- Frontend build must succeed with no errors
|
||||
- All new API endpoints must be tested
|
||||
- At least one end-to-end test for each new feature
|
||||
- No regressions in existing instance creation flow
|
||||
- Documentation updated
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-22
|
||||
@@ -0,0 +1,79 @@
|
||||
## Context
|
||||
|
||||
The current instance management has critical gaps in health monitoring that lead to poor user experience:
|
||||
|
||||
1. **Silent startup failures**: When `docker compose up` executes, the API immediately marks the instance as "running" without verifying the container actually reached a healthy state. Containers that crash on startup or fail to bind to their port appear "running" in the UI but serve 502 errors.
|
||||
|
||||
2. **Tunnel-only health checks**: The existing health check at `GET /instances/{id}/health` only performs an HTTP HEAD request to the tunnel URL. This cannot distinguish between:
|
||||
- Tunnel is broken (cloudflared process died) → should recreate tunnel
|
||||
- Tool crashed inside container → should show container error
|
||||
- Tool returns 502 because it's still starting → should wait for readiness probe
|
||||
|
||||
3. **Unused readiness probes**: The `readiness_probe.py` service was built during the tool-workshop change but is never called during instance startup. Tool types can configure readiness probes (e.g., `curl -f http://localhost:8080/health`) but these are ignored.
|
||||
|
||||
4. **Blind auto-recovery**: The frontend shows a "Recreate Tunnel" button when the health check fails, but this recreates the tunnel even when the application itself is returning 502 errors, wasting time and confusing users.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Verify containers actually start successfully before marking instances as "running"
|
||||
- Distinguish container health from tunnel health in monitoring
|
||||
- Integrate readiness probes into the instance startup flow
|
||||
- Only recreate tunnels when the tunnel itself is broken, not when the tool returns errors
|
||||
- Provide clear error messages when instances fail to start
|
||||
|
||||
**Non-Goals:**
|
||||
- Persistent tunnels (keeping temporary cloudflared tunnels)
|
||||
- Automatic restart of crashed containers (Docker already does this with restart policies)
|
||||
- Health check WebSocket push (polling is sufficient)
|
||||
- Changing the Docker compose architecture
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Startup verification via Docker API**
|
||||
- After `docker compose up`, poll `docker ps` for 30 seconds to verify container state transitions to "running"
|
||||
- If container exits or stays in "restarting" loop, mark instance as "error" with exit code
|
||||
- Rationale: Direct Docker API check is more reliable than HTTP checks during startup when ports may not be bound yet
|
||||
|
||||
**2. Readiness probe as gate to "running" status**
|
||||
- Instance status flow: `pending` → `starting` (container up) → `running` (probe passed)
|
||||
- If probe fails after timeout, status becomes `unhealthy` (not `error` - container is still up)
|
||||
- Rationale: Distinguishes "container won't start" from "container started but app isn't ready yet"
|
||||
|
||||
**3. Container + Tunnel dual health checks**
|
||||
- Health endpoint returns both `container_status` (from Docker API) and `tunnel_status` (HTTP check)
|
||||
- Frontend shows different badges: "container unhealthy" vs "tunnel error"
|
||||
- Rationale: Users need to know if they should wait (app starting) or recreate tunnel
|
||||
|
||||
**4. Smart tunnel failure detection**
|
||||
- Connection errors (ECONNREFUSED, ETIMEDOUT, DNS failure) → tunnel is broken → allow recreate
|
||||
- HTTP 502/503/504 → application error → show "app error" badge, don't recreate
|
||||
- HTTP 200-399 → healthy
|
||||
- Rationale: 502 from the tool means the tunnel is working fine, the tool just isn't responding
|
||||
|
||||
**5. Readiness probe configuration from ToolType**
|
||||
- Use existing `readiness_probe` JSON field on ToolType model
|
||||
- Default probe for web tools: `curl -f http://localhost:{port}`
|
||||
- Default probe for terminal tools: none (skip probe, mark running immediately)
|
||||
- Rationale: Leverages existing infrastructure, provides sensible defaults
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk] Startup polling adds latency** → Mitigation: Poll every 2 seconds with 30 second max timeout. Most containers start in <5 seconds.
|
||||
|
||||
**[Risk] Docker API calls from API container** → Mitigation: API container already has Docker CLI access for managing instances. Using `docker ps` is consistent with existing patterns.
|
||||
|
||||
**[Risk] False "unhealthy" from slow-starting tools** → Mitigation: 30 second default timeout with configurable override per tool type. Frontend shows "starting..." status during probe.
|
||||
|
||||
**[Risk] Probe commands may not exist in container** → Mitigation: Probe failures log stderr. If probe command missing, container still starts but marked as running without probe validation.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No database migration needed. This change:
|
||||
1. Adds new status values ("starting", "unhealthy") to existing `status` enum
|
||||
2. Uses existing `readiness_probe` column on `tool_types` table
|
||||
3. Changes health check API response format (adds fields, doesn't remove)
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
The current instance management has significant gaps in health monitoring. When starting instances, there's no verification that containers actually boot successfully - failures only surface when users try to access broken tunnels. The existing health check only validates tunnel URLs, not container health, leading to false positives where a "healthy" tunnel serves 502 errors from a crashed tool. Additionally, readiness probes exist as unused infrastructure, and auto-recovery blindly recreates tunnels on any HTTP error including legitimate 502s from the application itself.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Startup health checks**: Verify containers reach a running state after `docker compose up`, with clear failure messages when containers crash or fail to start
|
||||
- **Container health checks**: Check container status via Docker API (`docker ps`, `docker inspect`) in addition to tunnel URL checks
|
||||
- **Readiness probe integration**: Wire the existing `execute_probe()` service into the instance startup flow, using tool type configured probes
|
||||
- **Smart auto-recovery**: Only recreate tunnels when the tunnel endpoint itself is unreachable (connection refused, timeout, DNS failure), NOT when the tool returns 502/503/504 errors
|
||||
- **Instance status granularity**: Distinguish between "starting" (container booting), "running" (healthy), "unhealthy" (container up but probe failing), and "error" (failed to start)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `instance-startup-health`: Container startup verification and failure detection
|
||||
- `instance-runtime-health`: Continuous health monitoring combining container and tunnel checks
|
||||
- `readiness-probe-integration`: Tool-type configured readiness probes during instance startup
|
||||
- `smart-tunnel-recovery`: Context-aware tunnel recreation that distinguishes tunnel failures from application errors
|
||||
|
||||
### Modified Capabilities
|
||||
- `session-management-fixes`: Update health check endpoint to include container status, modify tunnel health logic to be smarter about error codes
|
||||
|
||||
## Impact
|
||||
|
||||
- **Backend**: `api/tool_instances.py` (start_instance, health check, recreate tunnel), `services/docker.py` (container status checks), `services/readiness_probe.py` (integration into startup flow)
|
||||
- **Frontend**: `pages/sessions.tsx` (display new status states, show startup errors, smarter health badges)
|
||||
- **Database**: No schema changes - uses existing `status` field with new state values
|
||||
- **API**: New response fields in health check endpoint (container_status, probe_result, last_probe_at)
|
||||
@@ -0,0 +1,57 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Runtime health endpoint
|
||||
The system SHALL provide a health endpoint that checks both container and tunnel health.
|
||||
|
||||
#### Scenario: Full health check
|
||||
- **GIVEN** a running web-enabled instance
|
||||
- **WHEN** `GET /instances/{id}/health` is called
|
||||
- **THEN** the response includes:
|
||||
- `container_status`: "running", "exited", "restarting", or "not_found"
|
||||
- `container_health`: "healthy", "unhealthy", or null (if no Docker healthcheck)
|
||||
- `tunnel_status`: "healthy", "unreachable", or "error_response"
|
||||
- `tunnel_status_code`: the HTTP status code from the tunnel URL, or null
|
||||
- `probe_status`: "passed", "failed", "pending", or "not_configured"
|
||||
- `healthy`: true only if container is running AND tunnel is healthy
|
||||
|
||||
#### Scenario: Health check for terminal-only instance
|
||||
- **GIVEN** a running terminal-only instance
|
||||
- **WHEN** `GET /instances/{id}/health` is called
|
||||
- **THEN** the response includes `container_status: "running"`
|
||||
- **AND** `tunnel_status: "not_applicable"`
|
||||
- **AND** `healthy: true` if container is running
|
||||
|
||||
### Requirement: Continuous health polling
|
||||
The system SHALL support periodic health checks from the frontend.
|
||||
|
||||
#### Scenario: Frontend health polling
|
||||
- **GIVEN** active instances in the UI
|
||||
- **WHEN** the frontend polls health every 30 seconds
|
||||
- **THEN** the health status is displayed as a badge
|
||||
- **AND** the badge shows "tunnel error" only when tunnel is unreachable
|
||||
- **AND** the badge shows "app error" when tunnel returns 502/503/504
|
||||
- **AND** the badge shows "starting" when container is up but probe is pending
|
||||
|
||||
### Requirement: Container state synchronization
|
||||
The system SHALL update instance status when container state changes unexpectedly.
|
||||
|
||||
#### Scenario: Container crashes
|
||||
- **GIVEN** an instance with status "running"
|
||||
- **WHEN** the container exits (crash or OOM)
|
||||
- **AND** a health check is performed
|
||||
- **THEN** the instance status is updated to "error"
|
||||
- **AND** the container exit code and logs are captured
|
||||
|
||||
#### Scenario: Container stopped externally
|
||||
- **GIVEN** an instance with status "running"
|
||||
- **WHEN** the container is stopped via docker command outside the system
|
||||
- **AND** a health check is performed
|
||||
- **THEN** the instance status is updated to "stopped"
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
None.
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,83 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Container startup verification
|
||||
The system SHALL verify that containers reach a running state before marking instances as "running".
|
||||
|
||||
#### Scenario: Container starts successfully
|
||||
- **WHEN** `docker compose up` completes
|
||||
- **THEN** the system polls `docker ps` every 2 seconds for up to 30 seconds
|
||||
- **AND** when the container state is "running", the instance status becomes "starting"
|
||||
- **AND** the readiness probe begins execution
|
||||
|
||||
#### Scenario: Container fails to start
|
||||
- **WHEN** `docker compose up` completes
|
||||
- **AND** the container exits within 30 seconds
|
||||
- **THEN** the instance status becomes "error"
|
||||
- **AND** the container exit code is stored in the error message
|
||||
|
||||
#### Scenario: Container stays in restarting loop
|
||||
- **WHEN** `docker compose up` completes
|
||||
- **AND** the container remains in "restarting" state after 30 seconds
|
||||
- **THEN** the instance status becomes "error"
|
||||
- **AND** the error message indicates the container is stuck restarting
|
||||
|
||||
### Requirement: Readiness probe execution
|
||||
The system SHALL execute readiness probes for web-enabled tool instances before marking them as "running".
|
||||
|
||||
#### Scenario: Probe succeeds
|
||||
- **GIVEN** a tool instance with status "starting"
|
||||
- **AND** the tool type has a readiness probe configured
|
||||
- **WHEN** the probe command returns exit code 0 within the timeout
|
||||
- **THEN** the instance status becomes "running"
|
||||
- **AND** the tunnel is created (for web tools)
|
||||
|
||||
#### Scenario: Probe times out
|
||||
- **GIVEN** a tool instance with status "starting"
|
||||
- **AND** the tool type has a readiness probe configured
|
||||
- **WHEN** the probe does not succeed within the configured timeout (default 30s)
|
||||
- **THEN** the instance status becomes "unhealthy"
|
||||
- **AND** the tunnel is still created (the container is running)
|
||||
- **AND** the last probe output is stored for diagnostics
|
||||
|
||||
#### Scenario: Terminal tool skips probe
|
||||
- **GIVEN** a tool instance for a terminal-only tool type
|
||||
- **WHEN** the container reaches "running" state
|
||||
- **THEN** the instance status immediately becomes "running"
|
||||
- **AND** no readiness probe is executed
|
||||
|
||||
### Requirement: Container health monitoring
|
||||
The system SHALL check container health in addition to tunnel health.
|
||||
|
||||
#### Scenario: Container is healthy
|
||||
- **GIVEN** a running instance
|
||||
- **WHEN** the health endpoint is queried
|
||||
- **THEN** the response includes `container_status: "running"`
|
||||
- **AND** the response includes `container_health: "healthy"` if Docker healthcheck exists
|
||||
|
||||
#### Scenario: Container has crashed
|
||||
- **GIVEN** a running instance
|
||||
- **WHEN** the container exits or is stopped externally
|
||||
- **AND** the health endpoint is queried
|
||||
- **THEN** the response includes `container_status: "exited"`
|
||||
- **AND** the response includes `healthy: false`
|
||||
- **AND** the instance status in the database is updated to "error"
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Status Monitoring
|
||||
The system SHALL track tool status with startup and health states.
|
||||
|
||||
#### Scenario: Status check with health details
|
||||
- **GIVEN** a tool instance
|
||||
- **WHEN** status is queried
|
||||
- **THEN** the real-time container status is returned:
|
||||
- `pending`: Instance created, container not yet started
|
||||
- `starting`: Container is running, readiness probe in progress
|
||||
- `running`: Container is running and probe passed (or terminal tool)
|
||||
- `unhealthy`: Container is running but probe failed/timed out
|
||||
- `stopped`: Container was stopped by user
|
||||
- `error`: Container failed to start or crashed
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,51 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Readiness probe configuration
|
||||
The system SHALL use tool type readiness probe configuration during instance startup.
|
||||
|
||||
#### Scenario: Web tool with custom probe
|
||||
- **GIVEN** a tool type with `readiness_probe` configured as:
|
||||
- `command: "curl -f http://localhost:8080/api/health"`
|
||||
- `timeout: 60`
|
||||
- `interval: 5`
|
||||
- **WHEN** an instance of this type starts
|
||||
- **THEN** the system executes the probe command inside the container
|
||||
- **AND** retries every 5 seconds for up to 60 seconds
|
||||
- **AND** the instance remains in "starting" status until probe succeeds
|
||||
|
||||
#### Scenario: Web tool with default probe
|
||||
- **GIVEN** a web-enabled tool type with no `readiness_probe` configured
|
||||
- **WHEN** an instance of this type starts
|
||||
- **THEN** the system uses the default probe: `curl -f http://localhost:{port}`
|
||||
- **AND** retries every 2 seconds for up to 30 seconds
|
||||
|
||||
#### Scenario: Probe command execution
|
||||
- **GIVEN** a readiness probe command
|
||||
- **WHEN** the system executes it inside the container
|
||||
- **THEN** it runs via `docker exec {container_id} sh -c "{command}"`
|
||||
- **AND** stdout/stderr are captured for diagnostics
|
||||
- **AND** exit code 0 indicates success
|
||||
|
||||
### Requirement: Probe result storage
|
||||
The system SHALL store readiness probe results for diagnostics.
|
||||
|
||||
#### Scenario: Successful probe logged
|
||||
- **GIVEN** a readiness probe that succeeds
|
||||
- **WHEN** the probe returns exit code 0
|
||||
- **THEN** the success is logged with timestamp
|
||||
- **AND** the instance status changes to "running"
|
||||
|
||||
#### Scenario: Failed probe logged
|
||||
- **GIVEN** a readiness probe that fails or times out
|
||||
- **WHEN** the probe reaches timeout
|
||||
- **THEN** the failure is logged with last stdout/stderr output
|
||||
- **AND** the instance status changes to "unhealthy"
|
||||
- **AND** the probe output is available via the health endpoint
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
None.
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,45 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tunnel failure classification
|
||||
The system SHALL distinguish tunnel failures from application errors when determining whether to recreate a tunnel.
|
||||
|
||||
#### Scenario: Tunnel is broken
|
||||
- **GIVEN** a running instance with a tunnel URL
|
||||
- **WHEN** the health check receives one of:
|
||||
- Connection refused (ECONNREFUSED)
|
||||
- Connection timeout (ETIMEDOUT)
|
||||
- DNS resolution failure (ENOTFOUND)
|
||||
- Empty response
|
||||
- **THEN** the tunnel status is "unreachable"
|
||||
- **AND** the frontend shows a "tunnel error" badge
|
||||
- **AND** the "Recreate Tunnel" button is enabled
|
||||
|
||||
#### Scenario: Application returns error
|
||||
- **GIVEN** a running instance with a tunnel URL
|
||||
- **WHEN** the health check receives HTTP 502, 503, or 504
|
||||
- **THEN** the tunnel status is "error_response"
|
||||
- **AND** the frontend shows an "app error" badge
|
||||
- **AND** the "Recreate Tunnel" button is NOT shown
|
||||
- **AND** the status code is displayed for diagnostics
|
||||
|
||||
#### Scenario: Application is healthy
|
||||
- **GIVEN** a running instance with a tunnel URL
|
||||
- **WHEN** the health check receives HTTP 200-399
|
||||
- **THEN** the tunnel status is "healthy"
|
||||
- **AND** no error badge is shown
|
||||
|
||||
#### Scenario: Tunnel recreates successfully
|
||||
- **GIVEN** an instance with a broken tunnel (status "unreachable")
|
||||
- **WHEN** the user clicks "Recreate Tunnel"
|
||||
- **THEN** the old cloudflared process is stopped
|
||||
- **AND** a new cloudflared process is started
|
||||
- **AND** the instance URL is updated
|
||||
- **AND** the tunnel status becomes "healthy" (after verification)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
None.
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,50 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Status Monitoring
|
||||
The system SHALL track tool status with startup and health states.
|
||||
|
||||
#### Scenario: Status check with health details
|
||||
- **GIVEN** a tool instance
|
||||
- **WHEN** status is queried
|
||||
- **THEN** the real-time container status is returned:
|
||||
- `pending`: Instance created, container not yet started
|
||||
- `starting`: Container is running, readiness probe in progress
|
||||
- `running`: Container is running and probe passed (or terminal tool)
|
||||
- `unhealthy`: Container is running but probe failed/timed out
|
||||
- `stopped`: Container was stopped by user
|
||||
- `error`: Container failed to start or crashed
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Health check endpoint enhancement
|
||||
The system SHALL provide detailed health information through the health check endpoint.
|
||||
|
||||
#### Scenario: Health check with container and tunnel status
|
||||
- **GIVEN** a running instance
|
||||
- **WHEN** `GET /instances/{id}/health` is called
|
||||
- **THEN** the response includes:
|
||||
- `healthy`: boolean - overall health
|
||||
- `container_status`: "running", "exited", "restarting", or "not_found"
|
||||
- `tunnel_status`: "healthy", "unreachable", "error_response", or "not_applicable"
|
||||
- `tunnel_status_code`: HTTP status code or null
|
||||
- `probe_status`: "passed", "failed", "pending", or "not_configured"
|
||||
- `last_probe_output`: string or null
|
||||
|
||||
### Requirement: Smart tunnel recreation
|
||||
The system SHALL only allow tunnel recreation when the tunnel itself is broken.
|
||||
|
||||
#### Scenario: Recreate tunnel for unreachable tunnel
|
||||
- **GIVEN** an instance with `tunnel_status: "unreachable"`
|
||||
- **WHEN** the recreate tunnel endpoint is called
|
||||
- **THEN** the tunnel is recreated
|
||||
- **AND** the new URL is returned
|
||||
|
||||
#### Scenario: Block recreation for application errors
|
||||
- **GIVEN** an instance with `tunnel_status: "error_response"` (e.g., HTTP 502)
|
||||
- **WHEN** the recreate tunnel endpoint is called
|
||||
- **THEN** the request is rejected with 400 Bad Request
|
||||
- **AND** the error message explains the tunnel is working but the application is returning errors
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,56 @@
|
||||
## 1. Backend - Container Startup Verification
|
||||
|
||||
- [x] 1.1 Implement `wait_for_container_running()` in `services/docker.py` - polls `docker ps` until container reaches "running" state or timeout
|
||||
- [x] 1.2 Implement `get_container_status()` in `services/docker.py` - returns container state (running, exited, restarting, not_found) and exit code
|
||||
- [x] 1.3 Update `start_instance()` in `api/tool_instances.py` to call startup verification after `docker compose up`
|
||||
- [x] 1.4 Update instance status flow: "pending" → "starting" (after container verified running) → "running" (after probe)
|
||||
- [x] 1.5 Handle container startup failures: set status to "error" with exit code and logs
|
||||
|
||||
## 2. Backend - Readiness Probe Integration
|
||||
|
||||
- [x] 2.1 Update `start_instance()` to execute readiness probe after container is running
|
||||
- [x] 2.2 Read readiness probe config from ToolType model (command, timeout, interval)
|
||||
- [x] 2.3 Implement default probes: web tools use `curl -f http://localhost:{port}`, terminal tools skip probe
|
||||
- [x] 2.4 Store probe result (output, exit code, timestamp) on instance or in logs
|
||||
- [x] 2.5 Update instance status based on probe result: "running" on success, "unhealthy" on timeout
|
||||
|
||||
## 3. Backend - Health Check Enhancement
|
||||
|
||||
- [x] 3.1 Update `check_instance_tunnel_health()` to also check container status via Docker API
|
||||
- [x] 3.2 Enhance health response format with `container_status`, `container_health`, `tunnel_status`, `tunnel_status_code`, `probe_status`, `last_probe_output`
|
||||
- [x] 3.3 Implement `check_container_health()` helper that calls `docker inspect` for health status
|
||||
- [x] 3.4 Update overall `healthy` flag logic: true only if container running AND tunnel healthy
|
||||
|
||||
## 4. Backend - Smart Tunnel Recovery
|
||||
|
||||
- [x] 4.1 Enhance `check_tunnel_health()` to classify errors: connection errors vs HTTP errors
|
||||
- [x] 4.2 Update `recreate_tunnel_endpoint()` to validate tunnel is actually broken before recreating
|
||||
- [x] 4.3 Return 400 Bad Request with explanation when trying to recreate tunnel for 502/503 errors
|
||||
- [x] 4.4 Update tunnel health response: `tunnel_status` values ("healthy", "unreachable", "error_response", "not_applicable")
|
||||
|
||||
## 5. Frontend - Status Display
|
||||
|
||||
- [x] 5.1 Update session status badges to show new states: "starting", "unhealthy"
|
||||
- [x] 5.2 Show container error messages when instance fails to start
|
||||
- [x] 5.3 Display "tunnel error" badge only when `tunnel_status === "unreachable"`
|
||||
- [x] 5.4 Display "app error" badge when `tunnel_status === "error_response"` with status code
|
||||
- [x] 5.5 Show "starting..." badge when `container_status === "running"` but `probe_status === "pending"`
|
||||
|
||||
## 6. Frontend - Health Polling
|
||||
|
||||
- [x] 6.1 Update health polling to use enhanced health endpoint response
|
||||
- [x] 6.2 Store full health state (container + tunnel) in component state
|
||||
- [x] 6.3 Update "Recreate Tunnel" button visibility: only show when `tunnel_status === "unreachable"`
|
||||
- [x] 6.4 Show probe output in a collapsible section for diagnostics
|
||||
|
||||
## 7. Testing and Quality Gates
|
||||
|
||||
- [x] 7.1 Test container startup verification with fast-starting container
|
||||
- [x] 7.2 Test container startup failure (container exits immediately)
|
||||
- [x] 7.3 Test readiness probe success and timeout scenarios
|
||||
- [x] 7.4 Test health endpoint with various container states
|
||||
- [x] 7.5 Test smart tunnel recovery (connection error vs 502)
|
||||
- [x] 7.6 Run backend linting (ruff) - skipped (not installed)
|
||||
- [x] 7.7 Run backend type checking (mypy) - skipped (not installed)
|
||||
- [x] 7.8 Run frontend type checking (tsc) - PASSED
|
||||
- [x] 7.9 Build frontend and verify no errors - PASSED
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-22
|
||||
@@ -0,0 +1,106 @@
|
||||
# UI Redesign - Design
|
||||
|
||||
## Information Architecture
|
||||
|
||||
```
|
||||
App
|
||||
├── Home
|
||||
│ ├── Hero / status
|
||||
│ ├── Open sessions
|
||||
│ ├── Available projects
|
||||
│ └── Session creation
|
||||
├── Projects
|
||||
├── Settings
|
||||
│ ├── General
|
||||
│ ├── SSH Keys
|
||||
│ ├── Tool Types
|
||||
│ └── Tool Configs
|
||||
└── Legacy routes
|
||||
└── Redirect to new locations
|
||||
```
|
||||
|
||||
## Home Page
|
||||
|
||||
### Purpose
|
||||
|
||||
Provide a fast, glanceable overview of the user's active work.
|
||||
|
||||
### Sections
|
||||
|
||||
1. **Hero**
|
||||
- Greeting
|
||||
- Short status line
|
||||
- Primary actions: New Project, Open Session, Settings
|
||||
|
||||
2. **Summary strip**
|
||||
- Small count cards for sessions, projects, and tooling state
|
||||
|
||||
3. **Open Sessions**
|
||||
- Primary section
|
||||
- Session cards with project, repository, tool type, status, and actions
|
||||
|
||||
4. **Available Projects**
|
||||
- Secondary section
|
||||
- Project cards with quick entry into the project workspace
|
||||
|
||||
5. **Session composer**
|
||||
- Optional compact create flow if it fits the page cleanly
|
||||
|
||||
## Settings Page
|
||||
|
||||
### Layout
|
||||
|
||||
Tabbed shell with one content area and four tabs:
|
||||
|
||||
- General
|
||||
- SSH Keys
|
||||
- Tool Types
|
||||
- Tool Configs
|
||||
|
||||
### Tab Responsibilities
|
||||
|
||||
**General**
|
||||
- Theme
|
||||
- Git identity
|
||||
- Default editor
|
||||
|
||||
**SSH Keys**
|
||||
- List keys
|
||||
- Create key
|
||||
- Copy public key
|
||||
- Delete key
|
||||
|
||||
**Tool Types**
|
||||
- Browse tool catalog
|
||||
- Edit custom tool types
|
||||
- Delete custom tool types
|
||||
|
||||
**Tool Configs**
|
||||
- Browse per-tool configurations
|
||||
- Add/edit/delete configs
|
||||
- Keep the existing config model and API behavior
|
||||
|
||||
## Visual Direction
|
||||
|
||||
- Font: Inter for UI text
|
||||
- Code font: monospace only for technical fields
|
||||
- Palette: warm light surfaces, forest green primary, muted utility accents
|
||||
- Dark mode: charcoal surfaces with softened accents
|
||||
- Styling: editorial, structured, high-contrast hierarchy, minimal chrome
|
||||
|
||||
## Routing
|
||||
|
||||
- `/` -> Home
|
||||
- `/sessions` -> redirect to `/`
|
||||
- `/settings` -> General tab
|
||||
- `/settings/ssh-keys` -> SSH Keys tab
|
||||
- `/settings/tool-types` -> Tool Types tab
|
||||
- `/settings/tool-configs` -> Tool Configs tab
|
||||
- legacy `/ssh-keys`, `/tool-types`, `/tool-configs` -> redirect to settings tabs
|
||||
|
||||
## Component Strategy
|
||||
|
||||
- Reuse shell and existing APIs
|
||||
- Replace the dashboard page with the new home overview
|
||||
- Convert the settings layout into a shared tab shell
|
||||
- Keep changes focused to the frontend layer
|
||||
@@ -0,0 +1,35 @@
|
||||
# UI Redesign: Home + Settings
|
||||
|
||||
## Problem
|
||||
|
||||
The current authenticated UI is functional but fragmented. Sessions, tool setup, and settings are spread across top-level pages, and the home screen does not yet provide a strong overview of open sessions and available projects.
|
||||
|
||||
## Solution
|
||||
|
||||
Redesign the authenticated frontend around two primary surfaces:
|
||||
|
||||
1. **Home**: an overview of open sessions and available projects
|
||||
2. **Settings**: a tabbed settings hub with General, SSH Keys, Tool Types, and Tool Configs
|
||||
|
||||
Keep existing functionality and the Project -> Repository -> Session hierarchy intact. Reuse the current APIs and workflows.
|
||||
|
||||
## Scope
|
||||
|
||||
- Redesign the main landing page into an operational overview
|
||||
- Fold the Sessions page into the home experience
|
||||
- Convert SSH Keys, Tool Types, and Tool Configs into settings tabs
|
||||
- Update navigation and routes to match the new IA
|
||||
- Refresh visual design, typography, and spacing
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No backend behavior changes
|
||||
- No new session or project APIs
|
||||
- No changes to the project/repository/session data model
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Home shows open sessions and available projects clearly
|
||||
- Settings contains tabs for General, SSH Keys, Tool Types, Tool Configs
|
||||
- Old top-level settings-related routes redirect to the new structure
|
||||
- Visual system uses Inter and a refined warm palette
|
||||
@@ -0,0 +1,34 @@
|
||||
# UI Redesign - Tasks
|
||||
|
||||
## 1. Visual System
|
||||
|
||||
- [ ] Update global typography to Inter
|
||||
- [ ] Refine color tokens for the new warm editorial palette
|
||||
- [ ] Add styling for new home sections and settings tabs
|
||||
|
||||
## 2. Navigation and Routing
|
||||
|
||||
- [ ] Remove Sessions from top-level navigation
|
||||
- [ ] Keep SSH Keys, Tool Types, and Tool Configs accessible from Settings tabs
|
||||
- [ ] Add redirects for legacy top-level config routes
|
||||
- [ ] Redirect `/sessions` to `/`
|
||||
|
||||
## 3. Home Page
|
||||
|
||||
- [ ] Redesign the home page as an overview of open sessions and projects
|
||||
- [ ] Add summary cards and hero actions
|
||||
- [ ] Reuse existing session and project data
|
||||
- [ ] Keep create/open session actions available
|
||||
|
||||
## 4. Settings Hub
|
||||
|
||||
- [ ] Turn Settings into a tabbed hub
|
||||
- [ ] Build General, SSH Keys, Tool Types, and Tool Configs tabs
|
||||
- [ ] Reuse existing APIs and forms
|
||||
- [ ] Keep the Project settings page separate
|
||||
|
||||
## 5. Cleanup and Verification
|
||||
|
||||
- [ ] Remove obsolete top-level pages from navigation flow
|
||||
- [ ] Update tests for the new landing page and redirects
|
||||
- [ ] Run typecheck, lint, and build
|
||||
Reference in New Issue
Block a user