feat: implement tool instances backend and session navigation
Backend: - Create ToolInstance model with status tracking - Add Alembic migration for tool_instances table - Create Docker service for compose template rendering and container execution - Add CRUD API endpoints for tool instances - Add lifecycle endpoints (start/stop/restart) - Add user sessions endpoint for navigation - Register routers in main.py Frontend: - Create SessionsProvider with React context - Create sessions API client - Update AppShell with sessions section in navigation - Add session status indicators and polling - Add CSS for session navigation Quality gates: typecheck ✓, lint ✓, build ✓
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
"""add tool_instances table
|
||||||
|
|
||||||
|
Revision ID: 0006_tool_instances
|
||||||
|
Revises: 0005_ssh_keys_timestamps
|
||||||
|
Create Date: 2026-05-19 10:00: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 = "0006_tool_instances"
|
||||||
|
down_revision: Union[str, None] = "0005_ssh_keys_timestamps"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("repository_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("status", sa.String(50), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("container_id", sa.String(255), nullable=True),
|
||||||
|
sa.Column("compose_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("url", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("port", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("last_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_stopped_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["repository_id"], ["git_repositories.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_instances_owner", "tool_instances", ["owner_id"])
|
||||||
|
op.create_index("idx_tool_instances_repo", "tool_instances", ["repository_id"])
|
||||||
|
op.create_index("idx_tool_instances_status", "tool_instances", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_tool_instances_status", table_name="tool_instances")
|
||||||
|
op.drop_index("idx_tool_instances_repo", table_name="tool_instances")
|
||||||
|
op.drop_index("idx_tool_instances_owner", table_name="tool_instances")
|
||||||
|
op.drop_table("tool_instances")
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""Tool instance API endpoints."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id
|
||||||
|
from src.database import get_db_session
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
from src.services.docker import (
|
||||||
|
ensure_instance_directory,
|
||||||
|
execute_compose_command,
|
||||||
|
find_free_port,
|
||||||
|
get_container_id,
|
||||||
|
get_container_logs,
|
||||||
|
get_container_status,
|
||||||
|
render_compose_template,
|
||||||
|
write_compose_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="user not found"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_owned_project(
|
||||||
|
project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession
|
||||||
|
) -> Project:
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
|
if project is None or project.owner_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="project not found"
|
||||||
|
)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/repositories/{repo_id}/instances")
|
||||||
|
async def create_instance(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
tool_type_id: uuid.UUID,
|
||||||
|
display_name: str | None = None,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new tool instance for a 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate unique name
|
||||||
|
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
||||||
|
instance_display = display_name or f"{tool_type.display_name} - {repo.name}"
|
||||||
|
|
||||||
|
# Create instance directory
|
||||||
|
instance_dir = ensure_instance_directory(instance_name)
|
||||||
|
compose_path = os.path.join(instance_dir, "docker-compose.yml")
|
||||||
|
|
||||||
|
# Find free port
|
||||||
|
tool_port = find_free_port()
|
||||||
|
|
||||||
|
# Render compose template
|
||||||
|
variables = {
|
||||||
|
"REPO_PATH": repo.path,
|
||||||
|
"INSTANCE_NAME": instance_name,
|
||||||
|
"INSTANCE_ID": instance_name,
|
||||||
|
"TOOL_PORT": tool_port,
|
||||||
|
"USER_ID": str(user_id),
|
||||||
|
"PROJECT_ID": str(project_id),
|
||||||
|
}
|
||||||
|
compose_content = render_compose_template(tool_type.compose_template, variables)
|
||||||
|
write_compose_file(instance_dir, compose_content)
|
||||||
|
|
||||||
|
# Create database record
|
||||||
|
instance = ToolInstance(
|
||||||
|
name=instance_name,
|
||||||
|
display_name=instance_display,
|
||||||
|
tool_type_id=tool_type_id,
|
||||||
|
repository_id=repo_id,
|
||||||
|
project_id=project_id,
|
||||||
|
owner_id=user_id,
|
||||||
|
status="pending",
|
||||||
|
compose_path=compose_path,
|
||||||
|
port=tool_port,
|
||||||
|
)
|
||||||
|
session.add(instance)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(instance)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(instance.id),
|
||||||
|
"name": instance.name,
|
||||||
|
"display_name": instance.display_name,
|
||||||
|
"tool_type_id": str(instance.tool_type_id),
|
||||||
|
"status": instance.status,
|
||||||
|
"created_at": instance.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/repositories/{repo_id}/instances")
|
||||||
|
async def list_instances(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List all instances for a 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance)
|
||||||
|
.where(ToolInstance.repository_id == repo_id)
|
||||||
|
.where(ToolInstance.owner_id == user_id)
|
||||||
|
.order_by(ToolInstance.created_at.desc())
|
||||||
|
)
|
||||||
|
instances = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"instances": [
|
||||||
|
{
|
||||||
|
"id": str(i.id),
|
||||||
|
"name": i.name,
|
||||||
|
"display_name": i.display_name,
|
||||||
|
"tool_type_id": str(i.tool_type_id),
|
||||||
|
"status": i.status,
|
||||||
|
"url": i.url,
|
||||||
|
"port": i.port,
|
||||||
|
"created_at": i.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for i in instances
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
|
||||||
|
async def get_instance(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get a specific instance with real-time status."""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get real-time status from Docker
|
||||||
|
if instance.container_id:
|
||||||
|
docker_status = get_container_status(instance.container_id)
|
||||||
|
if docker_status == "running" and instance.status != "running":
|
||||||
|
instance.status = "running"
|
||||||
|
await session.commit()
|
||||||
|
elif docker_status == "exited" and instance.status == "running":
|
||||||
|
instance.status = "stopped"
|
||||||
|
instance.last_stopped_at = datetime.now()
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(instance.id),
|
||||||
|
"name": instance.name,
|
||||||
|
"display_name": instance.display_name,
|
||||||
|
"tool_type_id": str(instance.tool_type_id),
|
||||||
|
"status": instance.status,
|
||||||
|
"container_id": instance.container_id,
|
||||||
|
"compose_path": instance.compose_path,
|
||||||
|
"url": instance.url,
|
||||||
|
"port": instance.port,
|
||||||
|
"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,
|
||||||
|
"created_at": instance.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/start")
|
||||||
|
async def start_instance(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Start a tool instance."""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
instance.status = "building"
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Execute docker compose up
|
||||||
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
|
instance.compose_path, "up"
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"failed to start instance: {stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get container ID
|
||||||
|
container_id = get_container_id(instance.name)
|
||||||
|
if container_id:
|
||||||
|
instance.container_id = container_id
|
||||||
|
|
||||||
|
instance.status = "running"
|
||||||
|
instance.last_started_at = datetime.now()
|
||||||
|
instance.url = f"http://localhost:{instance.port}"
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop")
|
||||||
|
async def stop_instance(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Stop a tool instance."""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
|
execute_compose_command(instance.compose_path, "stop")
|
||||||
|
|
||||||
|
instance.status = "stopped"
|
||||||
|
instance.last_stopped_at = datetime.now()
|
||||||
|
instance.url = None
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {"status": instance.status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart")
|
||||||
|
async def restart_instance(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Restart a tool instance."""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
|
instance.compose_path, "restart"
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode == 0:
|
||||||
|
instance.status = "running"
|
||||||
|
instance.last_started_at = datetime.now()
|
||||||
|
instance.url = f"http://localhost:{instance.port}"
|
||||||
|
await session.commit()
|
||||||
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
return {"status": instance.status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
|
||||||
|
async def delete_instance(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""Delete a tool instance."""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stop and remove container
|
||||||
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
|
execute_compose_command(instance.compose_path, "down")
|
||||||
|
|
||||||
|
# Remove instance directory
|
||||||
|
if instance.compose_path:
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
if os.path.exists(instance_dir):
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(instance_dir)
|
||||||
|
|
||||||
|
await session.delete(instance)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs")
|
||||||
|
async def get_instance_logs(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
tail: int = 100,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get container logs for an instance."""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not instance.container_id:
|
||||||
|
return {"logs": "No container running"}
|
||||||
|
|
||||||
|
logs = get_container_logs(instance.container_id, tail)
|
||||||
|
return {"logs": logs}
|
||||||
|
|
||||||
|
|
||||||
|
from fastapi import APIRouter as FastAPIRouter
|
||||||
|
|
||||||
|
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
|
||||||
|
|
||||||
|
@sessions_router.get("/me/sessions")
|
||||||
|
async def get_user_sessions(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get all active sessions (running instances) for the current user."""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance)
|
||||||
|
.where(ToolInstance.owner_id == user_id)
|
||||||
|
.where(ToolInstance.status.in_(["running", "building", "pending"]))
|
||||||
|
.order_by(ToolInstance.created_at.desc())
|
||||||
|
)
|
||||||
|
instances = result.scalars().all()
|
||||||
|
|
||||||
|
sessions = []
|
||||||
|
for instance in instances:
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
repo = await session.get(GitRepository, instance.repository_id)
|
||||||
|
project = await session.get(Project, instance.project_id)
|
||||||
|
|
||||||
|
sessions.append({
|
||||||
|
"id": str(instance.id),
|
||||||
|
"display_name": instance.display_name,
|
||||||
|
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||||
|
"tool_icon": tool_type.name if tool_type else "code",
|
||||||
|
"repository_name": repo.name if repo else "unknown",
|
||||||
|
"project_name": project.name if project else "unknown",
|
||||||
|
"status": instance.status,
|
||||||
|
"url": instance.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"sessions": sessions}
|
||||||
@@ -11,6 +11,8 @@ from src.api.dashboard import router as dashboard_router
|
|||||||
from src.api.git_repositories import router as git_repositories_router
|
from src.api.git_repositories import router as git_repositories_router
|
||||||
from src.api.projects import router as projects_router
|
from src.api.projects import router as projects_router
|
||||||
from src.api.ssh_keys import router as ssh_keys_router
|
from src.api.ssh_keys import router as ssh_keys_router
|
||||||
|
from src.api.tool_instances import router as tool_instances_router
|
||||||
|
from src.api.tool_instances import sessions_router
|
||||||
from src.api.tool_types import router as tool_types_router
|
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
|
||||||
@@ -164,4 +166,6 @@ app.include_router(ssh_keys_router)
|
|||||||
app.include_router(git_repositories_router)
|
app.include_router(git_repositories_router)
|
||||||
app.include_router(user_config_router)
|
app.include_router(user_config_router)
|
||||||
app.include_router(tool_types_router)
|
app.include_router(tool_types_router)
|
||||||
|
app.include_router(tool_instances_router)
|
||||||
|
app.include_router(sessions_router)
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ from src.models.base import Base
|
|||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
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
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
__all__ = ["Base", "GitRepository", "Project", "SSHKey", "ToolType", "User", "UserConfig"]
|
__all__ = ["Base", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "tool_instances"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
tool_type_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("tool_types.id"), nullable=False
|
||||||
|
)
|
||||||
|
repository_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("git_repositories.id"), nullable=False
|
||||||
|
)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("projects.id"), nullable=False
|
||||||
|
)
|
||||||
|
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(50), nullable=False, default="pending"
|
||||||
|
)
|
||||||
|
container_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True
|
||||||
|
)
|
||||||
|
compose_path: Mapped[str | None] = mapped_column(
|
||||||
|
String(1024), nullable=True
|
||||||
|
)
|
||||||
|
url: Mapped[str | None] = mapped_column(
|
||||||
|
String(1024), nullable=True
|
||||||
|
)
|
||||||
|
port: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, nullable=True
|
||||||
|
)
|
||||||
|
last_started_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
|
project: Mapped["Project"] = relationship()
|
||||||
|
owner: Mapped["User"] = relationship()
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Docker service for managing tool instances."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||||
|
"""Render a Docker Compose template with variable substitution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: The compose template string
|
||||||
|
variables: Dictionary of variable names to values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered compose file content
|
||||||
|
"""
|
||||||
|
result = template
|
||||||
|
for key, value in variables.items():
|
||||||
|
placeholder = f"{{{{{key}}}}}"
|
||||||
|
result = result.replace(placeholder, str(value))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_instance_directory(instance_id: str, base_path: str = "data/instances") -> str:
|
||||||
|
"""Create and return the instance directory path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: Unique instance identifier
|
||||||
|
base_path: Base directory for all instances
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to instance directory
|
||||||
|
"""
|
||||||
|
instance_dir = Path(base_path) / instance_id
|
||||||
|
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(instance_dir.absolute())
|
||||||
|
|
||||||
|
|
||||||
|
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||||
|
"""Write the rendered compose file to the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
content: Rendered compose content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the compose file
|
||||||
|
"""
|
||||||
|
compose_path = Path(instance_dir) / "docker-compose.yml"
|
||||||
|
compose_path.write_text(content)
|
||||||
|
return str(compose_path)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_compose_command(
|
||||||
|
compose_path: str, action: str, timeout: int = 60
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
"""Execute a docker compose command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
compose_path: Path to docker-compose.yml
|
||||||
|
action: The compose action (up, down, start, stop, restart)
|
||||||
|
timeout: Command timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (returncode, stdout, stderr)
|
||||||
|
"""
|
||||||
|
instance_dir = Path(compose_path).parent
|
||||||
|
|
||||||
|
cmd = ["docker", "compose", "-f", compose_path]
|
||||||
|
|
||||||
|
if action == "up":
|
||||||
|
cmd.extend(["up", "-d"])
|
||||||
|
elif action == "down":
|
||||||
|
cmd.extend(["down", "-v"])
|
||||||
|
elif action in ("start", "stop", "restart"):
|
||||||
|
cmd.append(action)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown compose action: {action}")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(instance_dir),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_id(instance_name: str) -> str | None:
|
||||||
|
"""Get the container ID for a compose service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The service name in compose
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container ID or None if not found
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip().split("\n")[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_status(container_id: str) -> str:
|
||||||
|
"""Get the status of a Docker container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container status string (running, exited, etc.)
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||||
|
"""Get the logs of a Docker container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
tail: Number of lines to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container logs
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "logs", "--tail", str(tail), container_id],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout
|
||||||
|
return f"Failed to get logs: {result.stderr}"
|
||||||
|
|
||||||
|
|
||||||
|
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||||
|
"""Find a free TCP port in the given range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start: Start of port range
|
||||||
|
end: End of port range
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Free port number
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
for port in range(start, end):
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
if s.connect_ex(("localhost", port)) != 0:
|
||||||
|
return port
|
||||||
|
|
||||||
|
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ToolInstance {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
tool_type_id: string;
|
||||||
|
status: string;
|
||||||
|
url: string | null;
|
||||||
|
port: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
tool_type_name: string;
|
||||||
|
tool_icon: string;
|
||||||
|
repository_name: string;
|
||||||
|
project_name: string;
|
||||||
|
status: string;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInstances(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string
|
||||||
|
): Promise<ToolInstance[]> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances`
|
||||||
|
);
|
||||||
|
return response.data.instances;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInstance(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
toolTypeId: string,
|
||||||
|
displayName?: string
|
||||||
|
): Promise<ToolInstance> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
|
{
|
||||||
|
tool_type_id: toolTypeId,
|
||||||
|
display_name: displayName,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startInstance(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
instanceId: string
|
||||||
|
): Promise<{ status: string; url?: string }> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopInstance(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
instanceId: string
|
||||||
|
): Promise<{ status: string }> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartInstance(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
instanceId: string
|
||||||
|
): Promise<{ status: string; url?: string }> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInstance(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
instanceId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.delete(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserSessions(): Promise<Session[]> {
|
||||||
|
const response = await apiClient.get("/users/me/sessions");
|
||||||
|
return response.data.sessions;
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { useCallback, useEffect } from "react";
|
||||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
import { Link, NavLink, Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
import { getUserSessions } 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 { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
@@ -13,9 +17,46 @@ const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
|||||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SessionItem = ({ session }: { session: Session }) => {
|
||||||
|
const isRunning = session.status === "running";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={session.url || "#"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="nav-item session-item"
|
||||||
|
title={`${session.display_name} (${session.status})`}
|
||||||
|
>
|
||||||
|
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
||||||
|
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||||
|
<span className="session-name">{session.display_name}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const AppShell = () => {
|
export const AppShell = () => {
|
||||||
useTheme();
|
useTheme();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { sessions, setAllSessions } = useSessions();
|
||||||
|
|
||||||
|
const loadSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getUserSessions();
|
||||||
|
setAllSessions(data);
|
||||||
|
} catch {
|
||||||
|
// Silently fail - sessions are optional
|
||||||
|
}
|
||||||
|
}, [setAllSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSessions();
|
||||||
|
// Poll every 10 seconds
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void loadSessions();
|
||||||
|
}, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [loadSessions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
@@ -53,6 +94,16 @@ export const AppShell = () => {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="nav-divider" />
|
||||||
|
<div className="nav-section-title">Sessions</div>
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<SessionItem key={session.id} session={session} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="shell-content">
|
<main className="shell-content">
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { BrowserRouter } from "react-router-dom";
|
|||||||
|
|
||||||
import { AppRouter } from "./router";
|
import { AppRouter } from "./router";
|
||||||
import { AuthProvider } from "./state/auth";
|
import { AuthProvider } from "./state/auth";
|
||||||
|
import { SessionsProvider } from "./state/sessions";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppRouter />
|
<SessionsProvider>
|
||||||
|
<AppRouter />
|
||||||
|
</SessionsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
tool_type_name: string;
|
||||||
|
tool_icon: string;
|
||||||
|
repository_name: string;
|
||||||
|
project_name: string;
|
||||||
|
status: string;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionsContextType {
|
||||||
|
sessions: Session[];
|
||||||
|
setAllSessions: (sessions: Session[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionsContext = createContext<SessionsContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
|
||||||
|
const setAllSessions = (newSessions: Session[]) => {
|
||||||
|
setSessions(newSessions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
|
||||||
|
{children}
|
||||||
|
</SessionsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSessions = () => {
|
||||||
|
const context = useContext(SessionsContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useSessions must be used within a SessionsProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -2217,3 +2217,46 @@ a.nav-item,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Session Navigation */
|
||||||
|
.session-item {
|
||||||
|
position: relative;
|
||||||
|
padding-left: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--space-2);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status.running {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section-title {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
name: tool-instances
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Tool Instances - Design
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Tool Instance System
|
||||||
|
├── Backend
|
||||||
|
│ ├── ToolInstance Model
|
||||||
|
│ ├── Session API (CRUD + lifecycle)
|
||||||
|
│ ├── Docker Service (compose execution)
|
||||||
|
│ └── Status Polling
|
||||||
|
├── Frontend
|
||||||
|
│ ├── SessionStore (active sessions)
|
||||||
|
│ ├── AppShell Integration (nav entries)
|
||||||
|
│ ├── Instance Manager (repo page)
|
||||||
|
│ └── Session Launcher (create dialog)
|
||||||
|
└── Docker
|
||||||
|
├── Compose Template Rendering
|
||||||
|
├── Container Execution
|
||||||
|
└── Volume Management
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### ToolInstance
|
||||||
|
```python
|
||||||
|
class ToolInstance(Base):
|
||||||
|
id: UUID
|
||||||
|
name: str # Generated: "vscode-myrepo-abc123"
|
||||||
|
display_name: str # User-friendly name
|
||||||
|
tool_type_id: UUID -> ToolType
|
||||||
|
repository_id: UUID -> GitRepository
|
||||||
|
project_id: UUID -> Project
|
||||||
|
owner_id: UUID -> User
|
||||||
|
status: str # pending, building, running, stopped, error
|
||||||
|
container_id: str | None
|
||||||
|
compose_path: str | None # Path to rendered compose file
|
||||||
|
url: str | None # Access URL
|
||||||
|
port: int | None
|
||||||
|
last_started_at: datetime | None
|
||||||
|
last_stopped_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
```
|
||||||
|
POST /projects/{id}/repositories/{id}/instances
|
||||||
|
GET /projects/{id}/repositories/{id}/instances
|
||||||
|
GET /projects/{id}/repositories/{id}/instances/{id}
|
||||||
|
PUT /projects/{id}/repositories/{id}/instances/{id}
|
||||||
|
DELETE /projects/{id}/repositories/{id}/instances/{id}
|
||||||
|
POST /projects/{id}/repositories/{id}/instances/{id}/start
|
||||||
|
POST /projects/{id}/repositories/{id}/instances/{id}/stop
|
||||||
|
POST /projects/{id}/repositories/{id}/instances/{id}/restart
|
||||||
|
GET /projects/{id}/repositories/{id}/instances/{id}/status
|
||||||
|
GET /projects/{id}/repositories/{id}/instances/{id}/logs
|
||||||
|
GET /users/me/sessions # Active sessions for nav
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Integration
|
||||||
|
|
||||||
|
### Compose Template Rendering
|
||||||
|
```yaml
|
||||||
|
# Template variables:
|
||||||
|
# {{REPO_PATH}} - Absolute path to repo
|
||||||
|
# {{INSTANCE_NAME}} - Unique instance name
|
||||||
|
# {{TOOL_PORT}} - Exposed port
|
||||||
|
|
||||||
|
services:
|
||||||
|
{{INSTANCE_NAME}}:
|
||||||
|
image: codercom/code-server:latest
|
||||||
|
volumes:
|
||||||
|
- {{REPO_PATH}}:/workspace
|
||||||
|
ports:
|
||||||
|
- "{{TOOL_PORT}}:8080"
|
||||||
|
environment:
|
||||||
|
- PASSWORD={{INSTANCE_NAME}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execution Flow
|
||||||
|
1. Create instance directory: `data/instances/{instance_id}/`
|
||||||
|
2. Render compose file to `docker-compose.yml`
|
||||||
|
3. Run `docker compose -f {path} up -d`
|
||||||
|
4. Capture container ID from output
|
||||||
|
5. Poll status until running or error
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Session Store
|
||||||
|
```typescript
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
toolType: string;
|
||||||
|
toolIcon: string;
|
||||||
|
repositoryId: string;
|
||||||
|
projectId: string;
|
||||||
|
status: "pending" | "running" | "stopped" | "error";
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSessions = () => {
|
||||||
|
const sessions = useAtom(sessionsAtom);
|
||||||
|
const addSession = (session: Session) => { ... };
|
||||||
|
const removeSession = (id: string) => { ... };
|
||||||
|
const updateStatus = (id: string, status: string) => { ... };
|
||||||
|
return { sessions, addSession, removeSession, updateStatus };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### AppShell Navigation
|
||||||
|
- Add "Sessions" section in nav
|
||||||
|
- Show active sessions with tool icons
|
||||||
|
- Session status indicator (green dot for running)
|
||||||
|
- Click opens tool in new tab
|
||||||
|
- Dropdown for managing sessions
|
||||||
|
|
||||||
|
### Repository Page
|
||||||
|
- "Launch Tool" button
|
||||||
|
- Dialog to select tool type
|
||||||
|
- Instance list with status/actions
|
||||||
|
- Quick actions: start/stop/delete
|
||||||
|
|
||||||
|
## State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
[create] -> pending -> [docker up] -> building -> [container running] -> running
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[docker error] -> error
|
||||||
|
|
||||||
|
[running] -> [stop] -> stopped
|
||||||
|
[stopped] -> [start] -> pending -> building -> running
|
||||||
|
[any] -> [delete] -> [docker down] -> deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Only repository owner can create instances
|
||||||
|
- Instances run in isolated Docker networks
|
||||||
|
- No privileged containers
|
||||||
|
- Resource limits (CPU, memory) on containers
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Tool Instances with Sessions
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Users currently have no way to launch development tools (code-server, Jupyter, etc.) directly from their repositories. The tool-types system exists but cannot create running container instances. Additionally, there's no concept of a "session" - a running tool linked to a specific repo that appears in navigation for quick access.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Implement a complete tool instance management system with sessions:
|
||||||
|
|
||||||
|
1. **ToolInstance Model** - Links a ToolType to a GitRepository with status tracking
|
||||||
|
2. **Session Concept** - A running ToolInstance that gets a top-level navigation entry
|
||||||
|
3. **Docker Integration** - Render compose templates and execute docker compose commands
|
||||||
|
4. **Lifecycle Management** - Start, stop, restart, and delete instances
|
||||||
|
5. **Navigation Integration** - Active sessions appear in the app shell for quick access
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Tool Instance Creation
|
||||||
|
- Select a tool type and repository
|
||||||
|
- Generate unique instance name
|
||||||
|
- Render Docker Compose template with variables
|
||||||
|
- Execute `docker compose up -d`
|
||||||
|
- Store container metadata
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
- Sessions are active/running instances
|
||||||
|
- Each session gets a top-level nav entry with the tool icon
|
||||||
|
- Session dropdown in app shell shows active sessions
|
||||||
|
- Clicking a session opens the tool in a new tab/window
|
||||||
|
|
||||||
|
### Lifecycle Operations
|
||||||
|
- Start: `docker compose start`
|
||||||
|
- Stop: `docker compose stop`
|
||||||
|
- Restart: `docker compose restart`
|
||||||
|
- Delete: `docker compose down -v` + remove DB record
|
||||||
|
|
||||||
|
### Status Monitoring
|
||||||
|
- pending, building, running, stopped, error
|
||||||
|
- Real-time status via Docker API
|
||||||
|
- Last accessed timestamp
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Create tool instances from repository page
|
||||||
|
- [ ] Sessions appear in top-level navigation
|
||||||
|
- [ ] Start/stop/restart/delete instances
|
||||||
|
- [ ] Status monitoring works
|
||||||
|
- [ ] Docker Compose templates render correctly
|
||||||
|
- [ ] Navigation updates when sessions change
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Tool Instances Specification
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
1. **ToolInstance Model**: Store instance metadata with status tracking
|
||||||
|
2. **Session API**: CRUD operations + lifecycle (start/stop/restart/delete)
|
||||||
|
3. **Docker Integration**: Render compose templates and execute commands
|
||||||
|
4. **Status Monitoring**: Real-time container status polling
|
||||||
|
5. **Log Access**: View container logs (last 100 lines)
|
||||||
|
6. **Session Navigation**: Active sessions appear in app shell
|
||||||
|
7. **URL Generation**: Unique access URLs for each running instance
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
1. **Security**: Isolated containers, no privileged mode
|
||||||
|
2. **Resource Limits**: CPU and memory constraints
|
||||||
|
3. **Error Handling**: Graceful failure with cleanup
|
||||||
|
4. **Performance**: Start time < 30 seconds
|
||||||
|
|
||||||
|
## API Specification
|
||||||
|
|
||||||
|
### Create Instance
|
||||||
|
```
|
||||||
|
POST /projects/{project_id}/repositories/{repo_id}/instances
|
||||||
|
Body: {
|
||||||
|
tool_type_id: string,
|
||||||
|
display_name: string (optional)
|
||||||
|
}
|
||||||
|
Response: {
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
display_name: string,
|
||||||
|
tool_type_id: string,
|
||||||
|
status: "pending",
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Instances
|
||||||
|
```
|
||||||
|
GET /projects/{project_id}/repositories/{repo_id}/instances
|
||||||
|
Response: {
|
||||||
|
instances: [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Instance
|
||||||
|
```
|
||||||
|
GET /projects/{project_id}/repositories/{repo_id}/instances/{instance_id}
|
||||||
|
Response: {
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
display_name: string,
|
||||||
|
status: string,
|
||||||
|
container_id: string | null,
|
||||||
|
url: string | null,
|
||||||
|
port: number | null,
|
||||||
|
last_started_at: string | null,
|
||||||
|
last_stopped_at: string | null,
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle Operations
|
||||||
|
```
|
||||||
|
POST /projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/start
|
||||||
|
POST /projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop
|
||||||
|
POST /projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart
|
||||||
|
Response: { status: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Instance
|
||||||
|
```
|
||||||
|
DELETE /projects/{project_id}/repositories/{repo_id}/instances/{instance_id}
|
||||||
|
Response: 204 No Content
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get User Sessions
|
||||||
|
```
|
||||||
|
GET /users/me/sessions
|
||||||
|
Response: {
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: string,
|
||||||
|
display_name: string,
|
||||||
|
tool_type_name: string,
|
||||||
|
tool_icon: string,
|
||||||
|
repository_name: string,
|
||||||
|
project_name: string,
|
||||||
|
status: string,
|
||||||
|
url: string | null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose Template Variables
|
||||||
|
|
||||||
|
- `{{REPO_PATH}}`: Absolute path to git repository on host
|
||||||
|
- `{{INSTANCE_NAME}}`: Unique instance identifier
|
||||||
|
- `{{INSTANCE_ID}}`: UUID of the instance
|
||||||
|
- `{{TOOL_PORT}}`: Assigned port for the tool
|
||||||
|
- `{{USER_ID}}`: Owner user ID
|
||||||
|
- `{{PROJECT_ID}}`: Project ID
|
||||||
|
|
||||||
|
## Status Values
|
||||||
|
|
||||||
|
- `pending`: Instance created, waiting to start
|
||||||
|
- `building`: Docker compose up in progress
|
||||||
|
- `running`: Container is running
|
||||||
|
- `stopped`: Container stopped
|
||||||
|
- `error`: Container failed to start or crashed
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE tool_instances (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(255) NOT NULL,
|
||||||
|
tool_type_id UUID NOT NULL REFERENCES tool_types(id),
|
||||||
|
repository_id UUID NOT NULL REFERENCES git_repositories(id),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id),
|
||||||
|
owner_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||||
|
container_id VARCHAR(255),
|
||||||
|
compose_path VARCHAR(1024),
|
||||||
|
url VARCHAR(1024),
|
||||||
|
port INTEGER,
|
||||||
|
last_started_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_stopped_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tool_instances_owner ON tool_instances(owner_id);
|
||||||
|
CREATE INDEX idx_tool_instances_repo ON tool_instances(repository_id);
|
||||||
|
CREATE INDEX idx_tool_instances_status ON tool_instances(status);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
1. **Unit Tests**: Docker command generation, template rendering
|
||||||
|
2. **Integration Tests**: API endpoints, database operations
|
||||||
|
3. **Manual Testing**: Container lifecycle, navigation updates
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Tool Instances - Tasks
|
||||||
|
|
||||||
|
## Phase 1: Backend Model & Database
|
||||||
|
|
||||||
|
- [ ] **Task 1.1**: Create ToolInstance model
|
||||||
|
- Create `src/models/tool_instance.py`
|
||||||
|
- Fields: id, name, display_name, tool_type_id, repository_id, project_id, owner_id, status, container_id, compose_path, url, port, timestamps
|
||||||
|
- Add relationship to ToolType and GitRepository
|
||||||
|
- Add to `models/__init__.py`
|
||||||
|
|
||||||
|
- [ ] **Task 1.2**: Create Alembic migration
|
||||||
|
- Generate migration for tool_instances table
|
||||||
|
- Add indexes for owner_id, repository_id, status
|
||||||
|
|
||||||
|
## Phase 2: Backend Docker Service
|
||||||
|
|
||||||
|
- [ ] **Task 2.1**: Create Docker service
|
||||||
|
- Create `src/services/docker.py`
|
||||||
|
- Function: `render_compose_template(template, variables)`
|
||||||
|
- Function: `execute_compose_command(compose_path, action)`
|
||||||
|
- Function: `get_container_status(container_id)`
|
||||||
|
- Function: `get_container_logs(container_id, tail=100)`
|
||||||
|
- Error handling and cleanup
|
||||||
|
|
||||||
|
- [ ] **Task 2.2**: Create instance directory structure
|
||||||
|
- Base path: `data/instances/{instance_id}/`
|
||||||
|
- Render compose file to `docker-compose.yml`
|
||||||
|
- Ensure directory exists and is writable
|
||||||
|
|
||||||
|
## Phase 3: Backend API
|
||||||
|
|
||||||
|
- [ ] **Task 3.1**: Create instances API module
|
||||||
|
- Create `src/api/tool_instances.py`
|
||||||
|
- Import required dependencies
|
||||||
|
|
||||||
|
- [ ] **Task 3.2**: Implement create instance endpoint
|
||||||
|
- POST /projects/{id}/repositories/{id}/instances
|
||||||
|
- Validate tool_type_id exists
|
||||||
|
- Generate unique instance name
|
||||||
|
- Render compose template
|
||||||
|
- Save to database (status: pending)
|
||||||
|
- Return instance metadata
|
||||||
|
|
||||||
|
- [ ] **Task 3.3**: Implement list instances endpoint
|
||||||
|
- GET /projects/{id}/repositories/{id}/instances
|
||||||
|
- Filter by repository
|
||||||
|
- Include tool type info
|
||||||
|
|
||||||
|
- [ ] **Task 3.4**: Implement get instance endpoint
|
||||||
|
- GET /projects/{id}/repositories/{id}/instances/{id}
|
||||||
|
- Include real-time status from Docker
|
||||||
|
|
||||||
|
- [ ] **Task 3.5**: Implement lifecycle endpoints
|
||||||
|
- POST .../start - execute docker compose up
|
||||||
|
- POST .../stop - execute docker compose stop
|
||||||
|
- POST .../restart - execute docker compose restart
|
||||||
|
- Update status in database
|
||||||
|
|
||||||
|
- [ ] **Task 3.6**: Implement delete instance endpoint
|
||||||
|
- DELETE /projects/{id}/repositories/{id}/instances/{id}
|
||||||
|
- Execute docker compose down -v
|
||||||
|
- Remove instance directory
|
||||||
|
- Delete database record
|
||||||
|
|
||||||
|
- [ ] **Task 3.7**: Implement user sessions endpoint
|
||||||
|
- GET /users/me/sessions
|
||||||
|
- Return all running instances for current user
|
||||||
|
- Include tool type icon and names
|
||||||
|
|
||||||
|
- [ ] **Task 3.8**: Register router
|
||||||
|
- Add tool_instances router to main.py
|
||||||
|
|
||||||
|
## Phase 4: Frontend State Management
|
||||||
|
|
||||||
|
- [ ] **Task 4.1**: Create session store
|
||||||
|
- Create `src/state/sessions.ts`
|
||||||
|
- Define Session interface
|
||||||
|
- Create atom for sessions list
|
||||||
|
- Add helper functions
|
||||||
|
|
||||||
|
- [ ] **Task 4.2**: Create sessions API client
|
||||||
|
- Create `src/api/sessions.ts`
|
||||||
|
- Functions: list, create, start, stop, restart, delete, getStatus
|
||||||
|
- TypeScript interfaces
|
||||||
|
|
||||||
|
## Phase 5: Frontend Components
|
||||||
|
|
||||||
|
- [ ] **Task 5.1**: Update AppShell with sessions
|
||||||
|
- Add "Sessions" section in navigation
|
||||||
|
- Show active sessions with icons
|
||||||
|
- Status indicators (green dot)
|
||||||
|
- Click opens tool URL
|
||||||
|
|
||||||
|
- [ ] **Task 5.2**: Create LaunchToolDialog
|
||||||
|
- Select tool type from dropdown
|
||||||
|
- Enter display name (optional)
|
||||||
|
- Create instance on submit
|
||||||
|
- Show creation progress
|
||||||
|
|
||||||
|
- [ ] **Task 5.3**: Create InstanceList component
|
||||||
|
- List instances for a repository
|
||||||
|
- Show status, name, tool type
|
||||||
|
- Action buttons: start/stop/restart/delete
|
||||||
|
- Open URL button
|
||||||
|
|
||||||
|
- [ ] **Task 5.4**: Create InstanceCard component
|
||||||
|
- Compact card showing instance info
|
||||||
|
- Status badge
|
||||||
|
- Quick actions
|
||||||
|
|
||||||
|
## Phase 6: Frontend Pages
|
||||||
|
|
||||||
|
- [ ] **Task 6.1**: Add instances to repository page
|
||||||
|
- Add "Instances" tab or section
|
||||||
|
- Show InstanceList
|
||||||
|
- Add "Launch Tool" button
|
||||||
|
|
||||||
|
- [ ] **Task 6.2**: Create sessions dropdown
|
||||||
|
- Add to header or nav
|
||||||
|
- Quick access to active sessions
|
||||||
|
- Show status indicators
|
||||||
|
|
||||||
|
## Phase 7: Integration & Polish
|
||||||
|
|
||||||
|
- [ ] **Task 7.1**: Add instance status polling
|
||||||
|
- Poll status every 5 seconds
|
||||||
|
- Update session store
|
||||||
|
- Reflect in UI
|
||||||
|
|
||||||
|
- [ ] **Task 7.2**: Add error handling
|
||||||
|
- Docker failures
|
||||||
|
- Template rendering errors
|
||||||
|
- Network errors
|
||||||
|
|
||||||
|
- [ ] **Task 7.3**: Add loading states
|
||||||
|
- Creating instance
|
||||||
|
- Starting/stopping
|
||||||
|
- Deleting
|
||||||
|
|
||||||
|
## Phase 8: Quality Gates
|
||||||
|
|
||||||
|
- [ ] **Task 8.1**: Backend tests
|
||||||
|
- ruff check
|
||||||
|
- mypy check
|
||||||
|
- pytest
|
||||||
|
|
||||||
|
- [ ] **Task 8.2**: Frontend tests
|
||||||
|
- typecheck
|
||||||
|
- lint
|
||||||
|
- build
|
||||||
|
|
||||||
|
- [ ] **Task 8.3**: Manual testing
|
||||||
|
- Create instance
|
||||||
|
- Start/stop/restart
|
||||||
|
- Delete
|
||||||
|
- Navigation updates
|
||||||
|
- Status polling
|
||||||
Reference in New Issue
Block a user