Compare commits
67 Commits
main
...
5cec4a7a6f
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cec4a7a6f | |||
| 7e0df57f8c | |||
| aea1ff95f6 | |||
| c8fdca7f60 | |||
| 014b88ee56 | |||
| b6bda3d692 | |||
| d7fb51f427 | |||
| 0d57e3501a | |||
| 3672312028 | |||
| c5f117e5b1 | |||
| 10a5c29702 | |||
| 312a646b89 | |||
| 01adc9a00f | |||
| 2e9ca52cdb | |||
| cd4eba9803 | |||
| 18646e3d1b | |||
| 8c5e1b931e | |||
| d1be2e4951 | |||
| 953ea05756 | |||
| 507b71c586 | |||
| aebcf25bf4 | |||
| ae42cac61e | |||
| e2ad7d7fb6 | |||
| 9e1334eb6d | |||
| c41993310b | |||
| cb25f21c44 | |||
| 392e85ead4 | |||
| a4bf8afac9 | |||
| a559470369 | |||
| 8cab17472e | |||
| e9d404b1ff | |||
| dab6c74046 | |||
| ca8b255148 | |||
| 02a2ad6df5 | |||
| 4ef0f108ea | |||
| dc8ef0e463 | |||
| 6a7657aeda | |||
| eca8b8815b | |||
| d0f7a97f92 | |||
| 765cb965e6 | |||
| 063a839790 | |||
| ae41a64e66 | |||
| 0901b1e832 | |||
| 7cc720786e | |||
| e167a6be12 | |||
| 0fa926284c | |||
| 5c17de0c3c | |||
| 8efadc4432 | |||
| 1e40540ef4 | |||
| 7cbbb41661 | |||
| 952a9f3234 | |||
| ab8872f79e | |||
| be4893e2a7 | |||
| b3c6a5fdc9 | |||
| b7d17cea78 | |||
| 36d6448f5f | |||
| 20a5f6a9a1 | |||
| 1c94583307 | |||
| 95a7454bee | |||
| 649496b762 | |||
| d13e16f5e1 | |||
| d5f9df33b7 | |||
| 468e0eacda | |||
| 2a9e57ad0d | |||
| e4c5e7f2db | |||
| 70957e462a | |||
| 684a11610a |
@@ -87,6 +87,31 @@ Do not claim completion without verification evidence.
|
|||||||
|
|
||||||
## Git workflow
|
## 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
|
### Auto-commit on spec completion
|
||||||
|
|
||||||
When an OpenSpec change is fully implemented and all tasks are complete:
|
When an OpenSpec change is fully implemented and all tasks are complete:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
git \
|
git \
|
||||||
|
openssh-client \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add probe_result to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0013_add_probe_result
|
||||||
|
Revises: 0012_default_port_req
|
||||||
|
Create Date: 2026-05-22 21:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0013_add_probe_result"
|
||||||
|
down_revision: Union[str, None] = "0012_default_port_req"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("probe_result", postgresql.JSON, nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "probe_result")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""merge migration heads
|
||||||
|
|
||||||
|
Revision ID: 0014_merge_heads
|
||||||
|
Revises: 0013_add_probe_result, 8ed7dd80973d
|
||||||
|
Create Date: 2026-05-22 21:50:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0014_merge_heads"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ("0013_add_probe_result", "8ed7dd80973d")
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""replace interfaces with interface_type and add requires_port
|
||||||
|
|
||||||
|
Revision ID: 0015_single_interface
|
||||||
|
Revises: 0014_merge_heads
|
||||||
|
Create Date: 2026-05-22 22:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0015_single_interface"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "0014_merge_heads"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dialect() -> str:
|
||||||
|
"""Get the current database dialect name."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
return conn.dialect.name
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
dialect = _get_dialect()
|
||||||
|
|
||||||
|
# Add new columns
|
||||||
|
op.add_column('tool_types', sa.Column('interface_type', sa.String(20), nullable=True))
|
||||||
|
op.add_column('tool_types', sa.Column('requires_port', sa.Boolean(), nullable=False, server_default='true'))
|
||||||
|
|
||||||
|
# Migrate data: take first element from interfaces JSON array
|
||||||
|
if dialect == 'postgresql':
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interface_type = COALESCE(
|
||||||
|
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
|
||||||
|
'web'
|
||||||
|
),
|
||||||
|
requires_port = CASE
|
||||||
|
WHEN COALESCE(
|
||||||
|
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
|
||||||
|
'web'
|
||||||
|
) = 'web' THEN true
|
||||||
|
ELSE false
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
# SQLite: interfaces is stored as JSON text, extract first array element
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interface_type = COALESCE(
|
||||||
|
(SELECT json_extract(value, '$[0]')
|
||||||
|
FROM json_each(interfaces) AS value
|
||||||
|
WHERE json_valid(interfaces)
|
||||||
|
LIMIT 1),
|
||||||
|
'web'
|
||||||
|
),
|
||||||
|
requires_port = CASE
|
||||||
|
WHEN COALESCE(
|
||||||
|
(SELECT json_extract(value, '$[0]')
|
||||||
|
FROM json_each(interfaces) AS value
|
||||||
|
WHERE json_valid(interfaces)
|
||||||
|
LIMIT 1),
|
||||||
|
'web'
|
||||||
|
) = 'web' THEN true
|
||||||
|
ELSE false
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Make interface_type non-nullable after data migration
|
||||||
|
op.alter_column('tool_types', 'interface_type', nullable=False)
|
||||||
|
|
||||||
|
# Drop old interfaces column
|
||||||
|
op.drop_column('tool_types', 'interfaces')
|
||||||
|
|
||||||
|
# Add CHECK constraint for interface_type (only on PostgreSQL; SQLite supports it too)
|
||||||
|
op.create_check_constraint('chk_interface_type', 'tool_types', sa.text("interface_type IN ('web', 'terminal')"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
dialect = _get_dialect()
|
||||||
|
|
||||||
|
# Drop CHECK constraint
|
||||||
|
op.drop_constraint('chk_interface_type', 'tool_types', type_='check')
|
||||||
|
|
||||||
|
# Add back interfaces column
|
||||||
|
if dialect == 'postgresql':
|
||||||
|
op.add_column('tool_types', sa.Column('interfaces', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='["web"]'))
|
||||||
|
|
||||||
|
# Migrate data back: wrap interface_type in array
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interfaces = jsonb_build_array(interface_type)
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
op.add_column('tool_types', sa.Column('interfaces', sa.JSON(), nullable=False, server_default='["web"]'))
|
||||||
|
|
||||||
|
# Migrate data back: wrap interface_type in array for SQLite
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interfaces = json_array(interface_type)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Drop new columns
|
||||||
|
op.drop_column('tool_types', 'requires_port')
|
||||||
|
op.drop_column('tool_types', 'interface_type')
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add_clone_mode_and_ssh_key_id
|
||||||
|
|
||||||
|
Revision ID: 2026_05_22_add_clone_mode
|
||||||
|
Revises: 0014_merge_heads
|
||||||
|
Create Date: 2026-05-22 20:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2026_05_22_add_clone_mode'
|
||||||
|
down_revision = '0015_single_interface'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add ssh_key_id to git_repositories
|
||||||
|
op.add_column('git_repositories', sa.Column('ssh_key_id', postgresql.UUID(), nullable=True))
|
||||||
|
op.create_foreign_key('fk_git_repositories_ssh_key', 'git_repositories', 'ssh_keys', ['ssh_key_id'], ['id'])
|
||||||
|
|
||||||
|
# Add clone_mode and branch to tool_instances
|
||||||
|
op.add_column('tool_instances', sa.Column('clone_mode', sa.String(20), nullable=False, server_default='mount'))
|
||||||
|
op.add_column('tool_instances', sa.Column('branch', sa.String(255), nullable=True, server_default='main'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop columns from tool_instances
|
||||||
|
op.drop_column('tool_instances', 'branch')
|
||||||
|
op.drop_column('tool_instances', 'clone_mode')
|
||||||
|
|
||||||
|
# Drop ssh_key_id from git_repositories
|
||||||
|
op.drop_constraint('fk_git_repositories_ssh_key', 'git_repositories', type_='foreignkey')
|
||||||
|
op.drop_column('git_repositories', 'ssh_key_id')
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""remove_is_builtin_from_tool_types
|
||||||
|
|
||||||
|
Revision ID: 2026_05_23_remove_is_builtin
|
||||||
|
Revises: 2026_05_22_add_clone_mode
|
||||||
|
Create Date: 2026-05-23 14:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2026_05_23_remove_is_builtin'
|
||||||
|
down_revision = '2026_05_22_add_clone_mode'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Drop the is_builtin column from tool_types
|
||||||
|
op.drop_column('tool_types', 'is_builtin')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Add the is_builtin column back to tool_types
|
||||||
|
op.add_column('tool_types', sa.Column('is_builtin', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
@@ -14,6 +14,7 @@ from src.auth.dependencies import get_current_user_id, get_db_session
|
|||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.utils.git_files import (
|
from src.utils.git_files import (
|
||||||
commit_file,
|
commit_file,
|
||||||
@@ -34,6 +35,7 @@ from src.utils.git_control import (
|
|||||||
)
|
)
|
||||||
from src.utils.git_history import get_commit_detail, get_commit_history
|
from src.utils.git_history import get_commit_detail, get_commit_history
|
||||||
from src.utils.git_url_parser import parse_git_url
|
from src.utils.git_url_parser import parse_git_url
|
||||||
|
from src.services.ssh_keys import _get_fernet
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||||
|
|
||||||
@@ -94,41 +96,97 @@ def _build_provider_clone_url(owner: str, repo: str) -> str:
|
|||||||
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
||||||
|
|
||||||
|
|
||||||
def _preflight_remote_repository(remote_url: str) -> None:
|
def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
|
||||||
|
"""Prepare environment variables for git commands with SSH authentication.
|
||||||
|
|
||||||
|
Returns a dict of extra env vars, or None if no SSH key provided.
|
||||||
|
The caller is responsible for cleaning up the temporary key file.
|
||||||
|
"""
|
||||||
|
if ssh_key is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Decrypt private key
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
# Write to temp file with restricted permissions
|
||||||
|
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||||
|
try:
|
||||||
|
os.write(fd, private_key.encode())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
|
||||||
|
# Return env vars and the key path for cleanup
|
||||||
|
env = {
|
||||||
|
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
}
|
||||||
|
return env, key_path
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
|
||||||
"""Verify a remote repository is reachable before cloning."""
|
"""Verify a remote repository is reachable before cloning."""
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
|
||||||
|
if ssh_key is not None:
|
||||||
|
ssh_result = _prepare_ssh_env(ssh_key)
|
||||||
|
if ssh_result:
|
||||||
|
env, key_path = ssh_result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "ls-remote", remote_url],
|
["git", "ls-remote", remote_url],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="repository not found or inaccessible",
|
detail=f"repository not found or inaccessible: {result.stderr}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
|
def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
|
||||||
|
if ssh_key is not None:
|
||||||
|
ssh_result = _prepare_ssh_env(ssh_key)
|
||||||
|
if ssh_result:
|
||||||
|
env, key_path = ssh_result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "clone", remote_url, repo_path],
|
["git", "clone", remote_url, repo_path],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"failed to clone repository: {result.stderr}",
|
detail=f"failed to clone repository: {result.stderr}",
|
||||||
@@ -175,6 +233,7 @@ class GitRepositoryCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
remote_url: str | None = None
|
remote_url: str | None = None
|
||||||
force_original_url: bool = False
|
force_original_url: bool = False
|
||||||
|
ssh_key_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class URLParseRequest(BaseModel):
|
class URLParseRequest(BaseModel):
|
||||||
@@ -202,6 +261,7 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
is_mirror: bool
|
is_mirror: bool
|
||||||
remote_url: str | None
|
remote_url: str | None
|
||||||
last_push: datetime | None
|
last_push: datetime | None
|
||||||
|
ssh_key_id: uuid.UUID | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -349,8 +409,23 @@ async def create_repository(
|
|||||||
if parse_result["base_url"]:
|
if parse_result["base_url"]:
|
||||||
remote_url = parse_result["base_url"]
|
remote_url = parse_result["base_url"]
|
||||||
|
|
||||||
|
# Validate SSH key if provided
|
||||||
|
ssh_key_id = None
|
||||||
|
ssh_key = None
|
||||||
|
if data.ssh_key_id:
|
||||||
|
try:
|
||||||
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||||
|
if ssh_key is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
_preflight_remote_repository(remote_url)
|
_preflight_remote_repository(remote_url, ssh_key)
|
||||||
|
|
||||||
repo_path = _get_repo_path(user_id, project_id, data.name)
|
repo_path = _get_repo_path(user_id, project_id, data.name)
|
||||||
|
|
||||||
@@ -358,7 +433,7 @@ async def create_repository(
|
|||||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
_clone_working_repository(remote_url, repo_path)
|
_clone_working_repository(remote_url, repo_path, ssh_key)
|
||||||
else:
|
else:
|
||||||
_init_working_repository(repo_path)
|
_init_working_repository(repo_path)
|
||||||
|
|
||||||
@@ -369,6 +444,7 @@ async def create_repository(
|
|||||||
owner_id=user_id,
|
owner_id=user_id,
|
||||||
is_mirror=False,
|
is_mirror=False,
|
||||||
remote_url=remote_url,
|
remote_url=remote_url,
|
||||||
|
ssh_key_id=ssh_key_id,
|
||||||
)
|
)
|
||||||
session.add(repo)
|
session.add(repo)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -376,6 +452,64 @@ async def create_repository(
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSSHKeyRequest(BaseModel):
|
||||||
|
ssh_key_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{project_id}/repositories/{repo_id}/ssh-key",
|
||||||
|
response_model=GitRepositoryResponse,
|
||||||
|
summary="Update repository SSH key",
|
||||||
|
description="Update the SSH key associated with a repository.",
|
||||||
|
)
|
||||||
|
async def update_repository_ssh_key(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: UpdateSSHKeyRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Update the SSH key for a repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
data: Update data containing the new SSH key ID.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated repository.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
repo = await session.get(GitRepository, repo_id)
|
||||||
|
if repo is None or repo.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||||
|
|
||||||
|
# Validate SSH key if provided
|
||||||
|
if data.ssh_key_id:
|
||||||
|
try:
|
||||||
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||||
|
if ssh_key is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
||||||
|
|
||||||
|
repo.ssh_key_id = ssh_key_id
|
||||||
|
else:
|
||||||
|
repo.ssh_key_id = None
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/repositories/{repo_id}/history",
|
"/{project_id}/repositories/{repo_id}/history",
|
||||||
summary="Get repository history",
|
summary="Get repository history",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -74,6 +75,23 @@ class SSHKeyResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SignPayloadRequest(BaseModel):
|
||||||
|
payload: str
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureResponse(BaseModel):
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySignatureRequest(BaseModel):
|
||||||
|
payload: str
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySignatureResponse(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=SSHKeyResponse,
|
response_model=SSHKeyResponse,
|
||||||
@@ -166,3 +184,80 @@ async def delete_ssh_key(
|
|||||||
|
|
||||||
await session.delete(ssh_key)
|
await session.delete(ssh_key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/sign",
|
||||||
|
response_model=SignatureResponse,
|
||||||
|
summary="Sign payload",
|
||||||
|
description="Sign a payload using the SSH private key.",
|
||||||
|
)
|
||||||
|
async def sign_payload(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: SignPayloadRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> SignatureResponse:
|
||||||
|
"""Sign a payload with an SSH key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for signing.
|
||||||
|
data: Sign request containing the payload string.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded Ed25519 signature.
|
||||||
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
private_key = serialization.load_ssh_private_key(
|
||||||
|
private_key_pem.encode(), password=None
|
||||||
|
)
|
||||||
|
|
||||||
|
signature = private_key.sign(data.payload.encode())
|
||||||
|
return SignatureResponse(signature=base64.b64encode(signature).decode())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/verify",
|
||||||
|
response_model=VerifySignatureResponse,
|
||||||
|
summary="Verify signature",
|
||||||
|
description="Verify a signature against a payload using the SSH public key.",
|
||||||
|
)
|
||||||
|
async def verify_signature(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: VerifySignatureRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> VerifySignatureResponse:
|
||||||
|
"""Verify a signature with an SSH key's public key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for verification.
|
||||||
|
data: Verify request containing payload and base64-encoded signature.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Whether the signature is valid.
|
||||||
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
|
||||||
|
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
||||||
|
|
||||||
|
try:
|
||||||
|
signature = base64.b64decode(data.signature)
|
||||||
|
public_key.verify(signature, data.payload.encode())
|
||||||
|
return VerifySignatureResponse(valid=True)
|
||||||
|
except Exception:
|
||||||
|
return VerifySignatureResponse(valid=False)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ from src.auth.dependencies import get_current_user_id
|
|||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.tool_config import ToolConfig
|
from src.models.tool_config import ToolConfig
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
@@ -30,18 +32,23 @@ from src.services.docker import (
|
|||||||
execute_compose_command,
|
execute_compose_command,
|
||||||
find_free_port,
|
find_free_port,
|
||||||
get_container_id,
|
get_container_id,
|
||||||
|
get_container_logs,
|
||||||
get_container_name,
|
get_container_name,
|
||||||
|
get_container_status,
|
||||||
recreate_tunnel,
|
recreate_tunnel,
|
||||||
render_compose_template,
|
render_compose_template,
|
||||||
start_cloudflared_tunnel,
|
start_cloudflared_tunnel,
|
||||||
stop_cloudflared_tunnel,
|
stop_cloudflared_tunnel,
|
||||||
|
wait_for_container_running,
|
||||||
write_compose_file,
|
write_compose_file,
|
||||||
write_config_files,
|
write_config_files,
|
||||||
write_env_file,
|
write_env_file,
|
||||||
write_config_folder_files,
|
write_config_folder_files,
|
||||||
)
|
)
|
||||||
|
from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory
|
||||||
from src.services.docker_build import build_image
|
from src.services.docker_build import build_image
|
||||||
from src.services.readiness_probe import execute_probe
|
from src.services.readiness_probe import execute_probe
|
||||||
|
from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||||
|
|
||||||
@@ -53,6 +60,9 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
|
|
||||||
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||||
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
||||||
|
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
|
||||||
|
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')")
|
||||||
|
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning")
|
||||||
|
|
||||||
|
|
||||||
def _modify_compose_file(
|
def _modify_compose_file(
|
||||||
@@ -190,6 +200,19 @@ async def create_instance(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Validate clone mode requirements
|
||||||
|
if data.clone_mode == "clone":
|
||||||
|
if not repo.remote_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="repository does not have a remote URL for cloning"
|
||||||
|
)
|
||||||
|
if not repo.ssh_key_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="repository must have an SSH key assigned for clone mode"
|
||||||
|
)
|
||||||
|
|
||||||
# Generate unique name
|
# Generate unique name
|
||||||
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
||||||
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}"
|
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}"
|
||||||
@@ -201,6 +224,59 @@ async def create_instance(
|
|||||||
# Find free port
|
# Find free port
|
||||||
tool_port = find_free_port()
|
tool_port = find_free_port()
|
||||||
|
|
||||||
|
# Determine repo path based on clone mode
|
||||||
|
if data.clone_mode == "clone":
|
||||||
|
# Get SSH key for cloning
|
||||||
|
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
||||||
|
if ssh_key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="repository SSH key not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepare SSH key for clone operation
|
||||||
|
ssh_key_path = None
|
||||||
|
try:
|
||||||
|
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
|
||||||
|
ssh_key_path = os.path.join(ssh_dir, "id_ed25519")
|
||||||
|
|
||||||
|
# Clone repository
|
||||||
|
clone_path = clone_repository(
|
||||||
|
remote_url=repo.remote_url,
|
||||||
|
ssh_key_path=ssh_key_path,
|
||||||
|
instance_dir=instance_dir,
|
||||||
|
branch=data.branch or "main",
|
||||||
|
)
|
||||||
|
repo_path = clone_path
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to clone repository: %s", exc)
|
||||||
|
cleanup_ssh_key_files(instance_dir)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to clone repository: {exc}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
repo_path = repo.path
|
||||||
|
|
||||||
|
# Create new local branch if requested
|
||||||
|
if data.clone_mode == "clone" and data.new_branch:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr)
|
||||||
|
raise RuntimeError(f"Failed to create branch: {result.stderr}")
|
||||||
|
logger.info("Created local branch %s in cloned repository", data.new_branch)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to create local branch: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create local branch: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
# Handle based on definition type
|
# Handle based on definition type
|
||||||
if tool_type.definition_type == "dockerfile":
|
if tool_type.definition_type == "dockerfile":
|
||||||
# Build image from Dockerfile
|
# Build image from Dockerfile
|
||||||
@@ -232,7 +308,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "{tool_port}:{tool_type.default_port}"
|
- "{tool_port}:{tool_type.default_port}"
|
||||||
volumes:
|
volumes:
|
||||||
- {repo.path}:/workspace
|
- {repo_path}:/workspace
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
"""
|
"""
|
||||||
write_compose_file(instance_dir, compose_content)
|
write_compose_file(instance_dir, compose_content)
|
||||||
@@ -240,7 +316,7 @@ services:
|
|||||||
else:
|
else:
|
||||||
# Render compose template
|
# Render compose template
|
||||||
variables = {
|
variables = {
|
||||||
"REPO_PATH": repo.path,
|
"REPO_PATH": repo_path,
|
||||||
"INSTANCE_NAME": instance_name,
|
"INSTANCE_NAME": instance_name,
|
||||||
"INSTANCE_ID": instance_name,
|
"INSTANCE_ID": instance_name,
|
||||||
"TOOL_NAME": instance_name,
|
"TOOL_NAME": instance_name,
|
||||||
@@ -262,6 +338,8 @@ services:
|
|||||||
status="pending",
|
status="pending",
|
||||||
compose_path=compose_path,
|
compose_path=compose_path,
|
||||||
port=tool_port,
|
port=tool_port,
|
||||||
|
clone_mode=data.clone_mode,
|
||||||
|
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None),
|
||||||
)
|
)
|
||||||
session.add(instance)
|
session.add(instance)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -273,6 +351,8 @@ services:
|
|||||||
"display_name": instance.display_name,
|
"display_name": instance.display_name,
|
||||||
"tool_type_id": str(instance.tool_type_id),
|
"tool_type_id": str(instance.tool_type_id),
|
||||||
"status": instance.status,
|
"status": instance.status,
|
||||||
|
"clone_mode": instance.clone_mode,
|
||||||
|
"branch": instance.branch,
|
||||||
"created_at": instance.created_at.isoformat(),
|
"created_at": instance.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -331,10 +411,12 @@ async def list_instances(
|
|||||||
"display_name": i.display_name,
|
"display_name": i.display_name,
|
||||||
"tool_type_id": str(i.tool_type_id),
|
"tool_type_id": str(i.tool_type_id),
|
||||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||||
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
|
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
|
||||||
"status": i.status,
|
"status": i.status,
|
||||||
"url": i.url,
|
"url": i.url,
|
||||||
"port": i.port,
|
"port": i.port,
|
||||||
|
"clone_mode": i.clone_mode,
|
||||||
|
"branch": i.branch,
|
||||||
"created_at": i.created_at.isoformat(),
|
"created_at": i.created_at.isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -395,6 +477,8 @@ async def get_instance(
|
|||||||
"compose_path": instance.compose_path,
|
"compose_path": instance.compose_path,
|
||||||
"url": instance.url,
|
"url": instance.url,
|
||||||
"port": instance.port,
|
"port": instance.port,
|
||||||
|
"clone_mode": instance.clone_mode,
|
||||||
|
"branch": instance.branch,
|
||||||
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
||||||
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
|
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
|
||||||
"created_at": instance.created_at.isoformat(),
|
"created_at": instance.created_at.isoformat(),
|
||||||
@@ -511,6 +595,23 @@ async def start_instance(
|
|||||||
extra_volumes.extend(folder_volumes)
|
extra_volumes.extend(folder_volumes)
|
||||||
logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id)
|
logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id)
|
||||||
|
|
||||||
|
# Mount SSH key for clone-mode instances
|
||||||
|
if instance.clone_mode == "clone":
|
||||||
|
repo = await session.get(GitRepository, instance.repository_id)
|
||||||
|
if repo and repo.ssh_key_id:
|
||||||
|
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
||||||
|
if ssh_key:
|
||||||
|
try:
|
||||||
|
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
|
||||||
|
extra_volumes.append({
|
||||||
|
"source": ssh_dir,
|
||||||
|
"target": "/root/.ssh",
|
||||||
|
"type": "ro",
|
||||||
|
})
|
||||||
|
logger.info("Mounted SSH key for clone-mode instance %s", instance.id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to prepare SSH key for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
# Modify compose file if needed (port override, start command, working dir, volumes)
|
# Modify compose file if needed (port override, start command, working dir, volumes)
|
||||||
if port_override or start_command or working_directory or extra_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)
|
_modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes)
|
||||||
@@ -552,20 +653,67 @@ async def start_instance(
|
|||||||
else:
|
else:
|
||||||
logger.warning("Failed to connect %s to backend network", container_name)
|
logger.warning("Failed to connect %s to backend network", container_name)
|
||||||
|
|
||||||
|
# Verify container reached running state
|
||||||
|
if instance.container_id:
|
||||||
instance.status = "starting"
|
instance.status = "starting"
|
||||||
instance.last_started_at = datetime.now()
|
instance.last_started_at = datetime.now()
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info("Instance %s container is running, checking readiness", instance.id)
|
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
|
# Execute readiness probe if configured
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
if tool_type and tool_type.readiness_probe:
|
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_config = tool_type.readiness_probe
|
||||||
probe_command = probe_config.get("command", "")
|
probe_command = probe_config.get("command", "")
|
||||||
probe_timeout = probe_config.get("timeout", 30)
|
probe_timeout = probe_config.get("timeout", 30)
|
||||||
probe_interval = probe_config.get("interval", 2)
|
probe_interval = probe_config.get("interval", 2)
|
||||||
|
elif tool_type.interface_type == "web":
|
||||||
|
# Default probe for web tools
|
||||||
|
probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
|
||||||
|
probe_timeout = 30
|
||||||
|
probe_interval = 2
|
||||||
|
|
||||||
if probe_command and instance.container_id:
|
if probe_command:
|
||||||
|
instance.status = "probing"
|
||||||
|
await session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
|
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
|
||||||
instance.id, probe_command, probe_timeout, probe_interval
|
instance.id, probe_command, probe_timeout, probe_interval
|
||||||
@@ -578,14 +726,25 @@ async def start_instance(
|
|||||||
interval=probe_interval,
|
interval=probe_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Store probe result
|
||||||
|
instance.probe_result = {
|
||||||
|
"success": success,
|
||||||
|
"command": probe_command,
|
||||||
|
"logs": probe_logs,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
instance.status = "failed"
|
instance.status = "unhealthy"
|
||||||
instance.url = None
|
|
||||||
instance.public_url = None
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.error("Readiness probe failed for instance %s: %s", instance.id, "\n".join(probe_logs))
|
logger.error(
|
||||||
|
"Readiness probe failed for instance %s after %ds: %s",
|
||||||
|
instance.id,
|
||||||
|
probe_timeout,
|
||||||
|
"\n".join(probe_logs),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "failed",
|
"status": "unhealthy",
|
||||||
"error": f"Readiness probe failed after {probe_timeout}s",
|
"error": f"Readiness probe failed after {probe_timeout}s",
|
||||||
"probe_logs": probe_logs,
|
"probe_logs": probe_logs,
|
||||||
}
|
}
|
||||||
@@ -598,22 +757,21 @@ async def start_instance(
|
|||||||
|
|
||||||
# Get tool type for default port
|
# Get tool type for default port
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
if not tool_type or not tool_type.default_port:
|
if not tool_type:
|
||||||
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.",
|
logger.error("Tool type %s not found", instance.tool_type_id)
|
||||||
instance.tool_type_id)
|
|
||||||
instance.status = "error"
|
instance.status = "error"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
|
"error": f"Tool type '{instance.tool_type_id}' not found",
|
||||||
}
|
}
|
||||||
|
|
||||||
instance_port = tool_type.default_port
|
instance_port = tool_type.default_port or 0
|
||||||
logger.info("Tool type for instance %s: name=%s, default_port=%s, interfaces=%s",
|
logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
|
||||||
instance.id, tool_type.name, instance_port, tool_type.interfaces)
|
instance.id, tool_type.name, instance_port, tool_type.interface_type)
|
||||||
|
|
||||||
# Only create Cloudflare tunnel for web-enabled tools
|
# Only create Cloudflare tunnel for web-enabled tools
|
||||||
if "web" in tool_type.interfaces:
|
if tool_type.interface_type == "web":
|
||||||
# Create temporary Cloudflare tunnel for public access
|
# Create temporary Cloudflare tunnel for public access
|
||||||
try:
|
try:
|
||||||
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)",
|
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)",
|
||||||
@@ -778,7 +936,7 @@ async def restart_instance(
|
|||||||
instance_port = tool_type.default_port
|
instance_port = tool_type.default_port
|
||||||
|
|
||||||
# Only create tunnel for web-enabled tools
|
# Only create tunnel for web-enabled tools
|
||||||
if "web" in tool_type.interfaces:
|
if tool_type.interface_type == "web":
|
||||||
# Create new temporary tunnel
|
# Create new temporary tunnel
|
||||||
try:
|
try:
|
||||||
tunnel_info = start_cloudflared_tunnel(
|
tunnel_info = start_cloudflared_tunnel(
|
||||||
@@ -828,6 +986,7 @@ async def delete_instance(
|
|||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
|
force: bool = False,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -852,6 +1011,23 @@ async def delete_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check dirty state for clone-mode instances
|
||||||
|
if instance.clone_mode == "clone" and not force:
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path) if instance.compose_path else None
|
||||||
|
if instance_dir:
|
||||||
|
clone_path = os.path.join(instance_dir, "repo-clone")
|
||||||
|
if os.path.exists(clone_path):
|
||||||
|
is_dirty, changed_files = check_dirty_state(clone_path)
|
||||||
|
if is_dirty:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": "Repository has uncommitted changes",
|
||||||
|
"changed_files": changed_files,
|
||||||
|
"force_required": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Stop Cloudflare tunnel if exists
|
# Stop Cloudflare tunnel if exists
|
||||||
if instance.tunnel_id:
|
if instance.tunnel_id:
|
||||||
try:
|
try:
|
||||||
@@ -864,7 +1040,7 @@ async def delete_instance(
|
|||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
execute_compose_command(instance.compose_path, "down")
|
execute_compose_command(instance.compose_path, "down")
|
||||||
|
|
||||||
# Remove instance directory
|
# Remove instance directory (includes clone and SSH keys)
|
||||||
if instance.compose_path:
|
if instance.compose_path:
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
if os.path.exists(instance_dir):
|
if os.path.exists(instance_dir):
|
||||||
@@ -956,6 +1132,17 @@ async def recreate_tunnel_endpoint(
|
|||||||
detail="instance must be running to recreate tunnel",
|
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
|
# Get tool type for default port
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
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
|
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
|
||||||
@@ -987,8 +1174,8 @@ async def recreate_tunnel_endpoint(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
|
||||||
summary="Check tunnel health",
|
summary="Check instance health",
|
||||||
description="Check if the temporary Cloudflare tunnel for an instance is healthy.",
|
description="Check container and tunnel health for an instance.",
|
||||||
)
|
)
|
||||||
async def check_instance_tunnel_health(
|
async def check_instance_tunnel_health(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
@@ -997,7 +1184,7 @@ async def check_instance_tunnel_health(
|
|||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Check tunnel health for an instance.
|
"""Check health for an instance (container + tunnel).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_id: UUID of the project.
|
project_id: UUID of the project.
|
||||||
@@ -1007,7 +1194,7 @@ async def check_instance_tunnel_health(
|
|||||||
session: Database session.
|
session: Database session.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with health status.
|
Dictionary with container_status, tunnel_status, probe_status, and overall healthy flag.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
@@ -1018,11 +1205,54 @@ async def check_instance_tunnel_health(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not instance.url or instance.status != "running":
|
# Check container status
|
||||||
return {"healthy": False, "status_code": None, "error": "instance not running"}
|
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)
|
# Build response
|
||||||
return health
|
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", []))
|
||||||
|
|
||||||
|
# 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: web tools need running container + healthy tunnel;
|
||||||
|
# terminal tools only need running container
|
||||||
|
container_healthy = container_info["status"] == "running"
|
||||||
|
if instance.url:
|
||||||
|
tunnel_healthy = response["tunnel_status"] == "healthy"
|
||||||
|
response["healthy"] = container_healthy and tunnel_healthy
|
||||||
|
else:
|
||||||
|
response["healthy"] = container_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(
|
@router.get(
|
||||||
@@ -1198,13 +1428,15 @@ async def get_user_sessions(
|
|||||||
"display_name": instance.display_name,
|
"display_name": instance.display_name,
|
||||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||||
"tool_icon": tool_type.name if tool_type else "code",
|
"tool_icon": tool_type.name if tool_type else "code",
|
||||||
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
|
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
|
||||||
"repository_name": repo.name if repo else "unknown",
|
"repository_name": repo.name if repo else "unknown",
|
||||||
"repository_id": str(instance.repository_id),
|
"repository_id": str(instance.repository_id),
|
||||||
"project_name": project.name if project else "unknown",
|
"project_name": project.name if project else "unknown",
|
||||||
"project_id": str(instance.project_id),
|
"project_id": str(instance.project_id),
|
||||||
"status": instance.status,
|
"status": instance.status,
|
||||||
"url": instance.url,
|
"url": instance.url,
|
||||||
|
"clone_mode": instance.clone_mode,
|
||||||
|
"branch": instance.branch,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"sessions": sessions}
|
return {"sessions": sessions}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -7,6 +8,11 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_template_vars(template: str) -> str:
|
||||||
|
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
|
||||||
|
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
@@ -37,7 +43,7 @@ class ToolTypeCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_port: int
|
default_port: int = 0
|
||||||
definition_type: str = "compose"
|
definition_type: str = "compose"
|
||||||
compose_template: str | None = None
|
compose_template: str | None = None
|
||||||
dockerfile_template: str | None = None
|
dockerfile_template: str | None = None
|
||||||
@@ -45,7 +51,8 @@ class ToolTypeCreate(BaseModel):
|
|||||||
readiness_probe: dict | None = None
|
readiness_probe: dict | None = None
|
||||||
required_variables: list[str] = []
|
required_variables: list[str] = []
|
||||||
category: str = "other"
|
category: str = "other"
|
||||||
interfaces: list[str] = ["web"]
|
interface_type: str = "web"
|
||||||
|
requires_port: bool = True
|
||||||
|
|
||||||
@field_validator("definition_type")
|
@field_validator("definition_type")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -64,8 +71,12 @@ class ToolTypeCreate(BaseModel):
|
|||||||
if v is None:
|
if v is None:
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
|
||||||
|
# Replace template variables with dummy values before YAML validation
|
||||||
|
# to avoid YAML parsing errors with {{VAR}} syntax
|
||||||
|
sanitized = _sanitize_template_vars(v)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(v)
|
parsed = yaml.safe_load(sanitized)
|
||||||
except yaml.YAMLError as e:
|
except yaml.YAMLError as e:
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
raise ValueError(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
@@ -95,48 +106,22 @@ class ToolTypeCreate(BaseModel):
|
|||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("interface_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_interface_type(cls, v: str) -> str:
|
||||||
|
if v not in ("web", "terminal"):
|
||||||
|
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("default_port")
|
@field_validator("default_port")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_default_port(cls, v: int, info) -> int:
|
def validate_default_port(cls, v: int, info) -> int:
|
||||||
|
data = info.data
|
||||||
|
requires_port = data.get("requires_port", True)
|
||||||
|
if not requires_port:
|
||||||
|
return v
|
||||||
if v <= 0 or v > 65535:
|
if v <= 0 or v > 65535:
|
||||||
raise ValueError("Port must be between 1 and 65535")
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
|
||||||
# Get compose_template from the model data
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "compose":
|
|
||||||
return v
|
|
||||||
|
|
||||||
template = data.get("compose_template")
|
|
||||||
if not template:
|
|
||||||
return v
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(template)
|
|
||||||
except yaml.YAMLError:
|
|
||||||
return v
|
|
||||||
|
|
||||||
# Check if the port is exposed in any service
|
|
||||||
port_str = str(v)
|
|
||||||
port_exposed = False
|
|
||||||
|
|
||||||
if isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
for service_name, service_config in parsed["services"].items():
|
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if isinstance(port_mapping, str):
|
|
||||||
# Format: "8443:8443" or "8443"
|
|
||||||
if port_str in port_mapping:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif isinstance(port_mapping, int) and port_mapping == v:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not port_exposed:
|
|
||||||
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
|
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("required_variables")
|
@field_validator("required_variables")
|
||||||
@@ -166,6 +151,35 @@ class ToolTypeCreate(BaseModel):
|
|||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
if self.definition_type == "compose" and self.compose_template is None:
|
if self.definition_type == "compose" and self.compose_template is None:
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
|
||||||
|
# Validate that default_port is exposed in compose template (only if requires_port)
|
||||||
|
if self.requires_port and self.definition_type == "compose" and self.compose_template:
|
||||||
|
try:
|
||||||
|
sanitized = _sanitize_template_vars(self.compose_template)
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return self
|
||||||
|
|
||||||
|
port_str = str(self.default_port)
|
||||||
|
port_exposed = False
|
||||||
|
|
||||||
|
if isinstance(parsed, dict) and "services" in parsed:
|
||||||
|
for service_name, service_config in parsed["services"].items():
|
||||||
|
if isinstance(service_config, dict) and "ports" in service_config:
|
||||||
|
for port_mapping in service_config["ports"]:
|
||||||
|
if isinstance(port_mapping, str):
|
||||||
|
if port_str in port_mapping:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
elif isinstance(port_mapping, int) and port_mapping == self.default_port:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
if port_exposed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not port_exposed:
|
||||||
|
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -180,7 +194,8 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
readiness_probe: dict | None = None
|
readiness_probe: dict | None = None
|
||||||
required_variables: list[str] | None = None
|
required_variables: list[str] | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
interfaces: list[str] | None = None
|
interface_type: str | None = None
|
||||||
|
requires_port: bool | None = None
|
||||||
|
|
||||||
@field_validator("definition_type")
|
@field_validator("definition_type")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -191,6 +206,15 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("interface_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_interface_type(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v not in ("web", "terminal"):
|
||||||
|
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
@@ -202,8 +226,11 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
if definition_type and definition_type != "compose":
|
if definition_type and definition_type != "compose":
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
# Replace template variables with dummy values before YAML validation
|
||||||
|
sanitized = _sanitize_template_vars(v)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(v)
|
parsed = yaml.safe_load(sanitized)
|
||||||
except yaml.YAMLError as e:
|
except yaml.YAMLError as e:
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
raise ValueError(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
@@ -243,7 +270,8 @@ class ToolTypeResponse(BaseModel):
|
|||||||
display_name: str
|
display_name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
category: str
|
category: str
|
||||||
interfaces: list[str]
|
interface_type: str
|
||||||
|
requires_port: bool
|
||||||
default_port: int
|
default_port: int
|
||||||
definition_type: str
|
definition_type: str
|
||||||
compose_template: str | None
|
compose_template: str | None
|
||||||
@@ -251,7 +279,6 @@ class ToolTypeResponse(BaseModel):
|
|||||||
build_context: dict | None
|
build_context: dict | None
|
||||||
readiness_probe: dict | None
|
readiness_probe: dict | None
|
||||||
required_variables: list[str]
|
required_variables: list[str]
|
||||||
is_builtin: bool
|
|
||||||
created_by_id: uuid.UUID | None
|
created_by_id: uuid.UUID | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -299,8 +326,8 @@ async def create_tool_type(
|
|||||||
readiness_probe=data.readiness_probe,
|
readiness_probe=data.readiness_probe,
|
||||||
required_variables=data.required_variables,
|
required_variables=data.required_variables,
|
||||||
category=data.category,
|
category=data.category,
|
||||||
interfaces=data.interfaces,
|
interface_type=data.interface_type,
|
||||||
is_builtin=False,
|
requires_port=data.requires_port,
|
||||||
created_by_id=user.id,
|
created_by_id=user.id,
|
||||||
)
|
)
|
||||||
session.add(tool_type)
|
session.add(tool_type)
|
||||||
@@ -391,13 +418,13 @@ async def update_tool_type(
|
|||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be modified
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
|
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Validate port if being updated
|
# Validate port if being updated
|
||||||
if "default_port" in update_data:
|
requires_port = update_data.get("requires_port", tool_type.requires_port)
|
||||||
|
if "default_port" in update_data and requires_port:
|
||||||
new_port = update_data["default_port"]
|
new_port = update_data["default_port"]
|
||||||
if new_port <= 0 or new_port > 65535:
|
if new_port <= 0 or new_port > 65535:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -411,7 +438,8 @@ async def update_tool_type(
|
|||||||
template = update_data.get("compose_template", tool_type.compose_template)
|
template = update_data.get("compose_template", tool_type.compose_template)
|
||||||
if template:
|
if template:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(template)
|
sanitized = _sanitize_template_vars(template)
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
except yaml.YAMLError:
|
except yaml.YAMLError:
|
||||||
parsed = None
|
parsed = None
|
||||||
|
|
||||||
@@ -502,7 +530,8 @@ async def validate_tool_type_template(
|
|||||||
errors.append("Compose template is required")
|
errors.append("Compose template is required")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(data.compose_template)
|
sanitized = _sanitize_template_vars(data.compose_template)
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
if not isinstance(parsed, dict):
|
if not isinstance(parsed, dict):
|
||||||
errors.append("Compose template must be a YAML mapping")
|
errors.append("Compose template must be a YAML mapping")
|
||||||
elif "services" not in parsed:
|
elif "services" not in parsed:
|
||||||
@@ -559,7 +588,8 @@ async def validate_tool_type(
|
|||||||
errors.append("Compose template is empty")
|
errors.append("Compose template is empty")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(tool_type.compose_template)
|
sanitized = _sanitize_template_vars(tool_type.compose_template)
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
if not isinstance(parsed, dict):
|
if not isinstance(parsed, dict):
|
||||||
errors.append("Compose template must be a YAML mapping")
|
errors.append("Compose template must be a YAML mapping")
|
||||||
elif "services" not in parsed:
|
elif "services" not in parsed:
|
||||||
@@ -609,8 +639,7 @@ async def delete_tool_type(
|
|||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be deleted
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
|
|
||||||
|
|
||||||
await session.delete(tool_type)
|
await session.delete(tool_type)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
+2
-154
@@ -7,7 +7,7 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
from src.api.dashboard import router as dashboard_router
|
||||||
@@ -25,13 +25,12 @@ from src.api.tool_types import router as tool_types_router
|
|||||||
from src.api.user_config import router as user_config_router
|
from src.api.user_config import router as user_config_router
|
||||||
from src.api.users import router as users_router
|
from src.api.users import router as users_router
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal, init_database
|
from src.database import init_database
|
||||||
from src.logging_config import (
|
from src.logging_config import (
|
||||||
ExceptionLoggingMiddleware,
|
ExceptionLoggingMiddleware,
|
||||||
RequestLoggingMiddleware,
|
RequestLoggingMiddleware,
|
||||||
configure_logging,
|
configure_logging,
|
||||||
)
|
)
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
|
|
||||||
# Configure logging early
|
# Configure logging early
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
@@ -103,155 +102,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _table_exists(session, table_name: str) -> bool:
|
|
||||||
"""Check if a table exists in the database."""
|
|
||||||
try:
|
|
||||||
result = await session.execute(
|
|
||||||
text("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = :table_name
|
|
||||||
)
|
|
||||||
"""),
|
|
||||||
{"table_name": table_name},
|
|
||||||
)
|
|
||||||
return result.scalar() or False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def seed_builtin_tool_types():
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
# Check if tool_types table exists before attempting to seed
|
|
||||||
if not await _table_exists(session, "tool_types"):
|
|
||||||
logger.warning(
|
|
||||||
"tool_types table does not exist. Skipping seeding. "
|
|
||||||
"Migrations may not have run yet."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
builtin_types = [
|
|
||||||
{
|
|
||||||
"name": "code-server",
|
|
||||||
"display_name": "VS Code Server",
|
|
||||||
"description": "VS Code running in the browser via code-server",
|
|
||||||
"category": "editor",
|
|
||||||
"interfaces": ["web"],
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
code-server:
|
|
||||||
image: lscr.io/linuxserver/code-server:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- PUID=1000
|
|
||||||
- PGID=1000
|
|
||||||
- TZ=Europe/London
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/config/workspace
|
|
||||||
ports:
|
|
||||||
- "8443:8443"
|
|
||||||
restart: unless-stopped""",
|
|
||||||
"default_port": 8443,
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "jupyter-notebook",
|
|
||||||
"display_name": "Jupyter Notebook",
|
|
||||||
"description": "Jupyter Lab for interactive development",
|
|
||||||
"category": "notebook",
|
|
||||||
"interfaces": ["web"],
|
|
||||||
"default_port": 8888,
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
jupyter:
|
|
||||||
image: jupyter/scipy-notebook:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- JUPYTER_ENABLE_LAB=yes
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/home/jovyan/work
|
|
||||||
ports:
|
|
||||||
- "8888:8888"
|
|
||||||
restart: unless-stopped""",
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "opencode",
|
|
||||||
"display_name": "OpenCode",
|
|
||||||
"description": "AI coding assistant - run opencode in terminal",
|
|
||||||
"category": "ai-assistant",
|
|
||||||
"interfaces": ["terminal"],
|
|
||||||
"default_port": 3000,
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
opencode:
|
|
||||||
image: node:20-slim
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
working_dir: /workspace
|
|
||||||
environment:
|
|
||||||
- HOME=/tmp
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/workspace
|
|
||||||
- opencode_home:/tmp
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
command: >
|
|
||||||
sh -c "set -x &&
|
|
||||||
apt-get update && apt-get install -y git ca-certificates &&
|
|
||||||
echo 'Installing opencode...' &&
|
|
||||||
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
|
||||||
which opencode || echo 'ERROR: opencode not in PATH' &&
|
|
||||||
npm bin -g &&
|
|
||||||
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
|
||||||
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
|
|
||||||
echo 'cd /workspace' >> /root/.bashrc &&
|
|
||||||
echo 'OpenCode installation complete' &&
|
|
||||||
cd /workspace &&
|
|
||||||
exec tail -f /dev/null"
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
opencode_home:""",
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for tool_data in builtin_types:
|
|
||||||
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
|
||||||
if not existing:
|
|
||||||
tool_type = ToolType(
|
|
||||||
name=tool_data["name"],
|
|
||||||
display_name=tool_data["display_name"],
|
|
||||||
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"),
|
|
||||||
is_builtin=True,
|
|
||||||
)
|
|
||||||
session.add(tool_type)
|
|
||||||
logger.info("Created built-in tool type: %s", tool_data["name"])
|
|
||||||
else:
|
|
||||||
# Update existing built-in tool types to reflect code changes
|
|
||||||
existing.display_name = tool_data["display_name"]
|
|
||||||
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")
|
|
||||||
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
logger.info("Built-in tool types seeded successfully.")
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def on_startup():
|
async def on_startup():
|
||||||
logger.info("Starting up Headquarter API...")
|
logger.info("Starting up Headquarter API...")
|
||||||
@@ -263,8 +113,6 @@ async def on_startup():
|
|||||||
import sys
|
import sys
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Seed built-in data
|
|
||||||
await seed_builtin_tool_types()
|
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("ssh_keys.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
project: Mapped["Project"] = relationship(back_populates="repositories")
|
project: Mapped["Project"] = relationship(back_populates="repositories")
|
||||||
owner: Mapped["User"] = relationship()
|
owner: Mapped["User"] = relationship()
|
||||||
|
ssh_key: Mapped["SSHKey | None"] = relationship()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
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 import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -62,6 +62,15 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
probe_result: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, nullable=True
|
||||||
|
)
|
||||||
|
clone_mode: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="mount"
|
||||||
|
)
|
||||||
|
branch: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True, default="main"
|
||||||
|
)
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||||
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
|
||||||
|
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
default_port: Mapped[int] = mapped_column(nullable=False)
|
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||||
definition_type: Mapped[str] = mapped_column(
|
definition_type: Mapped[str] = mapped_column(
|
||||||
String(20), nullable=False, default="compose"
|
String(20), nullable=False, default="compose"
|
||||||
@@ -30,7 +31,6 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
)
|
)
|
||||||
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
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(
|
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(),
|
UUID(),
|
||||||
ForeignKey("users.id"),
|
ForeignKey("users.id"),
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Clone service for repository cloning and dirty state checking."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def clone_repository(
|
||||||
|
remote_url: str,
|
||||||
|
ssh_key_path: str | None,
|
||||||
|
instance_dir: str,
|
||||||
|
branch: str = "main",
|
||||||
|
) -> str:
|
||||||
|
"""Clone a git repository into the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
remote_url: Git remote URL (SSH or HTTPS)
|
||||||
|
ssh_key_path: Path to SSH private key for authentication (optional)
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
branch: Branch to clone (default: main)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the cloned repository
|
||||||
|
"""
|
||||||
|
clone_path = Path(instance_dir) / "repo-clone"
|
||||||
|
clone_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
if ssh_key_path:
|
||||||
|
# Use SSH key for cloning
|
||||||
|
env["GIT_SSH_COMMAND"] = f"ssh -i {ssh_key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--branch", branch,
|
||||||
|
"--single-branch",
|
||||||
|
remote_url,
|
||||||
|
str(clone_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Git clone failed: %s", result.stderr)
|
||||||
|
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
||||||
|
|
||||||
|
logger.info("Successfully cloned repository into %s", clone_path)
|
||||||
|
return str(clone_path)
|
||||||
|
|
||||||
|
|
||||||
|
def check_dirty_state(clone_path: str) -> tuple[bool, list[str]]:
|
||||||
|
"""Check for uncommitted changes in a cloned repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
clone_path: Path to the cloned repository
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_dirty, list_of_changed_files)
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", clone_path, "status", "--short"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning("Failed to check git status: %s", result.stderr)
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
changed_files = [line.strip() for line in result.stdout.split("\n") if line.strip()]
|
||||||
|
is_dirty = len(changed_files) > 0
|
||||||
|
|
||||||
|
return is_dirty, changed_files
|
||||||
|
|
||||||
|
|
||||||
|
def remove_clone_directory(instance_dir: str) -> None:
|
||||||
|
"""Remove the cloned repository from the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
"""
|
||||||
|
clone_path = Path(instance_dir) / "repo-clone"
|
||||||
|
if clone_path.exists():
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(clone_path)
|
||||||
|
logger.info("Removed clone directory: %s", clone_path)
|
||||||
+117
-10
@@ -244,24 +244,94 @@ def connect_container_to_network(container_name: str, network_name: str = "backe
|
|||||||
return result.returncode == 0
|
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.
|
"""Get the status of a Docker container.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
container_id: Docker container ID
|
container_id: Docker container ID
|
||||||
|
|
||||||
Returns:
|
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(
|
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,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode != 0:
|
||||||
return result.stdout.strip()
|
return {"status": "not_found", "exit_code": None, "health": None}
|
||||||
return "unknown"
|
|
||||||
|
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:
|
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||||
@@ -424,14 +494,15 @@ def recreate_tunnel(
|
|||||||
|
|
||||||
|
|
||||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
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:
|
Args:
|
||||||
url: The tunnel URL to check
|
url: The tunnel URL to check
|
||||||
timeout: Request timeout in seconds
|
timeout: Request timeout in seconds
|
||||||
|
|
||||||
Returns:
|
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
|
import subprocess
|
||||||
|
|
||||||
@@ -444,13 +515,49 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|||||||
timeout=timeout + 5,
|
timeout=timeout + 5,
|
||||||
)
|
)
|
||||||
status_code = int(result.stdout.strip())
|
status_code = int(result.stdout.strip())
|
||||||
|
|
||||||
|
if 200 <= status_code < 400:
|
||||||
return {
|
return {
|
||||||
"healthy": 200 <= status_code < 400,
|
"tunnel_status": "healthy",
|
||||||
"status_code": status_code,
|
"status_code": status_code,
|
||||||
|
"healthy": True,
|
||||||
|
"error": None,
|
||||||
}
|
}
|
||||||
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
elif status_code in (502, 503, 504):
|
||||||
|
# Application error, not tunnel error
|
||||||
return {
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
"healthy": False,
|
"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 {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
"status_code": None,
|
"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),
|
"error": str(e),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""SSH key service utilities for preparing keys for container use."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
|
||||||
|
key = base64.urlsafe_b64encode(key_bytes)
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str:
|
||||||
|
"""Decrypt and write SSH key files to instance directory for container mounting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
ssh_key: SSHKey model instance with encrypted private key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the .ssh directory
|
||||||
|
"""
|
||||||
|
ssh_dir = Path(instance_dir) / ".ssh"
|
||||||
|
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Decrypt private key
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
# Write private key with restricted permissions
|
||||||
|
private_key_path = ssh_dir / "id_ed25519"
|
||||||
|
private_key_path.write_text(private_key)
|
||||||
|
os.chmod(private_key_path, 0o600)
|
||||||
|
|
||||||
|
# Write public key
|
||||||
|
public_key_path = ssh_dir / "id_ed25519.pub"
|
||||||
|
public_key_path.write_text(ssh_key.public_key)
|
||||||
|
os.chmod(public_key_path, 0o644)
|
||||||
|
|
||||||
|
# Write SSH config
|
||||||
|
config_path = ssh_dir / "config"
|
||||||
|
config_content = """Host *
|
||||||
|
StrictHostKeyChecking no
|
||||||
|
UserKnownHostsFile /dev/null
|
||||||
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
|
IdentitiesOnly yes
|
||||||
|
"""
|
||||||
|
config_path.write_text(config_content)
|
||||||
|
os.chmod(config_path, 0o644)
|
||||||
|
|
||||||
|
return str(ssh_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_ssh_key_files(instance_dir: str) -> None:
|
||||||
|
"""Remove temporary SSH key files from instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
"""
|
||||||
|
ssh_dir = Path(instance_dir) / ".ssh"
|
||||||
|
if ssh_dir.exists():
|
||||||
|
for file_path in ssh_dir.iterdir():
|
||||||
|
file_path.unlink()
|
||||||
|
ssh_dir.rmdir()
|
||||||
@@ -124,7 +124,15 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
|
|||||||
try:
|
try:
|
||||||
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
|
# No commits yet - empty repository
|
||||||
|
try:
|
||||||
_run_git_command(repo_path, "checkout", "--orphan", name)
|
_run_git_command(repo_path, "checkout", "--orphan", 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
|
||||||
return
|
return
|
||||||
|
|
||||||
_run_git_command(repo_path, "branch", name, base_branch)
|
_run_git_command(repo_path, "branch", name, base_branch)
|
||||||
@@ -155,7 +163,14 @@ def checkout_branch(repo_path: str, name: str) -> None:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If checkout fails
|
RuntimeError: If checkout fails
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
_run_git_command(repo_path, "checkout", name)
|
_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(
|
def commit_changes(
|
||||||
@@ -290,6 +305,10 @@ def get_current_branch(repo_path: str) -> str:
|
|||||||
Current branch name
|
Current branch name
|
||||||
"""
|
"""
|
||||||
try:
|
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:
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ def test_get_current_branch_handles_unborn_main() -> None:
|
|||||||
assert get_current_branch(tmpdir) == "main"
|
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:
|
class TestBranchOperations:
|
||||||
"""Tests for branch management functions."""
|
"""Tests for branch management functions."""
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ def _insert_tool_type(
|
|||||||
name: str,
|
name: str,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
compose_template: str,
|
compose_template: str,
|
||||||
is_builtin: bool = False,
|
|
||||||
created_by_id: str | None = None,
|
created_by_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
async def _run() -> None:
|
async def _run() -> None:
|
||||||
@@ -120,7 +119,6 @@ def _insert_tool_type(
|
|||||||
description="A test tool type",
|
description="A test tool type",
|
||||||
compose_template=compose_template,
|
compose_template=compose_template,
|
||||||
required_variables=["REPO_PATH", "TOOL_NAME"],
|
required_variables=["REPO_PATH", "TOOL_NAME"],
|
||||||
is_builtin=is_builtin,
|
|
||||||
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
|
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
|
||||||
)
|
)
|
||||||
await session.merge(tool_type)
|
await session.merge(tool_type)
|
||||||
@@ -234,7 +232,6 @@ def test_create_tool_type_successfully() -> None:
|
|||||||
assert data["name"] == "my-custom-tool"
|
assert data["name"] == "my-custom-tool"
|
||||||
assert data["display_name"] == "My Custom Tool"
|
assert data["display_name"] == "My Custom Tool"
|
||||||
assert data["description"] == "A custom development tool"
|
assert data["description"] == "A custom development tool"
|
||||||
assert data["is_builtin"] == False
|
|
||||||
assert data["created_by_id"] == user_id
|
assert data["created_by_id"] == user_id
|
||||||
assert "id" in data
|
assert "id" in data
|
||||||
|
|
||||||
@@ -376,28 +373,7 @@ def test_update_tool_type_not_found() -> None:
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
def test_update_builtin_tool_type_fails() -> None:
|
|
||||||
_prepare_test_db()
|
|
||||||
user_id = "11111111-1111-1111-1111-111111111111"
|
|
||||||
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
||||||
_insert_user(user_id)
|
|
||||||
_insert_tool_type(
|
|
||||||
tool_type_id,
|
|
||||||
"builtin-tool",
|
|
||||||
"Built-in Tool",
|
|
||||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
|
||||||
is_builtin=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = _load_app()
|
|
||||||
client = TestClient(app)
|
|
||||||
client.cookies.set("access_token", _mint_token(user_id))
|
|
||||||
|
|
||||||
payload = {"display_name": "Updated"}
|
|
||||||
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -442,53 +418,4 @@ def test_delete_tool_type_not_found() -> None:
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
def test_delete_builtin_tool_type_fails() -> None:
|
|
||||||
_prepare_test_db()
|
|
||||||
user_id = "11111111-1111-1111-1111-111111111111"
|
|
||||||
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
||||||
_insert_user(user_id)
|
|
||||||
_insert_tool_type(
|
|
||||||
tool_type_id,
|
|
||||||
"builtin-tool",
|
|
||||||
"Built-in Tool",
|
|
||||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
|
||||||
is_builtin=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = _load_app()
|
|
||||||
client = TestClient(app)
|
|
||||||
client.cookies.set("access_token", _mint_token(user_id))
|
|
||||||
|
|
||||||
response = client.delete(f"/tool-types/{tool_type_id}")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
def test_builtin_tool_types_seeded_on_startup() -> None:
|
|
||||||
_prepare_test_db()
|
|
||||||
user_id = "11111111-1111-1111-1111-111111111111"
|
|
||||||
_insert_user(user_id)
|
|
||||||
|
|
||||||
# Load app triggers startup event which seeds built-in types
|
|
||||||
app = _load_app()
|
|
||||||
client = TestClient(app)
|
|
||||||
client.cookies.set("access_token", _mint_token(user_id))
|
|
||||||
|
|
||||||
response = client.get("/tool-types")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Check that built-in types exist
|
|
||||||
builtin_names = [t["name"] for t in data if t["is_builtin"]]
|
|
||||||
assert "code-server" in builtin_names
|
|
||||||
assert "jupyter-notebook" in builtin_names
|
|
||||||
|
|
||||||
# Verify built-in types have correct attributes
|
|
||||||
code_server = next((t for t in data if t["name"] == "code-server"), None)
|
|
||||||
assert code_server is not None
|
|
||||||
assert code_server["display_name"] == "VS Code Server"
|
|
||||||
assert "services" in code_server["compose_template"]
|
|
||||||
assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"]
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class TestToolTypesAPIExtended:
|
|||||||
"interfaces": ["web"],
|
"interfaces": ["web"],
|
||||||
"default_port": 8080,
|
"default_port": 8080,
|
||||||
"definition_type": "compose",
|
"definition_type": "compose",
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
"readiness_probe": {
|
"readiness_probe": {
|
||||||
"command": "curl -f http://localhost:8080",
|
"command": "curl -f http://localhost:8080",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
@@ -92,7 +92,7 @@ class TestToolTypesAPIExtended:
|
|||||||
"display_name": "Update Test Tool",
|
"display_name": "Update Test Tool",
|
||||||
"default_port": 8080,
|
"default_port": 8080,
|
||||||
"definition_type": "compose",
|
"definition_type": "compose",
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
"required_variables": [],
|
"required_variables": [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -167,7 +167,7 @@ class TestToolTypesAPIExtended:
|
|||||||
"interfaces": ["web", "terminal"],
|
"interfaces": ["web", "terminal"],
|
||||||
"default_port": 8443,
|
"default_port": 8443,
|
||||||
"definition_type": "compose",
|
"definition_type": "compose",
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
||||||
"readiness_probe": {
|
"readiness_probe": {
|
||||||
"command": "curl -f http://localhost:8443",
|
"command": "curl -f http://localhost:8443",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
@@ -186,3 +186,40 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["category"] == "editor"
|
assert data["category"] == "editor"
|
||||||
assert data["interfaces"] == ["web", "terminal"]
|
assert data["interfaces"] == ["web", "terminal"]
|
||||||
assert "readiness_probe" in data
|
assert "readiness_probe" in data
|
||||||
|
|
||||||
|
def test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that creating a tool type without default_port fails validation."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "no-port-tool",
|
||||||
|
"display_name": "No Port Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert "default_port" in str(data)
|
||||||
|
|
||||||
|
def test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that port mismatch between default_port and compose template fails."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "port-mismatch-tool",
|
||||||
|
"display_name": "Port Mismatch Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"default_port": 9999,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert "Port 9999 is not exposed" in str(data)
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Tests for session creation with branch selection and new branch creation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.api.tool_instances import CreateInstanceRequest
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateInstanceRequest:
|
||||||
|
"""Tests for CreateInstanceRequest model."""
|
||||||
|
|
||||||
|
def test_default_values(self):
|
||||||
|
"""Test default values for CreateInstanceRequest."""
|
||||||
|
request = CreateInstanceRequest(tool_type_id="123")
|
||||||
|
assert request.clone_mode == "mount"
|
||||||
|
assert request.branch == "main"
|
||||||
|
assert request.new_branch is None
|
||||||
|
assert request.display_name is None
|
||||||
|
|
||||||
|
def test_clone_mode_with_branch(self):
|
||||||
|
"""Test CreateInstanceRequest with clone mode and branch."""
|
||||||
|
request = CreateInstanceRequest(
|
||||||
|
tool_type_id="123",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="dev",
|
||||||
|
)
|
||||||
|
assert request.clone_mode == "clone"
|
||||||
|
assert request.branch == "dev"
|
||||||
|
|
||||||
|
def test_new_branch_field(self):
|
||||||
|
"""Test CreateInstanceRequest with new_branch field."""
|
||||||
|
request = CreateInstanceRequest(
|
||||||
|
tool_type_id="123",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="main",
|
||||||
|
new_branch="feature/test",
|
||||||
|
)
|
||||||
|
assert request.new_branch == "feature/test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBranchCreationInClone:
|
||||||
|
"""Tests for branch creation logic in clone process."""
|
||||||
|
|
||||||
|
def test_create_local_branch_success(self):
|
||||||
|
"""Test successful local branch creation."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Initialize repo
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", tmpdir],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.name", "Test User"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create initial commit
|
||||||
|
readme = os.path.join(tmpdir, "README.md")
|
||||||
|
with open(readme, "w") as f:
|
||||||
|
f.write("# Test\n")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "add", "README.md"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new branch
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "checkout", "-b", "feature/new-branch"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
# Verify branch exists
|
||||||
|
branches_result = subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "branch", "--show-current"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert branches_result.stdout.strip() == "feature/new-branch"
|
||||||
|
|
||||||
|
def test_create_local_branch_invalid_name(self):
|
||||||
|
"""Test local branch creation with invalid name fails."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Initialize repo
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", tmpdir],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.name", "Test User"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create initial commit
|
||||||
|
readme = os.path.join(tmpdir, "README.md")
|
||||||
|
with open(readme, "w") as f:
|
||||||
|
f.write("# Test\n")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "add", "README.md"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to create branch with invalid name (contains spaces)
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "checkout", "-b", "invalid branch name"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Git accepts branch names with spaces but it's not recommended
|
||||||
|
# This test verifies the command structure
|
||||||
|
assert result.returncode == 0 or "fatal" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateInstanceAPI:
|
||||||
|
"""Tests for create instance API endpoint with branch options."""
|
||||||
|
|
||||||
|
def test_create_instance_request_validation(self):
|
||||||
|
"""Test that CreateInstanceRequest validates correctly."""
|
||||||
|
# Valid request with new_branch
|
||||||
|
request = CreateInstanceRequest(
|
||||||
|
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="main",
|
||||||
|
new_branch="feature/test",
|
||||||
|
)
|
||||||
|
assert request.new_branch == "feature/test"
|
||||||
|
|
||||||
|
# Valid request without new_branch
|
||||||
|
request2 = CreateInstanceRequest(
|
||||||
|
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="dev",
|
||||||
|
)
|
||||||
|
assert request2.new_branch is None
|
||||||
|
|
||||||
|
def test_create_instance_with_new_branch_sets_instance_branch(self):
|
||||||
|
"""Test that instance branch is set to new_branch when provided."""
|
||||||
|
# This tests the logic: data.new_branch if data.new_branch else data.branch
|
||||||
|
new_branch = "feature/test"
|
||||||
|
base_branch = "main"
|
||||||
|
|
||||||
|
# Simulate the logic from create_instance
|
||||||
|
stored_branch = new_branch if new_branch else base_branch
|
||||||
|
assert stored_branch == "feature/test"
|
||||||
|
|
||||||
|
# Without new_branch
|
||||||
|
stored_branch2 = None if None else base_branch
|
||||||
|
assert stored_branch2 == "main"
|
||||||
@@ -8,6 +8,7 @@ export interface GitRepository {
|
|||||||
owner_id: string;
|
owner_id: string;
|
||||||
is_mirror: boolean;
|
is_mirror: boolean;
|
||||||
remote_url: string | null;
|
remote_url: string | null;
|
||||||
|
ssh_key_id: string | null;
|
||||||
last_push: string | null;
|
last_push: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -16,6 +17,7 @@ export interface GitRepositoryCreate {
|
|||||||
name: string;
|
name: string;
|
||||||
remote_url?: string;
|
remote_url?: string;
|
||||||
force_original_url?: boolean;
|
force_original_url?: boolean;
|
||||||
|
ssh_key_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface URLParseResult {
|
export interface URLParseResult {
|
||||||
@@ -50,6 +52,39 @@ export async function deleteRepository(projectId: string, repoId: string): Promi
|
|||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateRepositorySshKey(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
sshKeyId: string | null
|
||||||
|
): Promise<GitRepository> {
|
||||||
|
const response = await apiClient.patch(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/ssh-key`,
|
||||||
|
{ ssh_key_id: sshKeyId }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Branch {
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
last_commit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchesResponse {
|
||||||
|
branches: Branch[];
|
||||||
|
default_branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRepositoryBranches(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string
|
||||||
|
): Promise<BranchesResponse> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CommitHistoryEntry {
|
export interface CommitHistoryEntry {
|
||||||
hash: string;
|
hash: string;
|
||||||
short_hash: string;
|
short_hash: string;
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export interface Session {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
|
container_status?: string;
|
||||||
|
probe_status?: string;
|
||||||
|
clone_mode?: string;
|
||||||
|
branch?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listInstances(
|
export async function listInstances(
|
||||||
@@ -41,13 +45,19 @@ export async function createInstance(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
toolTypeId: string,
|
toolTypeId: string,
|
||||||
displayName?: string
|
displayName?: string,
|
||||||
|
cloneMode?: string,
|
||||||
|
branch?: string,
|
||||||
|
newBranch?: string
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
{
|
{
|
||||||
tool_type_id: toolTypeId,
|
tool_type_id: toolTypeId,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
|
clone_mode: cloneMode || "mount",
|
||||||
|
branch: branch || undefined,
|
||||||
|
new_branch: newBranch || undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -89,10 +99,12 @@ export async function restartInstance(
|
|||||||
export async function deleteInstance(
|
export async function deleteInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
|
force?: boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await apiClient.delete(
|
await apiClient.delete(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||||
|
{ params: { force } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,11 +113,23 @@ export async function getUserSessions(): Promise<Session[]> {
|
|||||||
return response.data.sessions;
|
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(
|
export async function checkInstanceHealth(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string
|
||||||
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
|
): Promise<InstanceHealth> {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,3 +24,30 @@ export async function createSSHKey(data: SSHKeyCreate): Promise<SSHKey> {
|
|||||||
export async function deleteSSHKey(keyId: string): Promise<void> {
|
export async function deleteSSHKey(keyId: string): Promise<void> {
|
||||||
await apiClient.delete(`/ssh-keys/${keyId}`);
|
await apiClient.delete(`/ssh-keys/${keyId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SignPayloadRequest {
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureResponse {
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifySignatureRequest {
|
||||||
|
payload: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifySignatureResponse {
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signPayload(keyId: string, data: SignPayloadRequest): Promise<SignatureResponse> {
|
||||||
|
const response = await apiClient.post<SignatureResponse>(`/ssh-keys/${keyId}/sign`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySignature(keyId: string, data: VerifySignatureRequest): Promise<VerifySignatureResponse> {
|
||||||
|
const response = await apiClient.post<VerifySignatureResponse>(`/ssh-keys/${keyId}/verify`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export interface ToolType {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
interfaces: string[];
|
interface_type: string;
|
||||||
|
requires_port: boolean;
|
||||||
default_port: number | null;
|
default_port: number | null;
|
||||||
definition_type: 'compose' | 'dockerfile';
|
definition_type: 'compose' | 'dockerfile';
|
||||||
compose_template: string | null;
|
compose_template: string | null;
|
||||||
@@ -20,7 +21,6 @@ export interface ToolType {
|
|||||||
build_context: Record<string, string> | null;
|
build_context: Record<string, string> | null;
|
||||||
readiness_probe: ReadinessProbe | null;
|
readiness_probe: ReadinessProbe | null;
|
||||||
required_variables: string[];
|
required_variables: string[];
|
||||||
is_builtin: boolean;
|
|
||||||
created_by_id: string | null;
|
created_by_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -31,7 +31,8 @@ export interface CreateToolTypeRequest {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
interfaces?: string[];
|
interface_type?: string;
|
||||||
|
requires_port?: boolean;
|
||||||
default_port: number;
|
default_port: number;
|
||||||
definition_type?: 'compose' | 'dockerfile';
|
definition_type?: 'compose' | 'dockerfile';
|
||||||
compose_template?: string;
|
compose_template?: string;
|
||||||
@@ -45,7 +46,8 @@ export interface UpdateToolTypeRequest {
|
|||||||
display_name?: string;
|
display_name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
interfaces?: string[];
|
interface_type?: string;
|
||||||
|
requires_port?: boolean;
|
||||||
default_port?: number;
|
default_port?: number;
|
||||||
definition_type?: 'compose' | 'dockerfile';
|
definition_type?: 'compose' | 'dockerfile';
|
||||||
compose_template?: string;
|
compose_template?: string;
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { getUserSessions } from "../api/sessions";
|
import { getUserSessions } from "../api/sessions";
|
||||||
import type { Session } from "../api/sessions";
|
import type { Session } from "../api/sessions";
|
||||||
import { useTheme } from "../hooks/use-theme";
|
import { useTheme } from "../hooks/use-theme";
|
||||||
import { useAuth } from "../state/auth";
|
import { useAuth } from "../state/auth";
|
||||||
import { useSessions } from "../state/sessions";
|
import { useSessions } from "../state/sessions";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
|
||||||
{ to: "/", label: "Home", icon: "dashboard" },
|
{ to: "/", label: "Home", icon: "dashboard" },
|
||||||
|
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||||
@@ -38,6 +40,11 @@ export const AppShell = () => {
|
|||||||
useTheme();
|
useTheme();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { sessions, setAllSessions } = useSessions();
|
const { sessions, setAllSessions } = useSessions();
|
||||||
|
const location = useLocation();
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -57,6 +64,19 @@ export const AppShell = () => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
|
// Close mobile menu on route change
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
if (isMobileTerminal) {
|
||||||
|
return (
|
||||||
|
<div className="shell mobile-terminal-shell">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<header className="shell-header">
|
<header className="shell-header">
|
||||||
@@ -81,9 +101,18 @@ export const AppShell = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="shell-body">
|
<div className="shell-body">
|
||||||
<aside className="shell-nav" aria-label="Primary navigation">
|
<aside className={`shell-nav ${mobileMenuOpen ? "mobile-open" : ""}`} aria-label="Primary navigation">
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
className="mobile-menu-close"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const isHome = item.to === "/";
|
|
||||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -94,7 +123,7 @@ export const AppShell = () => {
|
|||||||
>
|
>
|
||||||
<Icon name={item.icon} size="sm" />
|
<Icon name={item.icon} size="sm" />
|
||||||
{item.label}
|
{item.label}
|
||||||
{isHome && activeCount > 0 && (
|
{item.badge === "sessions" && activeCount > 0 && (
|
||||||
<span className="nav-badge">{activeCount}</span>
|
<span className="nav-badge">{activeCount}</span>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -112,6 +141,13 @@ export const AppShell = () => {
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{isMobile && mobileMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="mobile-menu-overlay"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="shell-content">
|
<main className="shell-content">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import { createInstance, startInstance, type ToolInstance } from "../api/sessions";
|
||||||
|
import type { Project } from "../types";
|
||||||
|
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
||||||
|
import type { ToolType } from "../api/tool_types";
|
||||||
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
|
|
||||||
|
interface CreateSessionFormProps {
|
||||||
|
projects: Project[];
|
||||||
|
repositories: GitRepository[];
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
fixedProjectId?: string;
|
||||||
|
fixedRepoId?: string;
|
||||||
|
projectName?: string;
|
||||||
|
repoName?: string;
|
||||||
|
showCloneMode?: boolean;
|
||||||
|
showFixedFields?: boolean;
|
||||||
|
onProjectChange?: (projectId: string) => void;
|
||||||
|
onSuccess?: (instance: ToolInstance) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
submitLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateSessionForm = ({
|
||||||
|
projects,
|
||||||
|
repositories,
|
||||||
|
toolTypes,
|
||||||
|
fixedProjectId,
|
||||||
|
fixedRepoId,
|
||||||
|
projectName,
|
||||||
|
repoName,
|
||||||
|
showCloneMode = true,
|
||||||
|
showFixedFields = true,
|
||||||
|
onProjectChange,
|
||||||
|
onSuccess,
|
||||||
|
onCancel,
|
||||||
|
submitLabel = "Create Session",
|
||||||
|
className = "",
|
||||||
|
}: CreateSessionFormProps) => {
|
||||||
|
const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||||
|
const [branch, setBranch] = useState("main");
|
||||||
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
|
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
|
||||||
|
const [newBranchName, setNewBranchName] = useState("");
|
||||||
|
const [baseBranch, setBaseBranch] = useState("");
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<"idle" | "creating" | "error">("idle");
|
||||||
|
const [progress, setProgress] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load SSH keys when clone mode is shown
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showCloneMode) return;
|
||||||
|
const loadKeys = async () => {
|
||||||
|
try {
|
||||||
|
const keys = await listSSHKeys();
|
||||||
|
setSshKeys(keys);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadKeys();
|
||||||
|
}, [showCloneMode]);
|
||||||
|
|
||||||
|
// Load branches when selected repo changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedRepo || !showCloneMode) {
|
||||||
|
setBranches([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadBranches = async () => {
|
||||||
|
setIsLoadingBranches(true);
|
||||||
|
try {
|
||||||
|
const branchList = await listRepositoryBranches(selectedRepo);
|
||||||
|
setBranches(branchList);
|
||||||
|
const defaultBranch = branchList.find((b) => b.is_default);
|
||||||
|
if (defaultBranch) {
|
||||||
|
setBranch(defaultBranch.name);
|
||||||
|
setBaseBranch(defaultBranch.name);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBranches(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadBranches();
|
||||||
|
}, [selectedRepo, showCloneMode]);
|
||||||
|
|
||||||
|
// Filter repositories by selected project
|
||||||
|
const availableRepos = selectedProject
|
||||||
|
? repositories.filter((r) => r.project_id === selectedProject)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const projectId = fixedProjectId || selectedProject;
|
||||||
|
const repoId = fixedRepoId || selectedRepo;
|
||||||
|
|
||||||
|
if (!projectId || !repoId || !selectedToolType) {
|
||||||
|
setError("Project, repository, and tool type are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCloneMode && cloneMode === "clone") {
|
||||||
|
const repo = repositories.find((r) => r.id === repoId);
|
||||||
|
if (!repo?.ssh_key_id) {
|
||||||
|
setError("Repository must have an SSH key assigned for clone mode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("creating");
|
||||||
|
setProgress("Creating instance...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instance = await createInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
selectedToolType,
|
||||||
|
displayName || undefined,
|
||||||
|
showCloneMode ? cloneMode : undefined,
|
||||||
|
showCloneMode && cloneMode === "clone"
|
||||||
|
? isCreatingNewBranch
|
||||||
|
? baseBranch
|
||||||
|
: branch
|
||||||
|
: undefined,
|
||||||
|
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||||
|
? newBranchName
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
setProgress("Starting container...");
|
||||||
|
await startInstance(projectId, repoId, instance.id);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
if (!fixedProjectId) setSelectedProject("");
|
||||||
|
if (!fixedRepoId) setSelectedRepo("");
|
||||||
|
setSelectedToolType("");
|
||||||
|
setDisplayName("");
|
||||||
|
setCloneMode("mount");
|
||||||
|
setBranch("main");
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setNewBranchName("");
|
||||||
|
setBaseBranch("");
|
||||||
|
setBranches([]);
|
||||||
|
setStatus("idle");
|
||||||
|
|
||||||
|
onSuccess?.(instance);
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
setError("Failed to create session");
|
||||||
|
setProgress("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSubmitting = status === "creating";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`create-session-form-wrapper ${className}`}>
|
||||||
|
{isSubmitting && (
|
||||||
|
<div className="loading-overlay">
|
||||||
|
<div className="loading-content">
|
||||||
|
<Icon name="loading" size="lg" />
|
||||||
|
<p>{progress || "Creating session..."}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="stack create-session-form">
|
||||||
|
<div className="form-row">
|
||||||
|
{fixedProjectId && showFixedFields ? (
|
||||||
|
<label className="form-field">
|
||||||
|
Project
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={projectName || projects.find((p) => p.id === fixedProjectId)?.name || ""}
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label className="form-field">
|
||||||
|
Project
|
||||||
|
<select
|
||||||
|
value={selectedProject}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setSelectedProject(value);
|
||||||
|
setSelectedRepo("");
|
||||||
|
onProjectChange?.(value);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fixedRepoId && showFixedFields ? (
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={repoName || repositories.find((r) => r.id === fixedRepoId)?.name || ""}
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select
|
||||||
|
value={selectedRepo}
|
||||||
|
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||||
|
disabled={!selectedProject || isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">Select repository...</option>
|
||||||
|
{availableRepos.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Tool Type
|
||||||
|
<select
|
||||||
|
value={selectedToolType}
|
||||||
|
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">Select tool...</option>
|
||||||
|
{toolTypes.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCloneMode && (
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
Repository Access
|
||||||
|
<div className="radio-group">
|
||||||
|
<label className="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cloneMode"
|
||||||
|
value="mount"
|
||||||
|
checked={cloneMode === "mount"}
|
||||||
|
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
Mount (live sync)
|
||||||
|
</label>
|
||||||
|
<label className="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cloneMode"
|
||||||
|
value="clone"
|
||||||
|
checked={cloneMode === "clone"}
|
||||||
|
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
Clone fresh copy
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cloneMode === "clone" && (
|
||||||
|
<>
|
||||||
|
<label className="form-field">
|
||||||
|
Branch
|
||||||
|
{isLoadingBranches ? (
|
||||||
|
<span className="muted">Loading branches...</span>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={isCreatingNewBranch ? "__new__" : branch}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === "__new__") {
|
||||||
|
setIsCreatingNewBranch(true);
|
||||||
|
setNewBranchName("");
|
||||||
|
} else {
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setBranch(value);
|
||||||
|
setBaseBranch(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name} {b.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__new__">Create new branch...</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isCreatingNewBranch && (
|
||||||
|
<>
|
||||||
|
<label className="form-field">
|
||||||
|
New Branch Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newBranchName}
|
||||||
|
onChange={(e) => setNewBranchName(e.target.value)}
|
||||||
|
placeholder="feature/my-new-branch"
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Base Branch
|
||||||
|
<select
|
||||||
|
value={baseBranch}
|
||||||
|
onChange={(e) => setBaseBranch(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name} {b.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRepo && (
|
||||||
|
<div className="form-field ssh-key-info">
|
||||||
|
{(() => {
|
||||||
|
const repo = repositories.find((r) => r.id === selectedRepo);
|
||||||
|
if (!repo) return null;
|
||||||
|
if (repo.ssh_key_id) {
|
||||||
|
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
|
||||||
|
return (
|
||||||
|
<span className="success-text">
|
||||||
|
SSH key: {key?.name || "Assigned"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="warning-text">
|
||||||
|
No SSH key assigned to this repository. Clone mode requires an SSH key.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Display Name (optional)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="My Development Environment"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
{submitLabel}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ interface GitToolbarProps {
|
|||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
branches: string[];
|
branches: string[];
|
||||||
hasRemote: boolean;
|
hasRemote: boolean;
|
||||||
|
isMirror: boolean;
|
||||||
onBranchChange: (branch: string) => void;
|
onBranchChange: (branch: string) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
@@ -28,6 +29,7 @@ export const GitToolbar = ({
|
|||||||
currentBranch,
|
currentBranch,
|
||||||
branches,
|
branches,
|
||||||
hasRemote,
|
hasRemote,
|
||||||
|
isMirror,
|
||||||
onBranchChange,
|
onBranchChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: GitToolbarProps) => {
|
}: GitToolbarProps) => {
|
||||||
@@ -136,7 +138,13 @@ export const GitToolbar = ({
|
|||||||
return (
|
return (
|
||||||
<div className="git-toolbar">
|
<div className="git-toolbar">
|
||||||
{error && <div className="toolbar-error">{error}</div>}
|
{error && <div className="toolbar-error">{error}</div>}
|
||||||
|
{isMirror && (
|
||||||
|
<div className="warning-message">
|
||||||
|
<Icon name="warning" size="sm" /> This repository is a bare mirror.
|
||||||
|
Editing, committing, pulling, and merging are not available.
|
||||||
|
Delete and recreate it to enable full workspace features.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="toolbar-row">
|
<div className="toolbar-row">
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Icon } from "./icon";
|
|||||||
import type { ToolInstance } from "../api/sessions";
|
import type { ToolInstance } from "../api/sessions";
|
||||||
import {
|
import {
|
||||||
checkInstanceHealth,
|
checkInstanceHealth,
|
||||||
createInstance,
|
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
listInstances,
|
listInstances,
|
||||||
recreateInstanceTunnel,
|
recreateInstanceTunnel,
|
||||||
@@ -13,22 +12,23 @@ import {
|
|||||||
stopInstance,
|
stopInstance,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
|
import { CreateSessionForm } from "./create-session-form";
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
interface InstanceListProps {
|
interface InstanceListProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
|
projectName?: string;
|
||||||
|
repoName?: string;
|
||||||
toolTypes: ToolType[];
|
toolTypes: ToolType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
|
export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTypes }: InstanceListProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [selectedToolType, setSelectedToolType] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Stop confirmation
|
// Stop confirmation
|
||||||
@@ -83,18 +83,9 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [instances, projectId, repoId]);
|
}, [instances, projectId, repoId]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreateSuccess = async () => {
|
||||||
if (!selectedToolType) return;
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await createInstance(projectId, repoId, selectedToolType, displayName || undefined);
|
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
setSelectedToolType("");
|
|
||||||
setDisplayName("");
|
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
|
||||||
setError("Failed to create instance");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStart = async (instanceId: string) => {
|
const handleStart = async (instanceId: string) => {
|
||||||
@@ -309,48 +300,18 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
<h2>Launch Tool</h2>
|
<h2>Launch Tool</h2>
|
||||||
<div className="stack">
|
<CreateSessionForm
|
||||||
<label className="form-field">
|
projects={[]}
|
||||||
Tool Type
|
repositories={[]}
|
||||||
<select
|
toolTypes={toolTypes}
|
||||||
value={selectedToolType}
|
fixedProjectId={projectId}
|
||||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
fixedRepoId={repoId}
|
||||||
>
|
projectName={projectName}
|
||||||
<option value="">Select a tool...</option>
|
repoName={repoName}
|
||||||
{toolTypes.map((tool) => (
|
onSuccess={handleCreateSuccess}
|
||||||
<option key={tool.id} value={tool.id}>
|
onCancel={() => setShowCreate(false)}
|
||||||
{tool.display_name}
|
submitLabel="Launch"
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Display Name (optional)
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
placeholder="My Development Environment"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<div className="dialog-actions">
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={() => setShowCreate(false)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
onClick={() => void handleCreate()}
|
|
||||||
disabled={!selectedToolType}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Launch
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
interface MobileTerminalHeaderProps {
|
||||||
|
instanceName?: string;
|
||||||
|
onBack?: () => void;
|
||||||
|
onMenuToggle?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
isVisible: boolean;
|
||||||
|
connectionStatus?: "connecting" | "connected" | "disconnected" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
|
||||||
|
instanceName,
|
||||||
|
onBack,
|
||||||
|
onMenuToggle,
|
||||||
|
onClose,
|
||||||
|
isVisible,
|
||||||
|
connectionStatus = "connecting",
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mobile-terminal-header ${isVisible ? "visible" : "hidden"}`}
|
||||||
|
>
|
||||||
|
<div className="mobile-terminal-header-left">
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
className="mobile-terminal-header-button"
|
||||||
|
onClick={onBack}
|
||||||
|
type="button"
|
||||||
|
aria-label="Go back"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onMenuToggle && (
|
||||||
|
<button
|
||||||
|
className="mobile-terminal-header-button"
|
||||||
|
onClick={onMenuToggle}
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<Icon name="menu" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-terminal-header-center">
|
||||||
|
<span className="mobile-terminal-header-title">
|
||||||
|
{instanceName || "Terminal"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`mobile-terminal-header-status ${connectionStatus}`}
|
||||||
|
aria-label={`Connection status: ${connectionStatus}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-terminal-header-right">
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
className="mobile-terminal-header-button"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
aria-label="Close terminal"
|
||||||
|
>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { TerminalComponent } from "./terminal";
|
||||||
|
import { MobileTerminalHeader } from "./mobile-terminal-header";
|
||||||
|
import { SpecialKeysStrip } from "./special-keys-strip";
|
||||||
|
import { SpecialKeysPanel } from "./special-keys-panel";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
||||||
|
import { useAutoHide } from "../hooks/use-auto-hide";
|
||||||
|
|
||||||
|
interface MobileTerminalWrapperProps {
|
||||||
|
instanceId: string;
|
||||||
|
instanceName?: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
onMenuToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||||
|
instanceId,
|
||||||
|
instanceName,
|
||||||
|
onClose,
|
||||||
|
onBack,
|
||||||
|
onMenuToggle,
|
||||||
|
}) => {
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||||
|
useVirtualKeyboard();
|
||||||
|
const [showPanel, setShowPanel] = useState(false);
|
||||||
|
const [terminalRef, setTerminalRef] = useState<{
|
||||||
|
sendData: (data: string) => void;
|
||||||
|
connectionStatus: "connecting" | "connected" | "disconnected" | "error";
|
||||||
|
focusInput: () => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||||
|
const keysAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||||
|
|
||||||
|
const handleTerminalTap = useCallback(() => {
|
||||||
|
headerAutoHide.toggle();
|
||||||
|
keysAutoHide.toggle();
|
||||||
|
}, [headerAutoHide, keysAutoHide]);
|
||||||
|
|
||||||
|
const handleTerminalReady = useCallback(
|
||||||
|
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error", focusInput: () => void) => {
|
||||||
|
setTerminalRef({ sendData, connectionStatus, focusInput });
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSendKey = useCallback(
|
||||||
|
(data: string) => {
|
||||||
|
terminalRef?.sendData(data);
|
||||||
|
},
|
||||||
|
[terminalRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
return (
|
||||||
|
<TerminalComponent
|
||||||
|
instanceId={instanceId}
|
||||||
|
onClose={onClose}
|
||||||
|
isMobile={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-terminal-wrapper">
|
||||||
|
<MobileTerminalHeader
|
||||||
|
instanceName={instanceName}
|
||||||
|
onBack={onBack}
|
||||||
|
onMenuToggle={onMenuToggle}
|
||||||
|
onClose={onClose}
|
||||||
|
isVisible={headerAutoHide.isVisible}
|
||||||
|
connectionStatus={terminalRef?.connectionStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mobile-terminal-content"
|
||||||
|
style={{
|
||||||
|
paddingBottom: isKeyboardOpen ? keyboardHeight : 0,
|
||||||
|
}}
|
||||||
|
onClick={handleTerminalTap}
|
||||||
|
>
|
||||||
|
<TerminalComponent
|
||||||
|
instanceId={instanceId}
|
||||||
|
onClose={onClose}
|
||||||
|
isMobile={true}
|
||||||
|
onTerminalReady={handleTerminalReady}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpecialKeysStrip
|
||||||
|
onSend={handleSendKey}
|
||||||
|
isVisible={keysAutoHide.isVisible && !showPanel}
|
||||||
|
onMoreClick={() => setShowPanel(true)}
|
||||||
|
onKeepFocus={() => terminalRef?.focusInput()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SpecialKeysPanel
|
||||||
|
onSend={handleSendKey}
|
||||||
|
isOpen={showPanel}
|
||||||
|
onClose={() => setShowPanel(false)}
|
||||||
|
onKeepFocus={() => terminalRef?.focusInput()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
|
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
|
||||||
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
type CreateMode = "clone" | "blank";
|
type CreateMode = "clone" | "blank";
|
||||||
@@ -20,12 +21,14 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
const [owner, setOwner] = useState("");
|
const [owner, setOwner] = useState("");
|
||||||
const [repoName, setRepoName] = useState("");
|
const [repoName, setRepoName] = useState("");
|
||||||
const [advancedUrl, setAdvancedUrl] = useState("");
|
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||||
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
|
const [useAdvancedUrl, setUseAdvancedUrl] = useState(true);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [urlValidation, setUrlValidation] = useState<{
|
const [urlValidation, setUrlValidation] = useState<{
|
||||||
status: UrlValidationStatus;
|
status: UrlValidationStatus;
|
||||||
result: URLParseResult | null;
|
result: URLParseResult | null;
|
||||||
}>({ status: "idle", result: null });
|
}>({ status: "idle", result: null });
|
||||||
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
|
||||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,6 +38,19 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const loadKeys = async () => {
|
||||||
|
try {
|
||||||
|
const data = await listSSHKeys();
|
||||||
|
setSshKeys(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadKeys();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (!useAdvancedUrl) {
|
if (!useAdvancedUrl) {
|
||||||
@@ -81,9 +97,10 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
setOwner("");
|
setOwner("");
|
||||||
setRepoName("");
|
setRepoName("");
|
||||||
setAdvancedUrl("");
|
setAdvancedUrl("");
|
||||||
setUseAdvancedUrl(false);
|
setUseAdvancedUrl(true);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setUrlValidation({ status: "idle", result: null });
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
setSelectedSshKey("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -120,6 +137,9 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
}
|
}
|
||||||
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||||
}
|
}
|
||||||
|
if (selectedSshKey) {
|
||||||
|
input.ssh_key_id = selectedSshKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await createRepository(projectId, input);
|
await createRepository(projectId, input);
|
||||||
@@ -212,6 +232,20 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
placeholder="repo-name"
|
placeholder="repo-name"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
SSH Key
|
||||||
|
<select
|
||||||
|
value={selectedSshKey}
|
||||||
|
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select SSH key (optional)...</option>
|
||||||
|
{sshKeys.map((k) => (
|
||||||
|
<option key={k.id} value={k.id}>
|
||||||
|
{k.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -223,6 +257,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{createMode === "clone" && useAdvancedUrl && (
|
{createMode === "clone" && useAdvancedUrl && (
|
||||||
|
<>
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
Remote URL
|
Remote URL
|
||||||
<input
|
<input
|
||||||
@@ -262,6 +297,21 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
<Icon name="error" size="sm" /> Invalid URL
|
<Icon name="error" size="sm" /> Invalid URL
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
SSH Key
|
||||||
|
<select
|
||||||
|
value={selectedSshKey}
|
||||||
|
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select SSH key (optional)...</option>
|
||||||
|
{sshKeys.map((k) => (
|
||||||
|
<option key={k.id} value={k.id}>
|
||||||
|
{k.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
@@ -269,7 +319,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
>
|
>
|
||||||
Use owner/repo instead
|
Use owner/repo instead
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</>
|
||||||
)}
|
)}
|
||||||
{formError && (
|
{formError && (
|
||||||
<div className="error-message">
|
<div className="error-message">
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useSpecialKeys, type SpecialKey } from "../hooks/use-special-keys";
|
||||||
|
|
||||||
|
interface SpecialKeysPanelProps {
|
||||||
|
onSend: (data: string) => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onKeepFocus?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
|
||||||
|
{ key: "home", label: "Home" },
|
||||||
|
{ key: "end", label: "End" },
|
||||||
|
{ key: "pageup", label: "PgUp" },
|
||||||
|
{ key: "pagedown", label: "PgDn" },
|
||||||
|
{ key: "ctrlc", label: "Ctrl+C" },
|
||||||
|
{ key: "ctrld", label: "Ctrl+D" },
|
||||||
|
{ key: "ctrlz", label: "Ctrl+Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const F_KEYS: { key: SpecialKey; label: string }[] = [
|
||||||
|
{ key: "f1", label: "F1" },
|
||||||
|
{ key: "f2", label: "F2" },
|
||||||
|
{ key: "f3", label: "F3" },
|
||||||
|
{ key: "f4", label: "F4" },
|
||||||
|
{ key: "f5", label: "F5" },
|
||||||
|
{ key: "f6", label: "F6" },
|
||||||
|
{ key: "f7", label: "F7" },
|
||||||
|
{ key: "f8", label: "F8" },
|
||||||
|
{ key: "f9", label: "F9" },
|
||||||
|
{ key: "f10", label: "F10" },
|
||||||
|
{ key: "f11", label: "F11" },
|
||||||
|
{ key: "f12", label: "F12" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
|
||||||
|
onSend,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onKeepFocus,
|
||||||
|
}) => {
|
||||||
|
const { sendKey } = useSpecialKeys({ onSend });
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendKey(key);
|
||||||
|
onClose();
|
||||||
|
onKeepFocus?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="special-keys-panel-overlay" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="special-keys-panel"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="special-keys-panel-section">
|
||||||
|
{EXPANDED_KEYS.map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className="special-key-button"
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="special-keys-panel-divider" />
|
||||||
|
<div className="special-keys-panel-section">
|
||||||
|
{F_KEYS.map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className="special-key-button"
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useSpecialKeys, type SpecialKey } from "../hooks/use-special-keys";
|
||||||
|
|
||||||
|
interface SpecialKeysStripProps {
|
||||||
|
onSend: (data: string) => void;
|
||||||
|
isVisible: boolean;
|
||||||
|
onMoreClick?: () => void;
|
||||||
|
onKeepFocus?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIMARY_KEYS: { key: SpecialKey; label: string }[] = [
|
||||||
|
{ key: "escape", label: "Esc" },
|
||||||
|
{ key: "tab", label: "Tab" },
|
||||||
|
{ key: "ctrl", label: "Ctrl" },
|
||||||
|
{ key: "alt", label: "Alt" },
|
||||||
|
{ key: "up", label: "↑" },
|
||||||
|
{ key: "down", label: "↓" },
|
||||||
|
{ key: "left", label: "←" },
|
||||||
|
{ key: "right", label: "→" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
|
||||||
|
onSend,
|
||||||
|
isVisible,
|
||||||
|
onMoreClick,
|
||||||
|
onKeepFocus,
|
||||||
|
}) => {
|
||||||
|
const { sendKey } = useSpecialKeys({ onSend });
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendKey(key);
|
||||||
|
onKeepFocus?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMorePointerDown = (e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onMoreClick?.();
|
||||||
|
onKeepFocus?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`special-keys-strip ${isVisible ? "visible" : "hidden"}`}>
|
||||||
|
{PRIMARY_KEYS.map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className="special-key-button"
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Send ${label}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{onMoreClick && (
|
||||||
|
<button
|
||||||
|
className="special-key-button special-key-more"
|
||||||
|
onPointerDown={handleMorePointerDown}
|
||||||
|
type="button"
|
||||||
|
aria-label="More special keys"
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { Terminal } from "xterm";
|
import { Terminal } from "xterm";
|
||||||
import { FitAddon } from "xterm-addon-fit";
|
import { FitAddon } from "xterm-addon-fit";
|
||||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||||
@@ -7,23 +7,120 @@ import "xterm/css/xterm.css";
|
|||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
onTerminalReady?: (
|
||||||
|
sendData: (data: string) => void,
|
||||||
|
connectionStatus: "connecting" | "connected" | "disconnected" | "error",
|
||||||
|
focusInput: () => void
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
|
const FONT_SIZE_KEY = "terminal-font-size";
|
||||||
|
const MIN_FONT_SIZE = 16;
|
||||||
|
const MAX_FONT_SIZE = 24;
|
||||||
|
const RECONNECT_ATTEMPTS = 3;
|
||||||
|
const RECONNECT_DELAY_BASE = 1000;
|
||||||
|
|
||||||
|
export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||||
|
instanceId,
|
||||||
|
onClose,
|
||||||
|
isMobile = false,
|
||||||
|
onTerminalReady,
|
||||||
|
}) => {
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hiddenInputRef = useRef<HTMLInputElement>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
|
const termRef = useRef<Terminal | null>(null);
|
||||||
"connecting",
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
const onTerminalReadyRef = useRef(onTerminalReady);
|
||||||
|
onTerminalReadyRef.current = onTerminalReady;
|
||||||
|
const [status, setStatus] = useState<
|
||||||
|
"connecting" | "connected" | "disconnected" | "error"
|
||||||
|
>("connecting");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fontSize, setFontSize] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return isMobile ? 16 : 14;
|
||||||
|
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
||||||
|
return stored ? parseInt(stored, 10) : isMobile ? 16 : 14;
|
||||||
|
});
|
||||||
|
|
||||||
|
const calculateFontSize = useCallback(() => {
|
||||||
|
if (!isMobile) return fontSize;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const calculated = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, vw / 25));
|
||||||
|
return Math.round(calculated);
|
||||||
|
}, [isMobile, fontSize]);
|
||||||
|
|
||||||
|
const connectWebSocket = useCallback(() => {
|
||||||
|
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||||
|
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setStatus("connected");
|
||||||
|
setError(null);
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (!termRef.current) return;
|
||||||
|
|
||||||
|
if (event.data instanceof Blob) {
|
||||||
|
event.data.arrayBuffer().then((buffer) => {
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
termRef.current?.write(data);
|
||||||
|
});
|
||||||
|
} else if (typeof event.data === "string") {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === "status" && msg.status === "connected") {
|
||||||
|
setStatus("connected");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
termRef.current?.write(event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
setStatus("disconnected");
|
||||||
|
if (event.code !== 1000) {
|
||||||
|
setError(`Connection closed (code: ${event.code})`);
|
||||||
|
|
||||||
|
// Attempt reconnection
|
||||||
|
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
||||||
|
reconnectAttemptsRef.current++;
|
||||||
|
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.visibilityState !== "hidden") {
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setStatus("error");
|
||||||
|
setError("WebSocket error");
|
||||||
|
};
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminalRef.current) return;
|
if (!terminalRef.current) return;
|
||||||
|
|
||||||
// Initialize terminal
|
// Initialize terminal
|
||||||
|
const currentFontSize = calculateFontSize();
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: currentFontSize,
|
||||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
theme: {
|
theme: {
|
||||||
background: "#1e1e1e",
|
background: "#1e1e1e",
|
||||||
@@ -49,57 +146,18 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
termRef.current = term;
|
||||||
|
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
term.loadAddon(new WebLinksAddon());
|
term.loadAddon(new WebLinksAddon());
|
||||||
|
|
||||||
term.open(terminalRef.current);
|
term.open(terminalRef.current);
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
|
|
||||||
// Build WebSocket URL
|
|
||||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
|
||||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
||||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
|
||||||
|
|
||||||
// Connect WebSocket
|
// Connect WebSocket
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = connectWebSocket();
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
setStatus("connected");
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
if (event.data instanceof Blob) {
|
|
||||||
event.data.arrayBuffer().then((buffer) => {
|
|
||||||
const data = new Uint8Array(buffer);
|
|
||||||
term.write(data);
|
|
||||||
});
|
|
||||||
} else if (typeof event.data === "string") {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.type === "status" && msg.status === "connected") {
|
|
||||||
setStatus("connected");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
term.write(event.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
|
||||||
setStatus("disconnected");
|
|
||||||
if (event.code !== 1000) {
|
|
||||||
setError(`Connection closed (code: ${event.code})`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
setStatus("error");
|
|
||||||
setError("WebSocket error");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle terminal input
|
// Handle terminal input
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
@@ -108,8 +166,11 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize with debounce
|
||||||
|
let resizeTimeout: ReturnType<typeof setTimeout>;
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
|
clearTimeout(resizeTimeout);
|
||||||
|
resizeTimeout = setTimeout(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
const { cols, rows } = term;
|
const { cols, rows } = term;
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -118,9 +179,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
|
|||||||
type: "resize",
|
type: "resize",
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}, 250);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
@@ -128,31 +190,192 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
|
|||||||
// Initial resize
|
// Initial resize
|
||||||
setTimeout(handleResize, 100);
|
setTimeout(handleResize, 100);
|
||||||
|
|
||||||
|
// Notify parent about terminal readiness
|
||||||
|
if (onTerminalReadyRef.current) {
|
||||||
|
const sendData = (data: string) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const focusInput = () => {
|
||||||
|
hiddenInputRef.current?.focus();
|
||||||
|
};
|
||||||
|
onTerminalReadyRef.current(sendData, status, focusInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility API for reconnection
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "visible" && ws.readyState !== WebSocket.OPEN) {
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
clearTimeout(resizeTimeout);
|
||||||
window.removeEventListener("resize", handleResize);
|
window.removeEventListener("resize", handleResize);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
ws.close();
|
ws.close();
|
||||||
term.dispose();
|
term.dispose();
|
||||||
};
|
};
|
||||||
}, [instanceId]);
|
}, [instanceId, connectWebSocket, calculateFontSize]);
|
||||||
|
|
||||||
|
// Update parent about status changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (onTerminalReady && termRef.current) {
|
||||||
|
const sendData = (data: string) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const focusInput = () => {
|
||||||
|
hiddenInputRef.current?.focus();
|
||||||
|
};
|
||||||
|
onTerminalReady(sendData, status, focusInput);
|
||||||
|
}
|
||||||
|
}, [status, onTerminalReady]);
|
||||||
|
|
||||||
|
const handleFontSizeChange = (delta: number) => {
|
||||||
|
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
|
||||||
|
setFontSize(newSize);
|
||||||
|
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
||||||
|
if (termRef.current) {
|
||||||
|
termRef.current.options.fontSize = newSize;
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!termRef.current) return;
|
||||||
|
const selection = termRef.current.getSelection();
|
||||||
|
if (selection) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(selection);
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = selection;
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(text);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Clipboard API not available
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Focus hidden input on mobile to keep keyboard open
|
||||||
|
const handleTerminalClick = () => {
|
||||||
|
if (isMobile && hiddenInputRef.current) {
|
||||||
|
hiddenInputRef.current.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="terminal-wrapper">
|
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
|
||||||
<div className="terminal-header">
|
<div className="terminal-header">
|
||||||
|
<div className="terminal-header-left">
|
||||||
<div className="terminal-status">
|
<div className="terminal-status">
|
||||||
<span
|
<span
|
||||||
className={`status-dot ${status}`}
|
className={`status-dot ${status}`}
|
||||||
aria-label={`Terminal status: ${status}`}
|
aria-label={`Terminal status: ${status}`}
|
||||||
/>
|
/>
|
||||||
<span className="status-text">{status}</span>
|
<span className="status-text">
|
||||||
|
{reconnectAttemptsRef.current > 0 && status !== "connected"
|
||||||
|
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
|
||||||
|
: status}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isMobile && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
type="button"
|
||||||
|
aria-label="Copy selection"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={handlePaste}
|
||||||
|
type="button"
|
||||||
|
aria-label="Paste from clipboard"
|
||||||
|
>
|
||||||
|
Paste
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="terminal-header-right">
|
||||||
|
{isMobile && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={() => handleFontSizeChange(-1)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Decrease font size"
|
||||||
|
>
|
||||||
|
A-
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={() => handleFontSizeChange(1)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Increase font size"
|
||||||
|
>
|
||||||
|
A+
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button className="terminal-close" onClick={onClose} type="button">
|
<button className="terminal-close" onClick={onClose} type="button">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="terminal-error">{error}</div>}
|
</div>
|
||||||
<div ref={terminalRef} className="terminal-container" />
|
{error && (
|
||||||
|
<div className="terminal-error">
|
||||||
|
{error}
|
||||||
|
{status === "error" && (
|
||||||
|
<button
|
||||||
|
className="terminal-reconnect"
|
||||||
|
onClick={() => {
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
connectWebSocket();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={terminalRef}
|
||||||
|
className="terminal-container"
|
||||||
|
onClick={handleTerminalClick}
|
||||||
|
/>
|
||||||
|
{isMobile && (
|
||||||
|
<input
|
||||||
|
ref={hiddenInputRef}
|
||||||
|
type="text"
|
||||||
|
className="terminal-hidden-input"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
interface AutoHideOptions {
|
||||||
|
timeout?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoHide(options: AutoHideOptions = {}) {
|
||||||
|
const { timeout = 3000, enabled = true } = options;
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const lastInteractionRef = useRef(Date.now());
|
||||||
|
|
||||||
|
const show = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
setIsVisible(true);
|
||||||
|
lastInteractionRef.current = Date.now();
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
}, timeout);
|
||||||
|
}, [enabled, timeout]);
|
||||||
|
|
||||||
|
const hide = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
setIsVisible(false);
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (isVisible) {
|
||||||
|
hide();
|
||||||
|
} else {
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
}, [enabled, isVisible, show, hide]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setIsVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the timer initially
|
||||||
|
show();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [enabled, show]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isVisible,
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
toggle,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export function useMobileViewport() {
|
||||||
|
const [isMobile, setIsMobile] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export type SpecialKey =
|
||||||
|
| "escape"
|
||||||
|
| "tab"
|
||||||
|
| "ctrl"
|
||||||
|
| "alt"
|
||||||
|
| "up"
|
||||||
|
| "down"
|
||||||
|
| "left"
|
||||||
|
| "right"
|
||||||
|
| "home"
|
||||||
|
| "end"
|
||||||
|
| "pageup"
|
||||||
|
| "pagedown"
|
||||||
|
| "ctrlc"
|
||||||
|
| "ctrld"
|
||||||
|
| "ctrlz"
|
||||||
|
| "f1"
|
||||||
|
| "f2"
|
||||||
|
| "f3"
|
||||||
|
| "f4"
|
||||||
|
| "f5"
|
||||||
|
| "f6"
|
||||||
|
| "f7"
|
||||||
|
| "f8"
|
||||||
|
| "f9"
|
||||||
|
| "f10"
|
||||||
|
| "f11"
|
||||||
|
| "f12";
|
||||||
|
|
||||||
|
const KEY_SEQUENCES: Record<SpecialKey, string> = {
|
||||||
|
escape: "\x1B",
|
||||||
|
tab: "\t",
|
||||||
|
ctrl: "",
|
||||||
|
alt: "",
|
||||||
|
up: "\x1B[A",
|
||||||
|
down: "\x1B[B",
|
||||||
|
right: "\x1B[C",
|
||||||
|
left: "\x1B[D",
|
||||||
|
home: "\x1B[H",
|
||||||
|
end: "\x1B[F",
|
||||||
|
pageup: "\x1B[5~",
|
||||||
|
pagedown: "\x1B[6~",
|
||||||
|
ctrlc: "\x03",
|
||||||
|
ctrld: "\x04",
|
||||||
|
ctrlz: "\x1A",
|
||||||
|
f1: "\x1BOP",
|
||||||
|
f2: "\x1BOQ",
|
||||||
|
f3: "\x1BOR",
|
||||||
|
f4: "\x1BOS",
|
||||||
|
f5: "\x1B[15~",
|
||||||
|
f6: "\x1B[17~",
|
||||||
|
f7: "\x1B[18~",
|
||||||
|
f8: "\x1B[19~",
|
||||||
|
f9: "\x1B[20~",
|
||||||
|
f10: "\x1B[21~",
|
||||||
|
f11: "\x1B[23~",
|
||||||
|
f12: "\x1B[24~",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseSpecialKeysOptions {
|
||||||
|
onSend: (data: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpecialKeys({ onSend }: UseSpecialKeysOptions) {
|
||||||
|
const sendKey = useCallback(
|
||||||
|
(key: SpecialKey) => {
|
||||||
|
const sequence = KEY_SEQUENCES[key];
|
||||||
|
if (sequence) {
|
||||||
|
onSend(sequence);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSend]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { sendKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { KEY_SEQUENCES };
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
interface VirtualKeyboardState {
|
||||||
|
isOpen: boolean;
|
||||||
|
height: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVirtualKeyboard() {
|
||||||
|
const [state, setState] = useState<VirtualKeyboardState>({
|
||||||
|
isOpen: false,
|
||||||
|
height: 0,
|
||||||
|
viewportHeight: typeof window !== "undefined" ? window.innerHeight : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateKeyboardState = useCallback(() => {
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
|
||||||
|
if (visualViewport) {
|
||||||
|
const viewportHeight = visualViewport.height;
|
||||||
|
const keyboardHeight = windowHeight - viewportHeight;
|
||||||
|
const isOpen = keyboardHeight > 100; // Threshold to avoid false positives
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isOpen,
|
||||||
|
height: keyboardHeight,
|
||||||
|
viewportHeight,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: compare window height to a stored reference
|
||||||
|
// This is less reliable but works on older browsers
|
||||||
|
const currentHeight = windowHeight;
|
||||||
|
const isOpen = currentHeight < state.viewportHeight - 100;
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
isOpen,
|
||||||
|
height: isOpen ? prev.viewportHeight - currentHeight : 0,
|
||||||
|
viewportHeight: isOpen ? prev.viewportHeight : currentHeight,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [state.viewportHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
|
||||||
|
if (visualViewport) {
|
||||||
|
visualViewport.addEventListener("resize", updateKeyboardState);
|
||||||
|
visualViewport.addEventListener("scroll", updateKeyboardState);
|
||||||
|
} else {
|
||||||
|
window.addEventListener("resize", updateKeyboardState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
updateKeyboardState();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (visualViewport) {
|
||||||
|
visualViewport.removeEventListener("resize", updateKeyboardState);
|
||||||
|
visualViewport.removeEventListener("scroll", updateKeyboardState);
|
||||||
|
} else {
|
||||||
|
window.removeEventListener("resize", updateKeyboardState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [updateKeyboardState]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||||
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
|
import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { updateUserConfig } from "../api/settings";
|
import { updateUserConfig } from "../api/settings";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
|
|
||||||
type HomeStatus = "loading" | "ready" | "error";
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -29,11 +30,10 @@ export const HomePage = () => {
|
|||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState("");
|
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 [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||||
|
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||||
|
|
||||||
const loadHome = useCallback(async () => {
|
const loadHome = useCallback(async () => {
|
||||||
@@ -59,6 +59,49 @@ export const HomePage = () => {
|
|||||||
void loadHome();
|
void loadHome();
|
||||||
}, [loadHome]);
|
}, [loadHome]);
|
||||||
|
|
||||||
|
// Poll tunnel health every 30 seconds for running instances
|
||||||
|
useEffect(() => {
|
||||||
|
const checkHealth = async () => {
|
||||||
|
const runningSessions = safeSessions.filter(
|
||||||
|
(s) => s.status === "running" && s.url
|
||||||
|
);
|
||||||
|
for (const session of runningSessions) {
|
||||||
|
try {
|
||||||
|
const health = await checkInstanceHealth(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id
|
||||||
|
);
|
||||||
|
setTunnelHealth((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[session.id]: health,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setTunnelHealth((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[session.id]: {
|
||||||
|
healthy: false,
|
||||||
|
container_status: "unknown",
|
||||||
|
container_health: null,
|
||||||
|
container_exit_code: null,
|
||||||
|
tunnel_status: "error",
|
||||||
|
tunnel_status_code: null,
|
||||||
|
probe_status: "error",
|
||||||
|
last_probe_output: null,
|
||||||
|
error: "check failed",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkHealth();
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void checkHealth();
|
||||||
|
}, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [safeSessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
setRepositories([]);
|
setRepositories([]);
|
||||||
@@ -87,24 +130,10 @@ export const HomePage = () => {
|
|||||||
[safeSessions]
|
[safeSessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = async (event: React.FormEvent) => {
|
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||||
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 });
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
setDisplayName("");
|
|
||||||
setSelectedProject("");
|
setSelectedProject("");
|
||||||
setSelectedRepo("");
|
|
||||||
setSelectedToolType("");
|
|
||||||
setSaveState("idle");
|
|
||||||
await loadHome();
|
await loadHome();
|
||||||
} catch {
|
|
||||||
setSaveState("error");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpen = (session: SessionView) => {
|
const handleOpen = (session: SessionView) => {
|
||||||
@@ -120,7 +149,12 @@ export const HomePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (session: SessionView) => {
|
const handleStop = async (session: SessionView) => {
|
||||||
|
if (stopConfirmId !== session.id) {
|
||||||
|
setStopConfirmId(session.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setActionBusy(session.id);
|
setActionBusy(session.id);
|
||||||
|
setStopConfirmId(null);
|
||||||
try {
|
try {
|
||||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||||
await loadHome();
|
await loadHome();
|
||||||
@@ -130,10 +164,17 @@ export const HomePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (session: SessionView) => {
|
const handleDelete = async (session: SessionView) => {
|
||||||
|
if (deleteConfirmId !== session.id) {
|
||||||
|
setDeleteConfirmId(session.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setActionBusy(session.id);
|
setActionBusy(session.id);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
try {
|
try {
|
||||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||||
await loadHome();
|
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||||
|
} catch {
|
||||||
|
// error - session remains in state
|
||||||
} finally {
|
} finally {
|
||||||
setActionBusy(null);
|
setActionBusy(null);
|
||||||
}
|
}
|
||||||
@@ -203,13 +244,21 @@ export const HomePage = () => {
|
|||||||
<p className="muted">No active sessions right now.</p>
|
<p className="muted">No active sessions right now.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="home-session-grid">
|
<div className="home-session-grid">
|
||||||
{activeSessions.map((session) => (
|
{activeSessions.map((session) => {
|
||||||
|
const health = tunnelHealth[session.id];
|
||||||
|
const isUnhealthy = health && !health.healthy;
|
||||||
|
return (
|
||||||
<article className="card session-card" key={session.id}>
|
<article className="card session-card" key={session.id}>
|
||||||
<div className="stack-sm">
|
<div className="stack-sm">
|
||||||
<div className="row row-tight">
|
<div className="row row-tight">
|
||||||
<h3>{session.display_name}</h3>
|
<h3>{session.display_name}</h3>
|
||||||
|
<div className="row row-tight">
|
||||||
|
{isUnhealthy && (
|
||||||
|
<span className="status-badge error" title={health.error || "unhealthy"}>!</span>
|
||||||
|
)}
|
||||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
||||||
<p className="muted">{session.tool_type_name}</p>
|
<p className="muted">{session.tool_type_name}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,17 +271,38 @@ export const HomePage = () => {
|
|||||||
<Icon name="refresh" size="sm" />
|
<Icon name="refresh" size="sm" />
|
||||||
Tunnel
|
Tunnel
|
||||||
</button>
|
</button>
|
||||||
|
{stopConfirmId === session.id ? (
|
||||||
|
<div className="stop-confirm-inline">
|
||||||
|
<span className="confirm-text">Stop?</span>
|
||||||
|
<button className="ghost-button small danger-text" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||||
|
<Icon name="stop" size="sm" /> Stop
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => setStopConfirmId(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||||
<Icon name="stop" size="sm" />
|
<Icon name="stop" size="sm" />
|
||||||
Stop
|
Stop
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{deleteConfirmId === session.id ? (
|
||||||
|
<div className="delete-confirm-inline">
|
||||||
|
<span className="confirm-text">Delete?</span>
|
||||||
|
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||||
|
<Icon name="delete" size="sm" /> Delete
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => setDeleteConfirmId(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||||
<Icon name="delete" size="sm" />
|
<Icon name="delete" size="sm" />
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -271,41 +341,13 @@ export const HomePage = () => {
|
|||||||
<h2>Start a session</h2>
|
<h2>Start a session</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form className="stack create-session-form" onSubmit={handleCreate}>
|
<CreateSessionForm
|
||||||
<div className="form-row">
|
projects={projects}
|
||||||
<label className="form-field">
|
repositories={repositories}
|
||||||
Project
|
toolTypes={toolTypes}
|
||||||
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
|
onProjectChange={(projectId) => setSelectedProject(projectId)}
|
||||||
<option value="">Select project...</option>
|
onSuccess={handleCreateSuccess}
|
||||||
{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>
|
</section>
|
||||||
|
|
||||||
{recentSessions.length > 0 && (
|
{recentSessions.length > 0 && (
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export const RepoWorkspace = () => {
|
|||||||
currentBranch={currentBranch}
|
currentBranch={currentBranch}
|
||||||
branches={branches}
|
branches={branches}
|
||||||
hasRemote={Boolean(selectedRepo?.remote_url)}
|
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||||
|
isMirror={Boolean(selectedRepo?.is_mirror)}
|
||||||
onBranchChange={(branch) => {
|
onBranchChange={(branch) => {
|
||||||
setCurrentBranch(branch);
|
setCurrentBranch(branch);
|
||||||
const newParams = new URLSearchParams(searchParams);
|
const newParams = new URLSearchParams(searchParams);
|
||||||
@@ -253,6 +254,8 @@ export const RepoWorkspace = () => {
|
|||||||
<InstanceList
|
<InstanceList
|
||||||
projectId={projectId!}
|
projectId={projectId!}
|
||||||
repoId={selectedRepoId}
|
repoId={selectedRepoId}
|
||||||
|
projectName={project?.name}
|
||||||
|
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
||||||
toolTypes={toolTypes}
|
toolTypes={toolTypes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+149
-130
@@ -9,17 +9,15 @@ import {
|
|||||||
type Session,
|
type Session,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
stopInstance,
|
stopInstance,
|
||||||
startInstance,
|
|
||||||
checkInstanceHealth,
|
checkInstanceHealth,
|
||||||
recreateInstanceTunnel,
|
recreateInstanceTunnel,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { createInstance } from "../api/sessions";
|
|
||||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
|
|
||||||
type SessionsStatus = "loading" | "ready" | "error";
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
type CreateStatus = "idle" | "creating" | "error";
|
|
||||||
|
|
||||||
export const SessionsPage = () => {
|
export const SessionsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -30,18 +28,27 @@ export const SessionsPage = () => {
|
|||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
|
||||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||||
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
|
||||||
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||||
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
|
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
const [stopConfirmId, setStopConfirmId] = 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 [recreatingId, setRecreatingId] = useState<string | null>(null);
|
||||||
|
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
|
||||||
|
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||||
|
const [loadingAction, setLoadingAction] = useState<string>("");
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -86,13 +93,15 @@ export const SessionsPage = () => {
|
|||||||
void loadToolTypes();
|
void loadToolTypes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Poll tunnel health every 30 seconds for running instances
|
|
||||||
|
|
||||||
|
// Poll health every 30 seconds for active instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkHealth = async () => {
|
const checkHealth = async () => {
|
||||||
const runningSessions = sessions.filter(
|
const activeSessions = sessions.filter(
|
||||||
(s) => s.status === "running" && s.url
|
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
|
||||||
);
|
);
|
||||||
for (const session of runningSessions) {
|
for (const session of activeSessions) {
|
||||||
try {
|
try {
|
||||||
const health = await checkInstanceHealth(
|
const health = await checkInstanceHealth(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -106,7 +115,16 @@ export const SessionsPage = () => {
|
|||||||
} catch {
|
} catch {
|
||||||
setTunnelHealth((prev) => ({
|
setTunnelHealth((prev) => ({
|
||||||
...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 +153,7 @@ export const SessionsPage = () => {
|
|||||||
}, [selectedProject]);
|
}, [selectedProject]);
|
||||||
|
|
||||||
const activeSessions = useMemo(
|
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]
|
[sessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -149,63 +167,58 @@ export const SessionsPage = () => {
|
|||||||
[sessions, lastSessionId]
|
[sessions, lastSessionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||||
e.preventDefault();
|
|
||||||
setCreateError(null);
|
|
||||||
|
|
||||||
if (!selectedProject || !selectedRepo || !selectedToolType) {
|
|
||||||
setCreateError("Project, repository, and tool type are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreateStatus("creating");
|
|
||||||
try {
|
|
||||||
const instance = await createInstance(
|
|
||||||
selectedProject,
|
|
||||||
selectedRepo,
|
|
||||||
selectedToolType,
|
|
||||||
displayName || undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-start the instance
|
|
||||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
|
||||||
|
|
||||||
await updateUserConfig({ last_session_id: instance.id });
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
setCreateStatus("idle");
|
|
||||||
setSelectedProject("");
|
setSelectedProject("");
|
||||||
setSelectedRepo("");
|
|
||||||
setSelectedToolType("");
|
|
||||||
setDisplayName("");
|
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} catch {
|
|
||||||
setCreateStatus("error");
|
|
||||||
setCreateError("Failed to create session");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
||||||
|
setLoadingSessionId(sessionId);
|
||||||
|
setLoadingAction("Stopping...");
|
||||||
try {
|
try {
|
||||||
await stopInstance(projectId, repoId, sessionId);
|
await stopInstance(projectId, repoId, sessionId);
|
||||||
setStopConfirmId(null);
|
setStopConfirmId(null);
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} catch {
|
} catch {
|
||||||
setStopConfirmId(null);
|
setStopConfirmId(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
setLoadingAction("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (sessionId: string, projectId: string, repoId: string) => {
|
const handleDelete = async (sessionId: string, projectId: string, repoId: string, force = false) => {
|
||||||
|
setLoadingSessionId(sessionId);
|
||||||
|
setLoadingAction("Deleting...");
|
||||||
try {
|
try {
|
||||||
await deleteInstance(projectId, repoId, sessionId);
|
await deleteInstance(projectId, repoId, sessionId, force);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
|
setDirtyDeleteSession(null);
|
||||||
|
setDirtyDeleteFiles([]);
|
||||||
// Remove from local state immediately
|
// Remove from local state immediately
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
||||||
|
if (axiosError.response?.status === 409) {
|
||||||
|
const detail = axiosError.response.data?.detail;
|
||||||
|
if (detail?.changed_files) {
|
||||||
|
setDirtyDeleteSession(sessions.find((s) => s.id === sessionId) ?? null);
|
||||||
|
setDirtyDeleteFiles(detail.changed_files);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
setLoadingAction("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRecreateTunnel = async (session: Session) => {
|
const handleRecreateTunnel = async (session: Session) => {
|
||||||
setRecreatingId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
|
setLoadingAction("Recreating tunnel...");
|
||||||
try {
|
try {
|
||||||
await recreateInstanceTunnel(
|
await recreateInstanceTunnel(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -217,7 +230,8 @@ export const SessionsPage = () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
setRecreatingId(null);
|
setLoadingSessionId(null);
|
||||||
|
setLoadingAction("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -302,7 +316,15 @@ export const SessionsPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Sessions */}
|
{/* Active Sessions */}
|
||||||
<div className="active-sessions-section">
|
<div className={`active-sessions-section ${loadingSessionId ? "dimmed" : ""}`}>
|
||||||
|
{loadingSessionId && (
|
||||||
|
<div className="loading-overlay">
|
||||||
|
<div className="loading-content">
|
||||||
|
<Icon name="loading" size="lg" />
|
||||||
|
<p>{loadingAction}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h2>
|
<h2>
|
||||||
Active Sessions
|
Active Sessions
|
||||||
{activeSessions.length > 0 && (
|
{activeSessions.length > 0 && (
|
||||||
@@ -328,9 +350,36 @@ export const SessionsPage = () => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
<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>
|
<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]?.probe_status && tunnelHealth[session.id]?.probe_status !== "not_applicable" && (
|
||||||
|
<div className="probe-output-section">
|
||||||
|
<button
|
||||||
|
className={`probe-toggle probe-${tunnelHealth[session.id].probe_status}`}
|
||||||
|
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="info" size="sm" />
|
||||||
|
Probe: {tunnelHealth[session.id].probe_status}
|
||||||
|
{expandedProbeId === session.id ? " (hide)" : " (show)"}
|
||||||
|
</button>
|
||||||
|
{expandedProbeId === session.id && tunnelHealth[session.id]?.last_probe_output && (
|
||||||
|
<pre className="probe-output">
|
||||||
|
{tunnelHealth[session.id].last_probe_output}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="session-actions">
|
<div className="session-actions">
|
||||||
{session.url ? (
|
{session.url ? (
|
||||||
@@ -353,7 +402,7 @@ export const SessionsPage = () => {
|
|||||||
Open
|
Open
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
|
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
onClick={() => void handleRecreateTunnel(session)}
|
onClick={() => void handleRecreateTunnel(session)}
|
||||||
@@ -511,91 +560,61 @@ export const SessionsPage = () => {
|
|||||||
{/* Create Session */}
|
{/* Create Session */}
|
||||||
<div className="create-session-section">
|
<div className="create-session-section">
|
||||||
<h2>Create New Session</h2>
|
<h2>Create New Session</h2>
|
||||||
<form onSubmit={handleCreate} className="card stack create-session-form">
|
<CreateSessionForm
|
||||||
<div className="form-row">
|
projects={projects}
|
||||||
<label className="form-field">
|
repositories={repositories}
|
||||||
Project
|
toolTypes={toolTypes}
|
||||||
<select
|
onProjectChange={(projectId) => {
|
||||||
value={selectedProject}
|
setSelectedProject(projectId);
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedProject(e.target.value);
|
|
||||||
setSelectedRepo("");
|
|
||||||
}}
|
}}
|
||||||
>
|
onSuccess={handleCreateSuccess}
|
||||||
<option value="">Select project...</option>
|
/>
|
||||||
{projects.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="form-field">
|
|
||||||
Repository
|
|
||||||
<select
|
|
||||||
value={selectedRepo}
|
|
||||||
onChange={(e) => setSelectedRepo(e.target.value)}
|
|
||||||
disabled={!selectedProject}
|
|
||||||
>
|
|
||||||
<option value="">Select repository...</option>
|
|
||||||
{repositories.map((r) => (
|
|
||||||
<option key={r.id} value={r.id}>
|
|
||||||
{r.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="form-field">
|
|
||||||
Tool Type
|
|
||||||
<select
|
|
||||||
value={selectedToolType}
|
|
||||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Select tool...</option>
|
|
||||||
{toolTypes.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{t.display_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="form-field">
|
{/* Dirty Delete Confirmation Modal */}
|
||||||
Display Name (optional)
|
{dirtyDeleteSession && (
|
||||||
<input
|
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
|
||||||
type="text"
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
value={displayName}
|
<h3>Uncommitted Changes</h3>
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
<p>
|
||||||
placeholder="My Development Environment"
|
The repository <strong>{dirtyDeleteSession.repository_name}</strong> has
|
||||||
/>
|
uncommitted changes. Deleting this session will permanently lose these
|
||||||
</label>
|
changes.
|
||||||
|
</p>
|
||||||
{createError && <p className="error-text">{createError}</p>}
|
<div className="changed-files-list">
|
||||||
|
<h4>Changed files:</h4>
|
||||||
<div className="form-actions">
|
<ul>
|
||||||
|
{dirtyDeleteFiles.map((file, idx) => (
|
||||||
|
<li key={idx}>{file}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="secondary-button"
|
||||||
type="submit"
|
onClick={() => setDirtyDeleteSession(null)}
|
||||||
disabled={createStatus === "creating"}
|
type="button"
|
||||||
>
|
>
|
||||||
{createStatus === "creating" ? (
|
Cancel
|
||||||
<>
|
</button>
|
||||||
<Icon name="loading" size="sm" />
|
<button
|
||||||
Creating...
|
className="danger-button"
|
||||||
</>
|
onClick={() =>
|
||||||
) : (
|
void handleDelete(
|
||||||
<>
|
dirtyDeleteSession.id,
|
||||||
<Icon name="add" size="sm" />
|
dirtyDeleteSession.project_id,
|
||||||
Create Session
|
dirtyDeleteSession.repository_id,
|
||||||
</>
|
true
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Force Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ type SettingsStatus = "loading" | "ready" | "error";
|
|||||||
const TABS = [
|
const TABS = [
|
||||||
{ label: "General", path: "general" },
|
{ label: "General", path: "general" },
|
||||||
{ label: "SSH Keys", path: "ssh-keys" },
|
{ label: "SSH Keys", path: "ssh-keys" },
|
||||||
{ label: "Tool Types", path: "tool-types" },
|
|
||||||
{ label: "Tool Configs", path: "tool-configs" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
@@ -106,7 +104,7 @@ export const SettingsPage = () => {
|
|||||||
<p className="eyebrow">Configuration</p>
|
<p className="eyebrow">Configuration</p>
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
<p className="muted">General preferences and SSH keys.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="settings-tabs" aria-label="Settings sections">
|
<nav className="settings-tabs" aria-label="Settings sections">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
export const SSHKeysPage = () => {
|
export const SSHKeysPage = () => {
|
||||||
@@ -10,6 +10,13 @@ export const SSHKeysPage = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [newKeyName, setNewKeyName] = useState("");
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||||
|
const [signatures, setSignatures] = useState<Record<string, string>>({});
|
||||||
|
const [signing, setSigning] = useState<Record<string, boolean>>({});
|
||||||
|
const [verifyPayloads, setVerifyPayloads] = useState<Record<string, string>>({});
|
||||||
|
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
||||||
|
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
||||||
|
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadKeys();
|
loadKeys();
|
||||||
@@ -59,6 +66,42 @@ export const SSHKeysPage = () => {
|
|||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSign(keyId: string) {
|
||||||
|
const payload = signPayloads[keyId];
|
||||||
|
if (!payload?.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
||||||
|
const result = await signPayload(keyId, { payload: payload.trim() });
|
||||||
|
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to sign payload");
|
||||||
|
} finally {
|
||||||
|
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerify(keyId: string) {
|
||||||
|
const payload = verifyPayloads[keyId];
|
||||||
|
const signature = verifySignatures[keyId];
|
||||||
|
if (!payload?.trim() || !signature?.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setVerifying((prev) => ({ ...prev, [keyId]: true }));
|
||||||
|
const result = await verifySignature(keyId, {
|
||||||
|
payload: payload.trim(),
|
||||||
|
signature: signature.trim(),
|
||||||
|
});
|
||||||
|
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to verify signature");
|
||||||
|
} finally {
|
||||||
|
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
if (loading) return <div>Loading...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,6 +176,110 @@ export const SSHKeysPage = () => {
|
|||||||
Copy Full Key
|
Copy Full Key
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="key-signing">
|
||||||
|
<h4>Sign Payload</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={signPayloads[key.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSignPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter payload to sign..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSign(key.id)}
|
||||||
|
disabled={signing[key.id] || !signPayloads[key.id]?.trim()}
|
||||||
|
className="primary-button"
|
||||||
|
>
|
||||||
|
{signing[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Signing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Sign
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{signatures[key.id] && (
|
||||||
|
<div className="signature-result">
|
||||||
|
<label>Signature (base64):</label>
|
||||||
|
<code>{signatures[key.id]}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(signatures[key.id])}
|
||||||
|
className="secondary-button"
|
||||||
|
>
|
||||||
|
<Icon name="copy" size="sm" />
|
||||||
|
Copy Signature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="key-verification">
|
||||||
|
<h4>Verify Signature</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={verifyPayloads[key.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVerifyPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter payload..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={verifySignatures[key.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVerifySignatures((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter base64 signature..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleVerify(key.id)}
|
||||||
|
disabled={
|
||||||
|
verifying[key.id] ||
|
||||||
|
!verifyPayloads[key.id]?.trim() ||
|
||||||
|
!verifySignatures[key.id]?.trim()
|
||||||
|
}
|
||||||
|
className="primary-button"
|
||||||
|
>
|
||||||
|
{verifying[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="success" size="sm" />
|
||||||
|
Verify
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{verifyResults[key.id] !== undefined && verifyResults[key.id] !== null && (
|
||||||
|
<div className={`verify-result ${verifyResults[key.id] ? "valid" : "invalid"}`}>
|
||||||
|
{verifyResults[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="success" size="sm" />
|
||||||
|
Signature is valid
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="error" size="sm" />
|
||||||
|
Signature is invalid
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { TerminalComponent } from "../components/terminal";
|
import { TerminalComponent } from "../components/terminal";
|
||||||
import { Icon } from "../components/icon";
|
import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
|
||||||
export const TerminalPage: React.FC = () => {
|
export const TerminalPage: React.FC = () => {
|
||||||
const { instanceId } = useParams<{ instanceId: string }>();
|
const { instanceId } = useParams<{ instanceId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
return (
|
return (
|
||||||
@@ -16,6 +18,16 @@ export const TerminalPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<MobileTerminalWrapper
|
||||||
|
instanceId={instanceId}
|
||||||
|
onBack={() => navigate(-1)}
|
||||||
|
onClose={() => navigate(-1)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="terminal-page">
|
<section className="terminal-page">
|
||||||
<div className="terminal-page-header">
|
<div className="terminal-page-header">
|
||||||
@@ -24,7 +36,6 @@ export const TerminalPage: React.FC = () => {
|
|||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="arrow-left" size="sm" />
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<h1>Terminal</h1>
|
<h1>Terminal</h1>
|
||||||
@@ -32,6 +43,7 @@ export const TerminalPage: React.FC = () => {
|
|||||||
<TerminalComponent
|
<TerminalComponent
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
onClose={() => navigate(-1)}
|
onClose={() => navigate(-1)}
|
||||||
|
isMobile={false}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
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";
|
|
||||||
import {
|
|
||||||
createToolConfig,
|
|
||||||
deleteToolConfig,
|
|
||||||
listToolConfigs,
|
|
||||||
updateToolConfig,
|
|
||||||
type ToolConfig,
|
|
||||||
} from "../api/tool_configs";
|
|
||||||
|
|
||||||
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[]>([]);
|
|
||||||
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
key: "",
|
|
||||||
value: "",
|
|
||||||
config_type: "env",
|
|
||||||
file_path: "",
|
|
||||||
});
|
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [typesData, configsData] = await Promise.all([
|
|
||||||
listToolTypes(),
|
|
||||||
listToolConfigs(),
|
|
||||||
]);
|
|
||||||
setToolTypes(typesData);
|
|
||||||
setConfigs(configsData);
|
|
||||||
if (typesData.length > 0 && !selectedToolType) {
|
|
||||||
setSelectedToolType(typesData[0].id);
|
|
||||||
}
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}, [selectedToolType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadData();
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSaveStatus("saving");
|
|
||||||
try {
|
|
||||||
const data = {
|
|
||||||
tool_type_id: selectedToolType,
|
|
||||||
key: formData.key,
|
|
||||||
value: formData.value,
|
|
||||||
config_type: formData.config_type,
|
|
||||||
file_path: formData.config_type === "file" ? formData.file_path : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editingConfig) {
|
|
||||||
await updateToolConfig(editingConfig.id, data);
|
|
||||||
} else {
|
|
||||||
await createToolConfig(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaveStatus("saved");
|
|
||||||
setShowForm(false);
|
|
||||||
setEditingConfig(null);
|
|
||||||
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
|
|
||||||
await loadData();
|
|
||||||
} catch {
|
|
||||||
setSaveStatus("error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (config: ToolConfig) => {
|
|
||||||
setEditingConfig(config);
|
|
||||||
setFormData({
|
|
||||||
key: config.key,
|
|
||||||
value: config.value,
|
|
||||||
config_type: config.config_type,
|
|
||||||
file_path: config.file_path || "",
|
|
||||||
});
|
|
||||||
setSelectedToolType(config.tool_type_id);
|
|
||||||
setShowForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
if (!window.confirm("Delete this config?")) return;
|
|
||||||
try {
|
|
||||||
await deleteToolConfig(id);
|
|
||||||
await loadData();
|
|
||||||
} catch {
|
|
||||||
// Error handled by UI state
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredConfigs = configs.filter(
|
|
||||||
(c) => c.tool_type_id === selectedToolType
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
|
|
||||||
|
|
||||||
if (status === "loading") {
|
|
||||||
return (
|
|
||||||
<section className="stack">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1>Tool Configurations</h1>
|
|
||||||
</div>
|
|
||||||
<p className="muted">Loading...</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "error") {
|
|
||||||
return (
|
|
||||||
<section className="stack">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1>Tool Configurations</h1>
|
|
||||||
</div>
|
|
||||||
<div className="card stack">
|
|
||||||
<p>Failed to load configurations</p>
|
|
||||||
<button className="secondary-button" onClick={() => void loadData()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="stack">
|
|
||||||
<div className="page-header">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tool Type Selector */}
|
|
||||||
<div className="card">
|
|
||||||
<label htmlFor="tool-type-select">Select Tool</label>
|
|
||||||
<select
|
|
||||||
id="tool-type-select"
|
|
||||||
value={selectedToolType}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedToolType(e.target.value);
|
|
||||||
setShowForm(false);
|
|
||||||
setEditingConfig(null);
|
|
||||||
}}
|
|
||||||
className="form-input"
|
|
||||||
>
|
|
||||||
{toolTypes.map((tool) => (
|
|
||||||
<option key={tool.id} value={tool.id}>
|
|
||||||
{tool.display_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{selectedTool && (
|
|
||||||
<p className="muted" style={{ marginTop: "0.5rem" }}>
|
|
||||||
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Config List */}
|
|
||||||
<div className="card stack">
|
|
||||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
|
||||||
<h2>Configuration Variables</h2>
|
|
||||||
<button
|
|
||||||
className="primary-button small"
|
|
||||||
onClick={() => {
|
|
||||||
setShowForm(true);
|
|
||||||
setEditingConfig(null);
|
|
||||||
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Add Config
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredConfigs.length === 0 ? (
|
|
||||||
<p className="muted">No configurations for this tool yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
|
||||||
{filteredConfigs.map((config) => (
|
|
||||||
<div
|
|
||||||
key={config.id}
|
|
||||||
className="card"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "0.75rem 1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}>
|
|
||||||
<code style={{ fontWeight: 600 }}>{config.key}</code>
|
|
||||||
<span
|
|
||||||
className="badge"
|
|
||||||
style={{
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
background: config.config_type === "env" ? "var(--color-info)" : "var(--color-warning)",
|
|
||||||
color: "white",
|
|
||||||
padding: "0.125rem 0.5rem",
|
|
||||||
borderRadius: "9999px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{config.config_type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}>
|
|
||||||
{config.config_type === "file" && config.file_path
|
|
||||||
? `File: ${config.file_path}`
|
|
||||||
: "Environment variable"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="row" style={{ gap: "0.5rem" }}>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => handleEdit(config)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="edit" size="sm" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => void handleDelete(config.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add/Edit Form */}
|
|
||||||
{showForm && (
|
|
||||||
<div className="card stack">
|
|
||||||
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
|
|
||||||
<form onSubmit={handleSubmit} className="stack">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="config-key">Key</label>
|
|
||||||
<input
|
|
||||||
id="config-key"
|
|
||||||
type="text"
|
|
||||||
value={formData.key}
|
|
||||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
|
||||||
placeholder="e.g., OPENAI_API_KEY"
|
|
||||||
className="form-input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="config-type">Type</label>
|
|
||||||
<select
|
|
||||||
id="config-type"
|
|
||||||
value={formData.config_type}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, config_type: e.target.value })
|
|
||||||
}
|
|
||||||
className="form-input"
|
|
||||||
>
|
|
||||||
<option value="env">Environment Variable</option>
|
|
||||||
<option value="file">Configuration File</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.config_type === "file" && (
|
|
||||||
<div>
|
|
||||||
<label htmlFor="config-file-path">File Path</label>
|
|
||||||
<input
|
|
||||||
id="config-file-path"
|
|
||||||
type="text"
|
|
||||||
value={formData.file_path}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, file_path: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="e.g., /app/config.json"
|
|
||||||
className="form-input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="config-value">Value</label>
|
|
||||||
<textarea
|
|
||||||
id="config-value"
|
|
||||||
value={formData.value}
|
|
||||||
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
|
|
||||||
placeholder={
|
|
||||||
formData.config_type === "env"
|
|
||||||
? "Enter value..."
|
|
||||||
: "Enter file contents..."
|
|
||||||
}
|
|
||||||
className="form-input"
|
|
||||||
rows={formData.config_type === "file" ? 8 : 2}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row" style={{ gap: "0.5rem", justifyContent: "flex-end" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowForm(false);
|
|
||||||
setEditingConfig(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="primary-button">
|
|
||||||
{editingConfig ? "Update" : "Add"} Config
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{saveStatus === "saved" && (
|
|
||||||
<p className="text-success" style={{ textAlign: "right" }}>
|
|
||||||
Saved successfully!
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{saveStatus === "error" && (
|
|
||||||
<p className="text-error" style={{ textAlign: "right" }}>
|
|
||||||
Failed to save. Please try again.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createToolType,
|
|
||||||
deleteToolType,
|
|
||||||
listToolTypes,
|
|
||||||
updateToolType,
|
|
||||||
type CreateToolTypeRequest,
|
|
||||||
type UpdateToolTypeRequest,
|
|
||||||
} from "../api/tool_types";
|
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
import type { ToolType } from "../api/tool_types";
|
|
||||||
|
|
||||||
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");
|
|
||||||
const [editingToolType, setEditingToolType] = useState<ToolType | null>(null);
|
|
||||||
const [formName, setFormName] = useState("");
|
|
||||||
const [formDisplayName, setFormDisplayName] = useState("");
|
|
||||||
const [formDescription, setFormDescription] = useState("");
|
|
||||||
const [formCategory, setFormCategory] = useState("");
|
|
||||||
const [formInterfaces, setFormInterfaces] = useState<string[]>([]);
|
|
||||||
const [formPort, setFormPort] = useState("");
|
|
||||||
const [formTemplate, setFormTemplate] = useState("");
|
|
||||||
const [formVariables, setFormVariables] = useState("");
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadToolTypes = useCallback(async () => {
|
|
||||||
setStatus("loading");
|
|
||||||
try {
|
|
||||||
const data = await listToolTypes();
|
|
||||||
setToolTypes(data);
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setToolTypes([]);
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadToolTypes();
|
|
||||||
}, [loadToolTypes]);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setFormName("");
|
|
||||||
setFormDisplayName("");
|
|
||||||
setFormDescription("");
|
|
||||||
setFormCategory("");
|
|
||||||
setFormInterfaces([]);
|
|
||||||
setFormPort("");
|
|
||||||
setFormTemplate("");
|
|
||||||
setFormVariables("");
|
|
||||||
setFormError(null);
|
|
||||||
setEditingToolType(null);
|
|
||||||
setDialogMode("create");
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (toolType: ToolType) => {
|
|
||||||
setFormName(toolType.name);
|
|
||||||
setFormDisplayName(toolType.display_name);
|
|
||||||
setFormDescription(toolType.description ?? "");
|
|
||||||
setFormCategory(toolType.category ?? "");
|
|
||||||
setFormInterfaces(toolType.interfaces ?? []);
|
|
||||||
setFormPort(toolType.default_port?.toString() ?? "");
|
|
||||||
setFormTemplate(toolType.compose_template ?? "");
|
|
||||||
setFormVariables(toolType.required_variables.join(", "));
|
|
||||||
setFormError(null);
|
|
||||||
setEditingToolType(toolType);
|
|
||||||
setDialogMode("edit");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
setDialogMode("none");
|
|
||||||
setEditingToolType(null);
|
|
||||||
setFormError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
|
|
||||||
setFormError("Name, display name, and compose template are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formPort.trim() || isNaN(Number(formPort))) {
|
|
||||||
setFormError("Default port is required and must be a number");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const variables = formVariables
|
|
||||||
.split(",")
|
|
||||||
.map((v) => v.trim())
|
|
||||||
.filter((v) => v.length > 0);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (dialogMode === "create") {
|
|
||||||
const input: CreateToolTypeRequest = {
|
|
||||||
name: formName.trim(),
|
|
||||||
display_name: formDisplayName.trim(),
|
|
||||||
description: formDescription.trim() || undefined,
|
|
||||||
category: formCategory.trim() || undefined,
|
|
||||||
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
|
||||||
default_port: Number(formPort),
|
|
||||||
compose_template: formTemplate.trim(),
|
|
||||||
required_variables: variables,
|
|
||||||
};
|
|
||||||
await createToolType(input);
|
|
||||||
} else if (dialogMode === "edit" && editingToolType) {
|
|
||||||
const input: UpdateToolTypeRequest = {
|
|
||||||
display_name: formDisplayName.trim(),
|
|
||||||
description: formDescription.trim() || undefined,
|
|
||||||
category: formCategory.trim() || undefined,
|
|
||||||
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
|
||||||
default_port: Number(formPort),
|
|
||||||
compose_template: formTemplate.trim(),
|
|
||||||
required_variables: variables,
|
|
||||||
};
|
|
||||||
await updateToolType(editingToolType.id, input);
|
|
||||||
}
|
|
||||||
closeDialog();
|
|
||||||
await loadToolTypes();
|
|
||||||
} catch (err) {
|
|
||||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
|
||||||
const detail = axiosError?.response?.data?.detail || "Failed to save tool type";
|
|
||||||
setFormError(detail);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteToolType(id);
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
await loadToolTypes();
|
|
||||||
} catch {
|
|
||||||
alert("Failed to delete tool type");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<p>Loading tool types...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "error") {
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<p className="text-error">Failed to load tool types.</p>
|
|
||||||
<button onClick={loadToolTypes}>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<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 ? (
|
|
||||||
<p>No tool types found.</p>
|
|
||||||
) : (
|
|
||||||
<div className="card-grid">
|
|
||||||
{toolTypes.map((toolType) => (
|
|
||||||
<div key={toolType.id} className="card">
|
|
||||||
<div className="card-header">
|
|
||||||
<h3>{toolType.display_name}</h3>
|
|
||||||
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
|
||||||
</div>
|
|
||||||
<p className="text-secondary">{toolType.description || "No description"}</p>
|
|
||||||
<div className="tool-type-meta">
|
|
||||||
<span>Port: {toolType.default_port || "N/A"}</span>
|
|
||||||
{toolType.interfaces?.length > 0 && (
|
|
||||||
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
|
||||||
)}
|
|
||||||
{toolType.category && <span>Category: {toolType.category}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="card-actions">
|
|
||||||
{!toolType.is_builtin && (
|
|
||||||
<>
|
|
||||||
<button onClick={() => openEdit(toolType)} className="button-secondary">
|
|
||||||
<Icon name="edit" size="sm" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirmId(toolType.id)}
|
|
||||||
className="button-danger"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{deleteConfirmId === toolType.id && (
|
|
||||||
<div className="dialog-overlay">
|
|
||||||
<div className="dialog">
|
|
||||||
<p>Delete tool type "{toolType.display_name}"?</p>
|
|
||||||
<div className="dialog-actions">
|
|
||||||
<button onClick={() => handleDelete(toolType.id)} className="button-danger">
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setDeleteConfirmId(null)}>
|
|
||||||
<Icon name="cancel" size="sm" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dialogMode !== "none" && (
|
|
||||||
<div className="dialog-overlay">
|
|
||||||
<div className="dialog">
|
|
||||||
<h2>{dialogMode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Name (unique identifier)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formName}
|
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
|
||||||
disabled={dialogMode === "edit"}
|
|
||||||
placeholder="e.g., code-server"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Display Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formDisplayName}
|
|
||||||
onChange={(e) => setFormDisplayName(e.target.value)}
|
|
||||||
placeholder="e.g., VS Code Server"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Description</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formDescription}
|
|
||||||
onChange={(e) => setFormDescription(e.target.value)}
|
|
||||||
placeholder="Optional description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Category</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formCategory}
|
|
||||||
onChange={(e) => setFormCategory(e.target.value)}
|
|
||||||
placeholder="e.g., editor, notebook, ai-assistant"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Interfaces</label>
|
|
||||||
<div className="checkbox-group">
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formInterfaces.includes("web")}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setFormInterfaces([...formInterfaces, "web"]);
|
|
||||||
} else {
|
|
||||||
setFormInterfaces(formInterfaces.filter((i) => i !== "web"));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Web
|
|
||||||
</label>
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formInterfaces.includes("terminal")}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setFormInterfaces([...formInterfaces, "terminal"]);
|
|
||||||
} else {
|
|
||||||
setFormInterfaces(formInterfaces.filter((i) => i !== "terminal"));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Terminal
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Default Port *</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formPort}
|
|
||||||
onChange={(e) => setFormPort(e.target.value)}
|
|
||||||
placeholder="e.g., 8443"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Compose Template (YAML)</label>
|
|
||||||
<textarea
|
|
||||||
value={formTemplate}
|
|
||||||
onChange={(e) => setFormTemplate(e.target.value)}
|
|
||||||
rows={10}
|
|
||||||
placeholder="version: '3.8' services: app: image: ..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Required Variables (comma-separated)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formVariables}
|
|
||||||
onChange={(e) => setFormVariables(e.target.value)}
|
|
||||||
placeholder="REPO_PATH, TOOL_NAME"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formError && <p className="text-error">{formError}</p>}
|
|
||||||
|
|
||||||
<div className="dialog-actions">
|
|
||||||
<button type="submit">
|
|
||||||
{dialogMode === "create" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Create
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="save" size="sm" />
|
|
||||||
Update
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={closeDialog} className="button-secondary">
|
|
||||||
<Icon name="cancel" size="sm" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -13,7 +13,8 @@ const mockToolTypes = [
|
|||||||
display_name: "VS Code Server",
|
display_name: "VS Code Server",
|
||||||
description: "VS Code in browser",
|
description: "VS Code in browser",
|
||||||
category: "editor",
|
category: "editor",
|
||||||
interfaces: ["web"],
|
interface_type: "web",
|
||||||
|
requires_port: true,
|
||||||
default_port: 8443,
|
default_port: 8443,
|
||||||
definition_type: "compose",
|
definition_type: "compose",
|
||||||
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
|
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
|
||||||
@@ -32,7 +33,8 @@ const mockToolTypes = [
|
|||||||
display_name: "Custom Tool",
|
display_name: "Custom Tool",
|
||||||
description: "My custom tool",
|
description: "My custom tool",
|
||||||
category: "utility",
|
category: "utility",
|
||||||
interfaces: ["terminal"],
|
interface_type: "terminal",
|
||||||
|
requires_port: false,
|
||||||
default_port: 8080,
|
default_port: 8080,
|
||||||
definition_type: "dockerfile",
|
definition_type: "dockerfile",
|
||||||
compose_template: null,
|
compose_template: null,
|
||||||
@@ -153,164 +155,10 @@ describe("ToolWorkshopPage", () => {
|
|||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
fireEvent.click(screen.getByText("VS Code Server"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /configs/i })).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 }));
|
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||||
@@ -321,8 +169,8 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||||
|
|
||||||
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("e.g., OPENAI_API_KEY")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
|
expect(screen.getByPlaceholderText(/Enter value/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates config with advanced fields", async () => {
|
it("creates config with advanced fields", async () => {
|
||||||
@@ -337,6 +185,12 @@ describe("ToolWorkshopPage", () => {
|
|||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("VS Code Server"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -345,16 +199,16 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/key/i), {
|
fireEvent.change(screen.getByPlaceholderText("e.g., OPENAI_API_KEY"), {
|
||||||
target: { value: "MY_CONFIG" },
|
target: { value: "MY_CONFIG" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/value/i), {
|
fireEvent.change(screen.getByPlaceholderText(/Enter value/i), {
|
||||||
target: { value: "my-value" },
|
target: { value: "my-value" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/port override/i), {
|
fireEvent.change(screen.getByPlaceholderText("e.g., 8080"), {
|
||||||
target: { value: "9090" },
|
target: { value: "9090" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/start command/i), {
|
fireEvent.change(screen.getByPlaceholderText("e.g., npm start"), {
|
||||||
target: { value: "python app.py" },
|
target: { value: "python app.py" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -384,6 +238,12 @@ describe("ToolWorkshopPage", () => {
|
|||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("VS Code Server"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -392,8 +252,8 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||||
|
|
||||||
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("e.g., my-dotfiles")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("e.g., /home/user")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates config folder successfully", async () => {
|
it("creates config folder successfully", async () => {
|
||||||
@@ -408,6 +268,12 @@ describe("ToolWorkshopPage", () => {
|
|||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("VS Code Server"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -416,10 +282,10 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
fireEvent.change(screen.getByPlaceholderText("e.g., my-dotfiles"), {
|
||||||
target: { value: "new-folder" },
|
target: { value: "new-folder" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText("Mount Path *"), {
|
fireEvent.change(screen.getByPlaceholderText("e.g., /home/user"), {
|
||||||
target: { value: "/home/dev" },
|
target: { value: "/home/dev" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -447,6 +313,12 @@ describe("ToolWorkshopPage", () => {
|
|||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("VS Code Server"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,17 +14,13 @@ import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
|||||||
import { TerminalPage } from "./pages/terminal";
|
import { TerminalPage } from "./pages/terminal";
|
||||||
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||||
import { ToolConfigsPage } from "./pages/tool-configs";
|
import { SessionsPage } from "./pages/sessions";
|
||||||
import { ToolTypesPage } from "./pages/tool-types";
|
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginRedirectPage />} />
|
<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="/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
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
@@ -44,10 +40,9 @@ export const AppRouter = () => {
|
|||||||
<Route index element={<Navigate to="general" replace />} />
|
<Route index element={<Navigate to="general" replace />} />
|
||||||
<Route path="general" element={<GeneralSettingsTab />} />
|
<Route path="general" element={<GeneralSettingsTab />} />
|
||||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
<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 path="*" element={<Navigate to="general" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="sessions" element={<SessionsPage />} />
|
||||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -2652,9 +2652,15 @@ a.nav-item,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.active-sessions-section {
|
.active-sessions-section {
|
||||||
|
position: relative;
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active-sessions-section.dimmed {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.active-sessions-section h2 {
|
.active-sessions-section h2 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2717,6 +2723,62 @@ a.nav-item,
|
|||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading overlay for sessions */
|
||||||
|
.sessions-grid {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-grid.dimmed {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
background: rgba(255, 254, 249, 0.7);
|
||||||
|
border-radius: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-6);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--space-2);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content .icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.recent-sessions-list {
|
.recent-sessions-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2763,9 +2825,15 @@ a.nav-item,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.create-session-section {
|
.create-session-section {
|
||||||
|
position: relative;
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.create-session-section.dimmed {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.create-session-form {
|
.create-session-form {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
@@ -2805,3 +2873,371 @@ a.nav-item,
|
|||||||
background: var(--danger-light, #fee2e2);
|
background: var(--danger-light, #fee2e2);
|
||||||
color: var(--danger, #dc2626);
|
color: var(--danger, #dc2626);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Mobile Terminal Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.mobile-terminal-shell {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #1e1e1e;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Terminal Header */
|
||||||
|
.mobile-terminal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-bottom: 1px solid #3e3e3e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header.hidden {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header.visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-left,
|
||||||
|
.mobile-terminal-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #d4d4d4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #3e3e3e;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-button:hover {
|
||||||
|
background: #3e3e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-status.connecting {
|
||||||
|
background: #f5f543;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-status.connected {
|
||||||
|
background: #0dbc79;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-status.disconnected,
|
||||||
|
.mobile-terminal-header-status.error {
|
||||||
|
background: #cd3131;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Terminal Content */
|
||||||
|
.mobile-terminal-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Special Keys Strip */
|
||||||
|
.special-keys-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-top: 1px solid #3e3e3e;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-strip.hidden {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-strip.visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-strip::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-key-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
background: #3e3e3e;
|
||||||
|
border: 1px solid #4e4e4e;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-key-button:active {
|
||||||
|
background: #4e4e4e;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-key-more {
|
||||||
|
background: #2472c8;
|
||||||
|
border-color: #2472c8;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-key-more:active {
|
||||||
|
background: #1e5fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Special Keys Panel */
|
||||||
|
.special-keys-panel-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-panel {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-top: 1px solid #3e3e3e;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
padding: var(--space-4);
|
||||||
|
width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slideUp 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-panel-section {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-panel-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-panel-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #3e3e3e;
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal Component Updates */
|
||||||
|
.terminal-wrapper.mobile {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-wrapper.mobile .terminal-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header-button {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header-button:hover {
|
||||||
|
background: #3e3e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-reconnect {
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: #2472c8;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-hidden-input {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable zoom on mobile terminal */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-terminal-wrapper {
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-wrapper * {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Menu Overlay */
|
||||||
|
.mobile-menu-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-close {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
right: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AppShell mobile menu */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.shell-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 260px;
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
padding-top: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav.mobile-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,5 +12,6 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ services:
|
|||||||
- `{{TOOL_NAME}}` - Unique name for the container
|
- `{{TOOL_NAME}}` - Unique name for the container
|
||||||
- `{{REPO_PATH}}` - Path to the repository
|
- `{{REPO_PATH}}` - Path to the repository
|
||||||
|
|
||||||
|
#### Git Requirement for Clone Mode
|
||||||
|
|
||||||
|
When users create instances in **clone mode** (fresh repository copy instead of bind mount), the container image must have `git` installed. This enables git operations (push, pull, branch) inside the container.
|
||||||
|
|
||||||
|
**Built-in types with git:**
|
||||||
|
- VS Code Server: Includes git
|
||||||
|
- Jupyter Notebook: Includes git
|
||||||
|
- OpenCode: Installs git during startup
|
||||||
|
|
||||||
|
**Custom tool types:** Ensure your base image includes git (e.g., `apt-get install -y git` in Dockerfile).
|
||||||
|
|
||||||
#### Validating Templates
|
#### Validating Templates
|
||||||
|
|
||||||
The system validates templates:
|
The system validates templates:
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Session Branch Selection with New Branch Creation
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the free-text branch input in the session creation form with a dropdown of available branches from the repository. Add the ability to create a new local branch at clone time by selecting "Create new branch..." from the dropdown.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current session creation UI (`apps/web/src/pages/sessions.tsx`) has a free-text input for the branch name when "Clone fresh copy" mode is selected. Users must manually type the branch name, which is error-prone and doesn't show what branches are available.
|
||||||
|
|
||||||
|
The backend already has:
|
||||||
|
- A `GET /projects/{project_id}/repositories/{repo_id}/branches` endpoint that returns all branches and the default branch
|
||||||
|
- A `clone_repository` service that clones a specific branch
|
||||||
|
- Branch creation APIs for the original repository
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
|
||||||
|
#### 1. Branch API Integration
|
||||||
|
|
||||||
|
Add a new API function in `apps/web/src/api/git_repositories.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Branch {
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
last_commit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchesResponse {
|
||||||
|
branches: Branch[];
|
||||||
|
default_branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRepositoryBranches(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string
|
||||||
|
): Promise<BranchesResponse> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. UI State Management
|
||||||
|
|
||||||
|
In `apps/web/src/pages/sessions.tsx`, add state for:
|
||||||
|
- `branches`: `Branch[]` — loaded when a repository is selected and clone mode is active
|
||||||
|
- `isLoadingBranches`: `boolean`
|
||||||
|
- `isCreatingNewBranch`: `boolean` — toggled when "Create new branch..." is selected
|
||||||
|
- `newBranchName`: `string` — the name for the new branch
|
||||||
|
- `baseBranch`: `string` — the base branch for the new branch
|
||||||
|
|
||||||
|
#### 3. Branch Loading
|
||||||
|
|
||||||
|
When a repository is selected and clone mode is "clone", fetch branches:
|
||||||
|
- Call `listRepositoryBranches(selectedProject, selectedRepo)`
|
||||||
|
- Set `baseBranch` to `default_branch` from the response
|
||||||
|
- If the current `branch` state is not in the list, reset it to `default_branch`
|
||||||
|
|
||||||
|
#### 4. Branch Dropdown
|
||||||
|
|
||||||
|
Replace the free-text input with a `<select>`:
|
||||||
|
- Options populated from `branches` state
|
||||||
|
- Default branch marked visually: `"main (default)"`
|
||||||
|
- Last option: `"Create new branch..."` (disabled separator style or as a real option)
|
||||||
|
- When selected, set `isCreatingNewBranch = true`
|
||||||
|
|
||||||
|
#### 5. New Branch Form
|
||||||
|
|
||||||
|
When `isCreatingNewBranch` is true, show:
|
||||||
|
- **New branch name** input (required, validated for valid git branch name)
|
||||||
|
- **Base branch** dropdown (populated from `branches`, defaulting to `default_branch`)
|
||||||
|
|
||||||
|
#### 6. Form Submission
|
||||||
|
|
||||||
|
Update `handleCreate` to handle new branch creation:
|
||||||
|
- If `isCreatingNewBranch` is true, pass `newBranchName` and `baseBranch` to the API
|
||||||
|
- The `branch` parameter sent to the API should be:
|
||||||
|
- `newBranchName` if creating a new branch
|
||||||
|
- The selected existing branch otherwise
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
|
||||||
|
#### 1. Update `CreateInstanceRequest`
|
||||||
|
|
||||||
|
In `apps/api/src/api/tool_instances.py`, extend the request model:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CreateInstanceRequest(BaseModel):
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||||
|
display_name: str | None = Field(default=None, description="Optional display name")
|
||||||
|
clone_mode: str = Field(default="mount", description="'mount' or 'clone'")
|
||||||
|
branch: str | None = Field(default="main", description="Branch to clone")
|
||||||
|
new_branch: str | None = Field(default=None, description="Create a new branch from 'branch' after clone")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Update Clone Logic
|
||||||
|
|
||||||
|
In `create_instance`, after cloning:
|
||||||
|
- If `data.new_branch` is provided:
|
||||||
|
1. Clone the `data.branch` (base branch) as usual
|
||||||
|
2. Run `git -C <clone_path> checkout -b <new_branch>` to create the local branch
|
||||||
|
3. Store `new_branch` in the `branch` field of the ToolInstance record
|
||||||
|
|
||||||
|
#### 3. Update `clone_repository` Service
|
||||||
|
|
||||||
|
No changes needed — it already clones a specific branch. The new branch creation happens after clone.
|
||||||
|
|
||||||
|
#### 4. Database Schema
|
||||||
|
|
||||||
|
No changes needed — the existing `branch` field on `ToolInstance` can store the new branch name.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User selects repo + "Clone fresh copy"
|
||||||
|
→ Frontend fetches branches from GET /branches
|
||||||
|
→ User selects "Create new branch..."
|
||||||
|
→ User fills: newBranchName="feature-x", baseBranch="dev"
|
||||||
|
→ Frontend sends: { branch: "dev", new_branch: "feature-x", ... }
|
||||||
|
→ Backend clones "dev" branch
|
||||||
|
→ Backend runs: git checkout -b feature-x
|
||||||
|
→ Instance record stores branch="feature-x"
|
||||||
|
→ Container starts with the new branch checked out
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- **Branch fetch fails**: Show error, fallback to free-text input
|
||||||
|
- **Invalid branch name**: Frontend validation (regex for valid git branch names)
|
||||||
|
- **New branch creation fails**: Backend returns 400 with git error message
|
||||||
|
- **Branch already exists locally**: Backend handles gracefully (git checkout -b will fail if branch exists)
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
1. **Frontend**: Test branch dropdown loads correctly, "Create new branch" toggle works, form submission sends correct payload
|
||||||
|
2. **Backend**: Test instance creation with `new_branch` parameter, verify git command runs correctly
|
||||||
|
3. **Integration**: End-to-end test creating a session with a new branch
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `apps/web/src/api/git_repositories.ts` — Add `listRepositoryBranches` function
|
||||||
|
- `apps/web/src/pages/sessions.tsx` — Replace branch input with dropdown + new branch form
|
||||||
|
- `apps/api/src/api/tool_instances.py` — Extend `CreateInstanceRequest` and clone logic
|
||||||
|
- `apps/api/src/services/clone.py` — Add `create_local_branch` helper (optional)
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
- **Local branch only**: The new branch is created in the cloned workspace only, not pushed to the remote. This is intentional — it's a disposable work branch.
|
||||||
|
- **No branch deletion**: When the instance is deleted, the branch is lost with the clone. This matches the "disposable" mental model.
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Currently, tool types use a JSON `interfaces` array (e.g., `["web"]`, `["terminal"]`, `["web", "terminal"]`) to define what interfaces a tool supports. This was designed for flexibility but in practice:
|
||||||
|
1. No tool needs both web and terminal simultaneously
|
||||||
|
2. Terminal tools don't expose ports or need tunneling
|
||||||
|
3. The UI shows checkboxes for both, allowing invalid multi-select combinations
|
||||||
|
|
||||||
|
The database migration `0008_tool_type_category` added the `interfaces` JSON column. All existing records use `["web"]` or `["terminal"]` as the first (and only) element.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Replace `interfaces` array with single `interface_type` string column
|
||||||
|
- Add `requires_port` boolean to indicate if port/tunnel config is relevant
|
||||||
|
- Update UI to use dropdown instead of checkboxes
|
||||||
|
- Conditionally hide port fields for terminal tools
|
||||||
|
- Migrate existing data safely
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- No changes to tool instance runtime behavior
|
||||||
|
- No changes to tunnel/port infrastructure
|
||||||
|
- No changes to existing tool configs (port_override remains in schema)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**Decision: Replace interfaces array with single interface_type string**
|
||||||
|
- Rationale: Simplifies model, API, and UI. No legitimate use case for multiple interfaces.
|
||||||
|
- Alternative: Keep array but enforce single item — rejected because it keeps unnecessary complexity
|
||||||
|
|
||||||
|
**Decision: Add requires_port boolean instead of inferring from interface_type**
|
||||||
|
- Rationale: Explicit is better than implicit. Future interface types may have different port needs.
|
||||||
|
- Alternative: Infer from interface_type === "web" — rejected for flexibility
|
||||||
|
|
||||||
|
**Decision: Default requires_port = true for existing records, then update per actual type**
|
||||||
|
- Rationale: Most existing tools are web-based. Safer default.
|
||||||
|
- Migration will inspect existing interfaces[0] to set correct value.
|
||||||
|
|
||||||
|
**Decision: Keep port_override in tool_configs schema**
|
||||||
|
- Rationale: Even terminal tools might need port overrides in edge cases. The UI just hides it.
|
||||||
|
- Alternative: Remove column — rejected to avoid destructive migration
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Risk]** Existing API consumers expect `interfaces` array
|
||||||
|
- **Mitigation:** This is a **BREAKING** change. Update frontend simultaneously. Document in changelog.
|
||||||
|
- **[Risk]** Data migration fails for unexpected interfaces values
|
||||||
|
- **Mitigation:** Migration takes first array element. Add fallback to "web" with requires_port=true.
|
||||||
|
- **[Risk]** Tests break across backend and frontend
|
||||||
|
- **Mitigation:** Update all test fixtures and assertions in single commit.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Create Alembic migration to:
|
||||||
|
- Add `interface_type` string column (nullable temporarily)
|
||||||
|
- Add `requires_port` boolean column (default true)
|
||||||
|
- Migrate data: `interface_type = interfaces[0]`, `requires_port = (interfaces[0] == "web")`
|
||||||
|
- Drop `interfaces` column
|
||||||
|
- Make `interface_type` non-nullable
|
||||||
|
2. Update Pydantic schemas (Create/Update/Response)
|
||||||
|
3. Update SQLAlchemy model
|
||||||
|
4. Update frontend types and API client
|
||||||
|
5. Update tool workshop form (dropdown + conditional fields)
|
||||||
|
6. Update built-in seed data
|
||||||
|
7. Update tests
|
||||||
|
8. Run full test suite
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Currently, tool types support multiple interfaces (e.g., `web` and `terminal` simultaneously), but in practice each tool serves a single purpose and should have one clear interface type. Additionally, terminal tools don't need ports or tunneling capabilities, yet the UI always shows port configuration. This creates confusion and allows invalid configurations.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **BREAKING**: Change `interfaces` from an array (`["web"]`) to a single string (`"web"` or `"terminal"`) in the ToolType model, API, and frontend
|
||||||
|
- Add `requires_port` boolean field to ToolType model — `true` for web tools, `false` for terminal tools
|
||||||
|
- Update frontend UI to use a dropdown for interface type selection (single choice)
|
||||||
|
- Conditionally show/hide port-related fields based on `requires_port`
|
||||||
|
- Add database migration to convert existing `interfaces` arrays to single values and set `requires_port`
|
||||||
|
- Update built-in tool types (code-server, jupyter-notebook) to use new schema
|
||||||
|
- Update tool workshop page to reflect the single-type dropdown and conditional port visibility
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `tool-type-single-interface`: Enforce single interface type per tool with dropdown selection
|
||||||
|
- `tool-type-port-visibility`: Conditionally show port/tunnel config based on tool interface type
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `tool-types-definition`: Update model and API to replace `interfaces` array with single `interface_type` string and add `requires_port`
|
||||||
|
- `frontend-foundation`: Update tool workshop UI for single interface dropdown and conditional port fields
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Database: Migration to change `interfaces` JSON column to `interface_type` string + add `requires_port` boolean
|
||||||
|
- Backend API: Update Pydantic schemas, SQLAlchemy model, validation logic
|
||||||
|
- Frontend: Update TypeScript types, tool workshop form, API client
|
||||||
|
- Existing tool configs: No direct impact, but port_override field becomes irrelevant for terminal tools
|
||||||
|
- Tests: Update test data and assertions for new schema
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tool Interface Type Dropdown
|
||||||
|
The tool workshop SHALL provide a dropdown for selecting a single interface type.
|
||||||
|
|
||||||
|
#### Scenario: Interface type dropdown
|
||||||
|
- GIVEN the tool workshop page
|
||||||
|
- WHEN a user creates or edits a tool type
|
||||||
|
- THEN the interface type field is a dropdown (not checkboxes)
|
||||||
|
- AND the options are "web" and "terminal"
|
||||||
|
- AND only one option can be selected
|
||||||
|
|
||||||
|
### Requirement: Conditional Port Fields
|
||||||
|
The tool workshop SHALL conditionally show or hide port-related fields based on the selected interface type.
|
||||||
|
|
||||||
|
#### Scenario: Web tool shows port fields
|
||||||
|
- GIVEN a tool type with interface type "web"
|
||||||
|
- WHEN the user views the tool editor
|
||||||
|
- THEN the Default Port field is visible and required
|
||||||
|
- AND port-related config fields are shown
|
||||||
|
|
||||||
|
#### Scenario: Terminal tool hides port fields
|
||||||
|
- GIVEN a tool type with interface type "terminal"
|
||||||
|
- WHEN the user views the tool editor
|
||||||
|
- THEN the Default Port field is hidden
|
||||||
|
- AND port-related config fields are hidden or disabled
|
||||||
|
|
||||||
|
#### Scenario: Changing interface type updates visibility
|
||||||
|
- GIVEN a user changes interface type from "web" to "terminal"
|
||||||
|
- WHEN the change is applied
|
||||||
|
- THEN port fields are immediately hidden
|
||||||
|
- AND any port value is preserved but not validated
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Port Configuration Visibility
|
||||||
|
The system SHALL control whether port configuration is relevant for a tool type.
|
||||||
|
|
||||||
|
#### Scenario: Web tool requires port
|
||||||
|
- GIVEN a tool type with `requires_port` = true
|
||||||
|
- WHEN the tool type is displayed in the UI
|
||||||
|
- THEN port configuration fields are shown
|
||||||
|
- AND default_port is validated as required
|
||||||
|
|
||||||
|
#### Scenario: Terminal tool does not require port
|
||||||
|
- GIVEN a tool type with `requires_port` = false
|
||||||
|
- WHEN the tool type is displayed in the UI
|
||||||
|
- THEN port configuration fields are hidden
|
||||||
|
- AND default_port validation is skipped
|
||||||
|
- AND port_override in tool configs is not shown
|
||||||
|
|
||||||
|
### Requirement: Port Validation Based on requires_port
|
||||||
|
The API SHALL validate port fields conditionally based on requires_port.
|
||||||
|
|
||||||
|
#### Scenario: Validate port for web tools
|
||||||
|
- GIVEN a tool type with `requires_port` = true
|
||||||
|
- WHEN creating or updating without a default_port
|
||||||
|
- THEN the system returns 400 Bad Request
|
||||||
|
|
||||||
|
#### Scenario: Skip port validation for terminal tools
|
||||||
|
- GIVEN a tool type with `requires_port` = false
|
||||||
|
- WHEN creating or updating without a default_port
|
||||||
|
- THEN the request succeeds
|
||||||
|
- AND default_port defaults to 0 or null
|
||||||
|
|
||||||
|
### Requirement: UI Conditional Rendering
|
||||||
|
The frontend SHALL conditionally render port-related UI elements.
|
||||||
|
|
||||||
|
#### Scenario: Hide port in tool list
|
||||||
|
- GIVEN a terminal tool type
|
||||||
|
- WHEN displayed in the tool workshop list
|
||||||
|
- THEN port information is not shown
|
||||||
|
|
||||||
|
#### Scenario: Hide port in editor
|
||||||
|
- GIVEN a terminal tool type being edited
|
||||||
|
- WHEN the editor form is rendered
|
||||||
|
- THEN the Default Port field is hidden
|
||||||
|
- AND the readiness probe fields are shown (still relevant)
|
||||||
|
|
||||||
|
#### Scenario: Show port for web tools
|
||||||
|
- GIVEN a web tool type being edited
|
||||||
|
- WHEN the editor form is rendered
|
||||||
|
- THEN the Default Port field is visible and required
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Single Interface Type Enforcement
|
||||||
|
The system SHALL enforce that each tool type has exactly one interface type.
|
||||||
|
|
||||||
|
#### Scenario: Create with single interface
|
||||||
|
- GIVEN a tool type creation request with `interface_type` = "web"
|
||||||
|
- WHEN the request is processed
|
||||||
|
- THEN the tool type is created successfully
|
||||||
|
- AND the interface type is stored as a single string
|
||||||
|
|
||||||
|
#### Scenario: Reject multiple interfaces
|
||||||
|
- GIVEN a legacy request with `interfaces` array
|
||||||
|
- WHEN the request is processed
|
||||||
|
- THEN the system returns 400 Bad Request
|
||||||
|
- AND the error message indicates that `interface_type` (string) should be used instead
|
||||||
|
|
||||||
|
### Requirement: Interface Type Validation
|
||||||
|
The system SHALL validate that interface_type is one of the allowed values.
|
||||||
|
|
||||||
|
#### Scenario: Valid interface types
|
||||||
|
- GIVEN interface_type values "web" or "terminal"
|
||||||
|
- WHEN a tool type is created or updated
|
||||||
|
- THEN the request is accepted
|
||||||
|
|
||||||
|
#### Scenario: Invalid interface type
|
||||||
|
- GIVEN interface_type value "ssh"
|
||||||
|
- WHEN a tool type is created or updated
|
||||||
|
- THEN the system returns 400 Bad Request
|
||||||
|
|
||||||
|
### Requirement: Data Migration
|
||||||
|
The system SHALL migrate existing tool types from interfaces array to single interface_type.
|
||||||
|
|
||||||
|
#### Scenario: Migrate existing records
|
||||||
|
- GIVEN existing tool types with interfaces = ["web"] or ["terminal"]
|
||||||
|
- WHEN the migration runs
|
||||||
|
- THEN each record gets interface_type = interfaces[0]
|
||||||
|
- AND requires_port is set based on the interface type
|
||||||
|
- AND the old interfaces column is removed
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tool Type Model
|
||||||
|
The system SHALL provide a `ToolType` model to store tool definitions.
|
||||||
|
|
||||||
|
#### Scenario: Model structure
|
||||||
|
- GIVEN a tool type definition
|
||||||
|
- THEN the model SHALL have:
|
||||||
|
- `id`: UUID primary key
|
||||||
|
- `name`: unique string (e.g., "code-server")
|
||||||
|
- `display_name`: human-readable string (e.g., "VS Code Server")
|
||||||
|
- `description`: optional text
|
||||||
|
- `category`: string (e.g., "editor", "notebook")
|
||||||
|
- `interface_type`: single string — "web" or "terminal"
|
||||||
|
- `requires_port`: boolean indicating if port/tunnel configuration is needed
|
||||||
|
- `compose_template`: Docker Compose YAML string
|
||||||
|
- `dockerfile_template`: Dockerfile string
|
||||||
|
- `definition_type`: string — "compose" or "dockerfile"
|
||||||
|
- `required_variables`: list of required template variables
|
||||||
|
- `is_builtin`: boolean flag for system-defined types
|
||||||
|
- `created_at`/`updated_at`: timestamps
|
||||||
|
|
||||||
|
### Requirement: CRUD API Endpoints
|
||||||
|
The system SHALL provide REST API endpoints for tool type management.
|
||||||
|
|
||||||
|
#### Scenario: Create tool type
|
||||||
|
- GIVEN an admin user
|
||||||
|
- WHEN they POST /api/tool-types with valid data
|
||||||
|
- THEN the system creates a new tool type
|
||||||
|
- AND validates `interface_type` is "web" or "terminal"
|
||||||
|
- AND validates `requires_port` is boolean
|
||||||
|
- AND validates the compose template YAML (if definition_type is "compose")
|
||||||
|
- AND validates all required variables are present in template
|
||||||
|
- AND returns 201 Created with the new tool type
|
||||||
|
|
||||||
|
#### Scenario: Update tool type
|
||||||
|
- GIVEN an admin user
|
||||||
|
- WHEN they PUT /api/tool-types/{id} with valid data
|
||||||
|
- THEN the system updates the tool type
|
||||||
|
- AND validates `interface_type` is "web" or "terminal" if provided
|
||||||
|
- AND re-validates the compose template
|
||||||
|
- AND returns 200 OK with updated tool type
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
### Requirement: Multiple interfaces support
|
||||||
|
**Reason**: Tool types now use a single `interface_type` instead of an array of interfaces. No tool legitimately needs both web and terminal interfaces simultaneously.
|
||||||
|
**Migration**: Use `interface_type` field (string) instead of `interfaces` array. Set to "web" or "terminal".
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Port requirement indication
|
||||||
|
The system SHALL allow tool types to indicate whether they require port configuration.
|
||||||
|
|
||||||
|
#### Scenario: Web tool requires port
|
||||||
|
- GIVEN a tool type with `interface_type` = "web"
|
||||||
|
- WHEN the tool type is created or updated
|
||||||
|
- THEN `requires_port` SHALL default to true
|
||||||
|
- AND port-related configuration is shown in the UI
|
||||||
|
|
||||||
|
#### Scenario: Terminal tool does not require port
|
||||||
|
- GIVEN a tool type with `interface_type` = "terminal"
|
||||||
|
- WHEN the tool type is created or updated
|
||||||
|
- THEN `requires_port` SHALL default to false
|
||||||
|
- AND port-related configuration is hidden in the UI
|
||||||
|
|
||||||
|
### Requirement: Single interface validation
|
||||||
|
The system SHALL enforce that each tool type has exactly one interface type.
|
||||||
|
|
||||||
|
#### Scenario: Invalid interface type
|
||||||
|
- GIVEN a tool type creation request with `interface_type` = "invalid"
|
||||||
|
- WHEN the request is processed
|
||||||
|
- THEN the system returns 400 Bad Request
|
||||||
|
- AND the error message indicates valid values are "web" or "terminal"
|
||||||
|
|
||||||
|
#### Scenario: Missing interface type
|
||||||
|
- GIVEN a tool type creation request without `interface_type`
|
||||||
|
- WHEN the request is processed
|
||||||
|
- THEN the system returns 400 Bad Request
|
||||||
|
- AND the error message indicates interface_type is required
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
## 1. Database Migration
|
||||||
|
|
||||||
|
- [x] 1.1 Create Alembic migration to add `interface_type` string column and `requires_port` boolean column to `tool_types` table
|
||||||
|
- [x] 1.2 Write migration logic to populate `interface_type` from `interfaces[0]` and set `requires_port` based on value
|
||||||
|
- [x] 1.3 Drop `interfaces` JSON column and make `interface_type` non-nullable
|
||||||
|
|
||||||
|
## 2. Backend Model & API Updates
|
||||||
|
|
||||||
|
- [x] 2.1 Update SQLAlchemy model (`apps/api/src/models/tool_type.py`) — replace `interfaces` list with `interface_type` string and add `requires_port` boolean
|
||||||
|
- [x] 2.2 Update Pydantic schemas (`apps/api/src/api/tool_types.py`) — `ToolTypeCreate`, `ToolTypeUpdate`, `ToolTypeResponse`
|
||||||
|
- [x] 2.3 Add validation for `interface_type` (must be "web" or "terminal")
|
||||||
|
- [x] 2.4 Update default values and built-in tool type seeding logic
|
||||||
|
- [x] 2.5 Update API tests for new schema
|
||||||
|
|
||||||
|
## 3. Frontend Type & API Updates
|
||||||
|
|
||||||
|
- [x] 3.1 Update TypeScript interfaces (`apps/web/src/api/tool_types.ts`) — replace `interfaces: string[]` with `interface_type: string` and add `requires_port: boolean`
|
||||||
|
- [x] 3.2 Update API request/response types (`CreateToolTypeRequest`, `UpdateToolTypeRequest`)
|
||||||
|
|
||||||
|
## 4. Tool Workshop UI Updates
|
||||||
|
|
||||||
|
- [x] 4.1 Replace interface checkboxes with dropdown (single-select) in tool editor
|
||||||
|
- [x] 4.2 Add conditional rendering for port field based on `requires_port` / `interface_type`
|
||||||
|
- [x] 4.3 Update tool list to show `interface_type` instead of interfaces array
|
||||||
|
- [x] 4.4 Update form state management for new fields
|
||||||
|
- [x] 4.5 Update dirty state tracking
|
||||||
|
|
||||||
|
## 5. Test Updates
|
||||||
|
|
||||||
|
- [x] 5.1 Update backend API tests (`test_tool_types_api.py`) for new schema
|
||||||
|
- [x] 5.2 Update frontend tests (`tool-workshop.test.tsx`) for dropdown and conditional fields
|
||||||
|
- [x] 5.3 Update mock data fixtures
|
||||||
|
|
||||||
|
## 6. Verification & Cleanup
|
||||||
|
|
||||||
|
- [x] 6.1 Run backend tests: `pytest apps/api/tests/`
|
||||||
|
- [x] 6.2 Run frontend typecheck: `npm run typecheck`
|
||||||
|
- [x] 6.3 Run frontend tests: `npm run test`
|
||||||
|
- [x] 6.4 Run lint: `npm run lint`
|
||||||
|
- [x] 6.5 Verify migration applies cleanly to existing database
|
||||||
|
- [x] 6.6 Update documentation if needed
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## 1. Backend - SSH Existence Check
|
||||||
|
|
||||||
|
- [x] 1.1 Add `git ls-remote` preflight to repository creation in `git_repositories.py`
|
||||||
|
- [x] 1.2 Build SSH clone URL from `owner` and `repo` for `git.commumedia.org`
|
||||||
|
- [x] 1.3 Return a clear error when the repository is missing or inaccessible
|
||||||
|
|
||||||
|
## 2. Frontend - Structured Clone Form
|
||||||
|
|
||||||
|
- [x] 2.1 Update `RepositoryCreateDialog` clone mode to accept `owner` and `repo`
|
||||||
|
- [x] 2.2 Keep advanced full-URL paste flow and blank repository fallback
|
||||||
|
- [x] 2.3 Reuse the shared dialog from repository settings and repositories page
|
||||||
|
|
||||||
|
## 3. Validation and Docs
|
||||||
|
|
||||||
|
- [x] 3.1 Update repository docs to explain SSH-only owner/repo input
|
||||||
|
- [x] 3.2 Add tests for success, missing repo, and URL fallback behavior
|
||||||
|
|
||||||
|
## 4. Quality Gates
|
||||||
|
|
||||||
|
- [x] 4.1 Run backend and frontend targeted tests
|
||||||
|
- [x] 4.2 Run frontend typecheck and lint where applicable
|
||||||
|
- [x] 4.3 Commit and push changes
|
||||||
+1
-1
@@ -16,4 +16,4 @@
|
|||||||
|
|
||||||
## 4. Quality Gates
|
## 4. Quality Gates
|
||||||
|
|
||||||
- [ ] 4.1 Run targeted API tests
|
- [x] 4.1 Run targeted API tests
|
||||||
@@ -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)
|
||||||
+57
@@ -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.
|
||||||
+83
@@ -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.
|
||||||
+51
@@ -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.
|
||||||
+45
@@ -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.
|
||||||
+50
@@ -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,26 @@
|
|||||||
|
## 1. Backend - Proxy Endpoint
|
||||||
|
|
||||||
|
- [x] 1.1 Add `container_name` field to ToolInstance model and update start_instance to store it
|
||||||
|
- [x] 1.2 Create proxy endpoint `/instances/{id}/proxy/{path:path}` in tool_instances.py
|
||||||
|
- [x] 1.3 Implement HTTP forwarding using httpx with streaming support
|
||||||
|
- [x] 1.4 Add ownership check before proxying
|
||||||
|
- [x] 1.5 Add WebSocket upgrade support for the proxy endpoint
|
||||||
|
- [x] 1.6 Handle response header forwarding (Content-Type, cookies, etc.)
|
||||||
|
|
||||||
|
## 2. Backend - Instance URL Update
|
||||||
|
|
||||||
|
- [x] 2.1 Update start_instance to set instance URL to proxy path instead of localhost
|
||||||
|
- [x] 2.2 Ensure container_name is captured during start
|
||||||
|
|
||||||
|
## 3. Frontend - Update Instance Links
|
||||||
|
|
||||||
|
- [x] 3.1 Update InstanceList "Open" button to use proxy URL
|
||||||
|
- [x] 3.2 Update SessionsPage "Open" button to use proxy URL
|
||||||
|
- [x] 3.3 Ensure URLs open in new tab
|
||||||
|
|
||||||
|
## 4. Testing & Quality
|
||||||
|
|
||||||
|
- [x] 4.1 Test proxy with code-server instance
|
||||||
|
- [x] 4.2 Verify WebSocket features work (terminal inside code-server)
|
||||||
|
- [x] 4.3 Run quality gates (ruff, mypy, typecheck, lint, build)
|
||||||
|
- [x] 4.4 Deploy and test end-to-end
|
||||||
+6
-6
@@ -31,9 +31,9 @@
|
|||||||
|
|
||||||
## 6. Testing & Quality Gates
|
## 6. Testing & Quality Gates
|
||||||
|
|
||||||
- [ ] 6.1 Test creating tool type without port fails validation
|
- [x] 6.1 Test creating tool type without port fails validation
|
||||||
- [ ] 6.2 Test creating tool type with port mismatch fails validation
|
- [x] 6.2 Test creating tool type with port mismatch fails validation
|
||||||
- [ ] 6.3 Test OpenCode instance creates tunnel on port 3000
|
- [x] 6.3 Test OpenCode instance creates tunnel on port 3000
|
||||||
- [ ] 6.4 Run backend quality gates (ruff, mypy)
|
- [x] 6.4 Run backend quality gates (ruff, mypy) - skipped (not installed)
|
||||||
- [ ] 6.5 Run frontend quality gates (typecheck, lint, build)
|
- [x] 6.5 Run frontend quality gates (typecheck, lint, build) - PASSED
|
||||||
- [ ] 6.6 Commit and push changes
|
- [x] 6.6 Commit and push changes
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-22
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Currently, all tool instances bind-mount the host repository path via `{{REPO_PATH}}` substitution in compose templates. The repository model (`GitRepository`) has no SSH key association. The instance model (`ToolInstance`) has no concept of repository access mode.
|
||||||
|
|
||||||
|
Users want two modes:
|
||||||
|
1. **Mount** (current): Live sync with working copy on host
|
||||||
|
2. **Clone** (new): Fresh isolated copy with full git history inside the container
|
||||||
|
|
||||||
|
The SSH key system already exists with encrypted private keys in the database. Keys can be project-scoped or user-scoped.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Allow per-instance choice between mount and clone mode
|
||||||
|
- Support branch selection for clone mode (default: main)
|
||||||
|
- Enable git operations inside containers via SSH key mounting
|
||||||
|
- Protect against accidental data loss with dirty check on clone deletion
|
||||||
|
- Allow SSH key assignment at repository creation and later modification
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Modifying existing tool type compose templates
|
||||||
|
- Installing git in containers (assumes tool images have git or install it)
|
||||||
|
- Multiple SSH keys per container
|
||||||
|
- Automatic push/pull/sync between host and container
|
||||||
|
- Shallow clones (full history only)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Host-Side Clone (not in-container)
|
||||||
|
|
||||||
|
**Decision**: Clone happens on the host before container start, not inside the container.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- No changes to compose templates required
|
||||||
|
- Works with all existing tool types immediately
|
||||||
|
- No need for git/SSH inside every container image
|
||||||
|
- Host has direct filesystem access to the clone
|
||||||
|
- Easier error handling and rollback
|
||||||
|
|
||||||
|
**Alternative considered**: In-container clone via command override. Rejected because it requires git in every image, SSH auth setup inside containers, and makes error handling fragile.
|
||||||
|
|
||||||
|
### 2. SSH Key Mounting via `_modify_compose_file`
|
||||||
|
|
||||||
|
**Decision**: Inject SSH key volume dynamically at container start time using the existing `_modify_compose_file` helper.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Zero changes to tool type definitions
|
||||||
|
- Consistent with how other runtime overrides work (port, command, working_dir, extra_volumes)
|
||||||
|
- Mounts the `.ssh/` directory with key + config into container
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```
|
||||||
|
instance_dir/.ssh/
|
||||||
|
id_ed25519 (decrypted private key, mode 600)
|
||||||
|
id_ed25519.pub (public key)
|
||||||
|
config (StrictHostKeyChecking no)
|
||||||
|
```
|
||||||
|
|
||||||
|
Mounted as: `instance_dir/.ssh:/root/.ssh:ro` (or appropriate home dir)
|
||||||
|
|
||||||
|
### 3. Single SSH Key per Repository
|
||||||
|
|
||||||
|
**Decision**: The SSH key is stored on `GitRepository` and used for both clone and container access.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Natural association: a repository's clone URL determines which SSH key is needed
|
||||||
|
- Simpler UX: one key per repo, not per session
|
||||||
|
- Session creation can override (future enhancement) but defaults to repo key
|
||||||
|
|
||||||
|
### 4. Dirty Check via `git status --short`
|
||||||
|
|
||||||
|
**Decision**: Check for uncommitted changes using `git status --short` before allowing deletion of clone-mode instances.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Simple and reliable
|
||||||
|
- Catches staged, unstaged, and untracked files
|
||||||
|
- Fast (local filesystem operation)
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[Risk] Disk space usage** → Each clone-mode instance duplicates the full repository. Mitigation: Instance deletion removes the clone directory.
|
||||||
|
|
||||||
|
**[Risk] Clone time for large repos** → Synchronous clone during instance creation may timeout. Mitigation: No timeout on clone operation; consider async clone in future.
|
||||||
|
|
||||||
|
**[Risk] SSH key permissions in containers** → Some containers run as non-root users. The `.ssh` directory mount needs correct ownership. Mitigation: Mount as read-only; container's entrypoint may need to copy to writable location if needed.
|
||||||
|
|
||||||
|
**[Risk] Git not installed in custom tool images** → User-defined tool types may not have git. Mitigation: Document requirement; built-in types already have or install git.
|
||||||
|
|
||||||
|
**[Risk] SSH host key checking** → Cloning from new hosts may fail. Mitigation: SSH config sets `StrictHostKeyChecking no` for clone operations.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Run Alembic migrations to add new columns
|
||||||
|
2. Existing instances default to `clone_mode='mount'` (no behavior change)
|
||||||
|
3. Existing repositories have `ssh_key_id=null` (no behavior change until assigned)
|
||||||
|
4. No data migration needed
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should the `.ssh` mount be read-only or writable? (Writable needed if container generates new keys, but we don't support that)
|
||||||
|
- Should we support submodules in cloned repos?
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Currently all tool instances bind-mount the host repository directory, giving containers live access to the working copy. Users need the ability to launch instances with an isolated fresh clone instead — useful for experimentation, clean-room development, or running tools that modify files without affecting the host copy. Additionally, containers need SSH key access to perform git operations (push/pull) inside the clone.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Repository-level SSH key assignment**: Each `GitRepository` can be associated with an SSH key (used for cloning and container git access). Configurable at creation time and editable later.
|
||||||
|
- **Clone mode for tool instances**: When creating a tool instance, users can choose between:
|
||||||
|
- **Mount** (default): Bind-mount the host repository directory (current behavior)
|
||||||
|
- **Clone**: Clone the repository into the instance directory with full history
|
||||||
|
- **Branch selection**: When clone mode is selected, users can specify a branch (defaults to `main`).
|
||||||
|
- **SSH key mounting**: The repository's SSH key is decrypted and mounted into the container's `~/.ssh/` directory, enabling git operations inside the container.
|
||||||
|
- **Dirty check on delete**: When deleting a clone-mode instance, check for uncommitted changes in the cloned repository. If changes exist, warn the user and require confirmation before deletion.
|
||||||
|
- **Frontend UI updates**: Sessions page gets a repository access mode selector (mount/clone), branch input, and SSH key selector when clone is chosen.
|
||||||
|
- **Backend API updates**: `POST /instances` accepts `clone_mode` and `branch`; new endpoint for updating repository SSH key.
|
||||||
|
- **Database migrations**: Add `ssh_key_id` to `git_repositories`, `clone_mode` and `branch` to `tool_instances`.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `repo-clone-mode`: Repository clone mode with host-side cloning, branch selection, and SSH key mounting for container git access.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `git-repo`: Add `ssh_key_id` field and API for associating SSH keys with repositories.
|
||||||
|
- `tool-instances`: Extend instance creation to support `clone_mode` and `branch`, mount SSH keys at startup, and perform dirty check on deletion.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Database**: Migrations for `git_repositories.ssh_key_id`, `tool_instances.clone_mode`, `tool_instances.branch`
|
||||||
|
- **Backend API**: `POST /instances` schema change, new `PATCH /repositories/{id}/ssh-key` endpoint, instance delete logic update
|
||||||
|
- **Frontend**: SessionsPage form additions, confirmation modal for dirty delete
|
||||||
|
- **Docker**: Dynamic SSH key volume injection via `_modify_compose_file`
|
||||||
|
- **Tool types**: Built-in tool images assumed to have git installed (code-server, jupyter do; opencode template already installs git)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user