Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b79da51269 | |||
| 40a940304b | |||
| e344e961d6 |
@@ -0,0 +1,252 @@
|
|||||||
|
# Headquarter API
|
||||||
|
|
||||||
|
The backend API for Headquarter - a self-hosted platform for managing projects, git repositories, and development tools.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Built with **FastAPI** and **SQLAlchemy** (async), using **PostgreSQL** for data storage and **Docker** for tool instance management.
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: FastAPI (Python 3.12+)
|
||||||
|
- **Database**: PostgreSQL 15+ with asyncpg
|
||||||
|
- **ORM**: SQLAlchemy 2.0 (async)
|
||||||
|
- **Auth**: OAuth2 via Authentik with session cookies
|
||||||
|
- **Migrations**: Alembic
|
||||||
|
- **Tools**: Docker Compose for instance management
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- PostgreSQL 15+ running locally
|
||||||
|
- Docker (for tool instances)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/api
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Set up database
|
||||||
|
# Ensure PostgreSQL is running with a 'headquarter' database
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
uvicorn src.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `http://localhost:8000`.
|
||||||
|
|
||||||
|
### Interactive Documentation
|
||||||
|
|
||||||
|
Once running, visit:
|
||||||
|
- **Swagger UI**: http://localhost:8000/docs
|
||||||
|
- **ReDoc**: http://localhost:8000/redoc
|
||||||
|
- **OpenAPI JSON**: http://localhost:8000/openapi.json
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `DATABASE_URL` | Yes | - | PostgreSQL connection string |
|
||||||
|
| `API_BASE_URL` | Yes | - | Public API URL (e.g., `https://api.example.com`) |
|
||||||
|
| `AUTHENTIK_DOMAIN` | Yes | - | Authentik server domain |
|
||||||
|
| `AUTHENTIK_CLIENT_ID` | Yes | - | OAuth2 client ID |
|
||||||
|
| `AUTHENTIK_CLIENT_SECRET` | Yes | - | OAuth2 client secret |
|
||||||
|
| `AUTHENTIK_APPLICATION_SLUG` | Yes | - | Authentik application slug |
|
||||||
|
| `WEB_BASE_URL` | Yes | - | Public frontend URL |
|
||||||
|
| `SESSION_SECRET` | Yes | - | Secret for session cookie signing |
|
||||||
|
| `COOKIE_DOMAIN` | No | - | Cookie domain (e.g., `.example.com`) |
|
||||||
|
| `UPLOAD_DIR` | No | `./uploads` | Directory for file uploads |
|
||||||
|
| `REPO_BASE_PATH` | No | `./repositories` | Base path for git repositories |
|
||||||
|
| `INSTANCES_BASE_PATH` | No | `./instances` | Base path for tool instances |
|
||||||
|
| `LOG_LEVEL` | No | `INFO` | Logging level |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run specific test category
|
||||||
|
pytest -m unit # Unit tests (no DB)
|
||||||
|
pytest -m integration # Integration tests (requires DB)
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest --cov=src --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format code
|
||||||
|
ruff format src tests
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
ruff check src tests
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
mypy src
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create new migration
|
||||||
|
alembic revision --autogenerate -m "description"
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Rollback one migration
|
||||||
|
alembic downgrade -1
|
||||||
|
|
||||||
|
# Show current revision
|
||||||
|
alembic current
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # API endpoint routers
|
||||||
|
│ ├── auth.py # OAuth2 authentication
|
||||||
|
│ ├── dashboard.py # Dashboard summary
|
||||||
|
│ ├── git_repositories.py # Git repo management
|
||||||
|
│ ├── health.py # Health checks
|
||||||
|
│ ├── projects.py # Project CRUD
|
||||||
|
│ ├── ssh_keys.py # SSH key management
|
||||||
|
│ ├── terminal.py # WebSocket terminal
|
||||||
|
│ ├── tool_instances.py # Tool instance management
|
||||||
|
│ ├── tool_types.py # Tool type definitions
|
||||||
|
│ ├── user_config.py # User preferences
|
||||||
|
│ └── users.py # User profile
|
||||||
|
├── auth/ # Authentication logic
|
||||||
|
│ ├── cookies.py # Cookie utilities
|
||||||
|
│ ├── dependencies.py # Auth dependencies
|
||||||
|
│ ├── oidc.py # OpenID Connect
|
||||||
|
│ └── session.py # Session management
|
||||||
|
├── config.py # Application settings
|
||||||
|
├── database.py # Database setup
|
||||||
|
├── main.py # FastAPI application
|
||||||
|
├── models/ # SQLAlchemy models
|
||||||
|
├── schemas/ # Pydantic schemas
|
||||||
|
├── services/ # Business logic
|
||||||
|
│ ├── docker.py # Docker Compose management
|
||||||
|
│ ├── terminal_manager.py # Terminal sessions
|
||||||
|
│ └── terminal_session.py # Terminal I/O
|
||||||
|
└── utils/ # Utilities
|
||||||
|
├── git_control.py # Git operations
|
||||||
|
├── git_files.py # File operations
|
||||||
|
├── git_history.py # History extraction
|
||||||
|
└── git_url_parser.py # URL parsing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. User clicks "Login" → redirects to Authentik OAuth
|
||||||
|
2. Authentik redirects back with authorization code
|
||||||
|
3. API exchanges code for tokens and fetches user info
|
||||||
|
4. API creates session cookie (HMAC-signed, httpOnly)
|
||||||
|
5. Frontend stores nothing - cookie sent automatically
|
||||||
|
6. Subsequent requests include cookie for authentication
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → FastAPI Router → Auth Dependency → Service Layer → Database
|
||||||
|
↓
|
||||||
|
Pydantic Models (validation)
|
||||||
|
↓
|
||||||
|
SQLAlchemy Models (ORM)
|
||||||
|
↓
|
||||||
|
PostgreSQL (storage)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `GET /auth/login` - Initiate OAuth login
|
||||||
|
- `GET /auth/callback` - OAuth callback
|
||||||
|
- `GET /auth/me` - Get current user
|
||||||
|
- `POST /auth/logout` - Logout
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
- `GET /projects` - List projects
|
||||||
|
- `POST /projects` - Create project
|
||||||
|
- `GET /projects/{id}` - Get project
|
||||||
|
- `PUT /projects/{id}` - Update project
|
||||||
|
- `DELETE /projects/{id}` - Delete project
|
||||||
|
|
||||||
|
### Git Repositories
|
||||||
|
- `GET /projects/{id}/repositories` - List repositories
|
||||||
|
- `POST /projects/{id}/repositories` - Create repository
|
||||||
|
- `GET /projects/{id}/repositories/{id}` - Get repository
|
||||||
|
- `DELETE /projects/{id}/repositories/{id}` - Delete repository
|
||||||
|
- `GET /projects/{id}/repositories/{id}/files` - List files
|
||||||
|
- `GET /projects/{id}/repositories/{id}/files/content` - Get file content
|
||||||
|
- `POST /projects/{id}/repositories/{id}/files/content` - Update file
|
||||||
|
- `GET /projects/{id}/repositories/{id}/branches` - List branches
|
||||||
|
- `GET /projects/{id}/repositories/{id}/history` - Commit history
|
||||||
|
- `GET /projects/{id}/repositories/{id}/commits/{hash}` - Commit detail
|
||||||
|
|
||||||
|
### Tool Types
|
||||||
|
- `GET /tool-types` - List tool types
|
||||||
|
- `POST /tool-types` - Create tool type
|
||||||
|
- `GET /tool-types/{id}` - Get tool type
|
||||||
|
- `PUT /tool-types/{id}` - Update tool type
|
||||||
|
- `DELETE /tool-types/{id}` - Delete tool type
|
||||||
|
|
||||||
|
### Tool Instances
|
||||||
|
- `GET /tool-instances` - List instances
|
||||||
|
- `POST /tool-instances` - Create instance
|
||||||
|
- `GET /tool-instances/{id}` - Get instance
|
||||||
|
- `POST /tool-instances/{id}/start` - Start instance
|
||||||
|
- `POST /tool-instances/{id}/stop` - Stop instance
|
||||||
|
- `POST /tool-instances/{id}/restart` - Restart instance
|
||||||
|
- `DELETE /tool-instances/{id}` - Delete instance
|
||||||
|
- `GET /tool-instances/{id}/logs` - Get logs
|
||||||
|
|
||||||
|
### Terminal
|
||||||
|
- `WS /ws/tool-instances/{id}/terminal` - WebSocket terminal
|
||||||
|
|
||||||
|
### Users
|
||||||
|
- `GET /users/me` - Get profile
|
||||||
|
- `PUT /users/me` - Update profile
|
||||||
|
- `POST /users/me/avatar` - Upload avatar
|
||||||
|
- `GET /users/me/config` - Get config
|
||||||
|
- `PATCH /users/me/config` - Update config
|
||||||
|
|
||||||
|
### SSH Keys
|
||||||
|
- `GET /ssh-keys` - List keys
|
||||||
|
- `POST /ssh-keys` - Create key
|
||||||
|
- `DELETE /ssh-keys/{id}` - Delete key
|
||||||
|
|
||||||
|
### Health
|
||||||
|
- `GET /health` - System health
|
||||||
|
- `GET /health/db` - Database health
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
See the [deployment documentation](../../docs/deployment/) for Docker and Traefik setup.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Follow PEP 8 style guide
|
||||||
|
2. Add tests for new endpoints
|
||||||
|
3. Update documentation
|
||||||
|
4. Run quality gates before committing
|
||||||
@@ -25,8 +25,21 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get(
|
||||||
|
"/login",
|
||||||
|
summary="Initiate OAuth login",
|
||||||
|
description="Redirects to the configured OAuth provider (Authentik) to start the authentication flow.",
|
||||||
|
response_class=RedirectResponse,
|
||||||
|
)
|
||||||
async def login(next: str = "/") -> RedirectResponse:
|
async def login(next: str = "/") -> RedirectResponse:
|
||||||
|
"""Initiate OAuth2 login flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
next: URL to redirect to after successful authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RedirectResponse to the OAuth provider's authorization endpoint.
|
||||||
|
"""
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||||
state = token_urlsafe(24)
|
state = token_urlsafe(24)
|
||||||
|
|||||||
@@ -12,11 +12,24 @@ from src.models.ssh_key import SSHKey
|
|||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary")
|
@router.get(
|
||||||
|
"/summary",
|
||||||
|
summary="Get dashboard summary",
|
||||||
|
description="Get a summary of the user's projects, repositories, SSH keys, and recent activity.",
|
||||||
|
)
|
||||||
async def get_dashboard_summary(
|
async def get_dashboard_summary(
|
||||||
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:
|
||||||
|
"""Get a summary of the user's dashboard data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with counts of projects, repositories, SSH keys, and recent activity.
|
||||||
|
"""
|
||||||
# Count user's projects
|
# Count user's projects
|
||||||
projects_result = await session.execute(
|
projects_result = await session.execute(
|
||||||
select(func.count()).select_from(Project).where(Project.owner_id == user_id)
|
select(func.count()).select_from(Project).where(Project.owner_id == user_id)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
@@ -49,6 +50,19 @@ async def _get_owned_project(
|
|||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
"""Fetch a project and verify ownership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The project if found and owned by the user.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If project not found or user is not the owner.
|
||||||
|
"""
|
||||||
project = await session.get(Project, project_id)
|
project = await session.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||||
@@ -58,6 +72,16 @@ async def _get_owned_project(
|
|||||||
|
|
||||||
|
|
||||||
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
||||||
|
"""Generate the filesystem path for a repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: UUID of the repository owner.
|
||||||
|
project_id: UUID of the project.
|
||||||
|
name: Repository name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to the repository directory.
|
||||||
|
"""
|
||||||
base = Settings().repo_base_path or "/data/repos"
|
base = Settings().repo_base_path or "/data/repos"
|
||||||
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
||||||
|
|
||||||
@@ -97,12 +121,27 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
|
@router.get(
|
||||||
|
"/{project_id}/repositories",
|
||||||
|
response_model=list[GitRepositoryResponse],
|
||||||
|
summary="List repositories",
|
||||||
|
description="List all git repositories in a project.",
|
||||||
|
)
|
||||||
async def list_repositories(
|
async def list_repositories(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
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),
|
||||||
) -> list[GitRepository]:
|
) -> list[GitRepository]:
|
||||||
|
"""List all repositories in a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of repositories in the project.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -112,13 +151,29 @@ async def list_repositories(
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete(
|
||||||
|
"/{project_id}/repositories/{repo_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete a repository",
|
||||||
|
description="Delete a git repository from the project and remove it from disk.",
|
||||||
|
)
|
||||||
async def delete_repository(
|
async def delete_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
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),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
|
"""Delete a repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository to delete.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Empty response with 204 status code.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -135,20 +190,49 @@ async def delete_repository(
|
|||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/repositories/parse-url", response_model=URLParseResponse)
|
@router.post(
|
||||||
|
"/repositories/parse-url",
|
||||||
|
response_model=URLParseResponse,
|
||||||
|
summary="Parse a git URL",
|
||||||
|
description="Parse a git URL and detect if it's a browser URL that needs correction.",
|
||||||
|
)
|
||||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
||||||
"""Parse a git URL and detect if it's a browser URL that needs correction."""
|
"""Parse a git URL and detect if it's a browser URL that needs correction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Request containing the URL to parse.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed URL information including whether it needs parsing and suggested corrections.
|
||||||
|
"""
|
||||||
result = parse_git_url(data.url)
|
result = parse_git_url(data.url)
|
||||||
return URLParseResponse(**result)
|
return URLParseResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
|
@router.post(
|
||||||
|
"/{project_id}/repositories",
|
||||||
|
response_model=GitRepositoryResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create a repository",
|
||||||
|
description="Create a new git repository in a project. Can clone from remote or initialize bare.",
|
||||||
|
)
|
||||||
async def create_repository(
|
async def create_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: GitRepositoryCreate,
|
data: GitRepositoryCreate,
|
||||||
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),
|
||||||
) -> GitRepository:
|
) -> GitRepository:
|
||||||
|
"""Create a new git repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
data: Repository creation data including name and optional remote URL.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created repository.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -229,7 +313,11 @@ async def create_repository(
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/history")
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/history",
|
||||||
|
summary="Get repository history",
|
||||||
|
description="Get commit history for a repository with optional branch filtering.",
|
||||||
|
)
|
||||||
async def get_repository_history(
|
async def get_repository_history(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -240,7 +328,21 @@ async def get_repository_history(
|
|||||||
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:
|
||||||
"""Get commit history for a repository."""
|
"""Get commit history for a repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
view: View type for history display (default: graph).
|
||||||
|
branch: Optional branch name to filter commits.
|
||||||
|
limit: Maximum number of commits to return (default: 100).
|
||||||
|
offset: Number of commits to skip (default: 0).
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing commit history data.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -258,7 +360,11 @@ async def get_repository_history(
|
|||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}")
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/commits/{commit_hash}",
|
||||||
|
summary="Get commit details",
|
||||||
|
description="Get detailed information about a specific commit.",
|
||||||
|
)
|
||||||
async def get_repository_commit(
|
async def get_repository_commit(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -266,7 +372,18 @@ async def get_repository_commit(
|
|||||||
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:
|
||||||
"""Get detailed information about a specific commit."""
|
"""Get detailed information about a specific commit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
commit_hash: Hash of the commit to retrieve.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing commit details.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -322,7 +439,12 @@ class FileUpdateResponse(BaseModel):
|
|||||||
branch: str
|
branch: str
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse)
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/files",
|
||||||
|
response_model=FileListResponse,
|
||||||
|
summary="List repository files",
|
||||||
|
description="List files and directories in a repository path.",
|
||||||
|
)
|
||||||
async def list_repository_files(
|
async def list_repository_files(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -331,7 +453,19 @@ async def list_repository_files(
|
|||||||
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),
|
||||||
) -> FileListResponse:
|
) -> FileListResponse:
|
||||||
"""List files and directories in a repository path."""
|
"""List files and directories in a repository path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
branch: Branch name to browse (default: main).
|
||||||
|
path: Directory path within the repository (default: root).
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of files and directories in the specified path.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -363,7 +497,12 @@ async def list_repository_files(
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse)
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/files/content",
|
||||||
|
response_model=FileContentResponse,
|
||||||
|
summary="Get file content",
|
||||||
|
description="Get the content of a file in a repository.",
|
||||||
|
)
|
||||||
async def get_repository_file_content(
|
async def get_repository_file_content(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -372,7 +511,19 @@ async def get_repository_file_content(
|
|||||||
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),
|
||||||
) -> FileContentResponse:
|
) -> FileContentResponse:
|
||||||
"""Get the content of a file."""
|
"""Get the content of a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
branch: Branch name where the file is located.
|
||||||
|
path: File path within the repository.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File content and metadata.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -401,14 +552,29 @@ async def get_repository_file_content(
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/branches", response_model=BranchesResponse)
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/branches",
|
||||||
|
response_model=BranchesResponse,
|
||||||
|
summary="List branches",
|
||||||
|
description="List all branches in the repository.",
|
||||||
|
)
|
||||||
async def get_repository_branches(
|
async def get_repository_branches(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
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),
|
||||||
) -> BranchesResponse:
|
) -> BranchesResponse:
|
||||||
"""List all branches in the repository."""
|
"""List all branches in the repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of branches and the default branch name.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -436,7 +602,12 @@ async def get_repository_branches(
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse)
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/files/content",
|
||||||
|
response_model=FileUpdateResponse,
|
||||||
|
summary="Update file content",
|
||||||
|
description="Update a file and create a commit.",
|
||||||
|
)
|
||||||
async def update_repository_file(
|
async def update_repository_file(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -444,7 +615,18 @@ async def update_repository_file(
|
|||||||
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),
|
||||||
) -> FileUpdateResponse:
|
) -> FileUpdateResponse:
|
||||||
"""Update a file and create a commit."""
|
"""Update a file and create a commit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
data: File update data including path, branch, content, and commit message.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Commit information for the file update.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -493,14 +675,29 @@ class StatusResponse(BaseModel):
|
|||||||
behind: int
|
behind: int
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse)
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/status",
|
||||||
|
response_model=StatusResponse,
|
||||||
|
summary="Get repository status",
|
||||||
|
description="Get the working directory status including modified, added, and deleted files.",
|
||||||
|
)
|
||||||
async def get_repository_status(
|
async def get_repository_status(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
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),
|
||||||
) -> StatusResponse:
|
) -> StatusResponse:
|
||||||
"""Get the working directory status."""
|
"""Get the working directory status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Repository status including branch, modified files, and ahead/behind counts.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -536,7 +733,11 @@ class CheckoutRequest(BaseModel):
|
|||||||
branch: str
|
branch: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/branches")
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/branches",
|
||||||
|
summary="Create a branch",
|
||||||
|
description="Create a new branch in the repository.",
|
||||||
|
)
|
||||||
async def create_repository_branch(
|
async def create_repository_branch(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -544,7 +745,18 @@ async def create_repository_branch(
|
|||||||
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:
|
||||||
"""Create a new branch."""
|
"""Create a new branch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
data: Branch creation data including name and optional base branch.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success message and branch name.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -562,7 +774,11 @@ async def create_repository_branch(
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}")
|
@router.delete(
|
||||||
|
"/{project_id}/repositories/{repo_id}/branches/{branch_name}",
|
||||||
|
summary="Delete a branch",
|
||||||
|
description="Delete a branch from the repository.",
|
||||||
|
)
|
||||||
async def delete_repository_branch(
|
async def delete_repository_branch(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -571,7 +787,19 @@ async def delete_repository_branch(
|
|||||||
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:
|
||||||
"""Delete a branch."""
|
"""Delete a branch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
branch_name: Name of the branch to delete.
|
||||||
|
force: Whether to force delete the branch.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success message.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -589,7 +817,11 @@ async def delete_repository_branch(
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/checkout")
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/checkout",
|
||||||
|
summary="Checkout a branch",
|
||||||
|
description="Checkout a branch in the repository.",
|
||||||
|
)
|
||||||
async def checkout_repository_branch(
|
async def checkout_repository_branch(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -597,7 +829,18 @@ async def checkout_repository_branch(
|
|||||||
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:
|
||||||
"""Checkout a branch."""
|
"""Checkout a branch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
data: Checkout request containing the branch name.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success message and checked out branch name.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -625,7 +868,12 @@ class CommitResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse)
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/commit",
|
||||||
|
response_model=CommitResponse,
|
||||||
|
summary="Commit changes",
|
||||||
|
description="Commit changes to the repository.",
|
||||||
|
)
|
||||||
async def commit_repository_changes(
|
async def commit_repository_changes(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -633,7 +881,18 @@ async def commit_repository_changes(
|
|||||||
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),
|
||||||
) -> CommitResponse:
|
) -> CommitResponse:
|
||||||
"""Commit changes to the repository."""
|
"""Commit changes to the repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
data: Commit request containing message and optional files to commit.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Commit information including hash and message.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -669,14 +928,29 @@ class FetchResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse)
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/fetch",
|
||||||
|
response_model=FetchResponse,
|
||||||
|
summary="Fetch from remote",
|
||||||
|
description="Fetch updates from the remote repository.",
|
||||||
|
)
|
||||||
async def fetch_repository(
|
async def fetch_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
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),
|
||||||
) -> FetchResponse:
|
) -> FetchResponse:
|
||||||
"""Fetch from remote."""
|
"""Fetch from remote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -698,7 +972,12 @@ class PullResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse)
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/pull",
|
||||||
|
response_model=PullResponse,
|
||||||
|
summary="Pull from remote",
|
||||||
|
description="Pull updates from the remote repository.",
|
||||||
|
)
|
||||||
async def pull_repository(
|
async def pull_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -706,7 +985,18 @@ async def pull_repository(
|
|||||||
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),
|
||||||
) -> PullResponse:
|
) -> PullResponse:
|
||||||
"""Pull updates from remote."""
|
"""Pull updates from remote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
branch: Optional branch name to pull.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -728,7 +1018,12 @@ class PushResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse)
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/push",
|
||||||
|
response_model=PushResponse,
|
||||||
|
summary="Push to remote",
|
||||||
|
description="Push changes to the remote repository.",
|
||||||
|
)
|
||||||
async def push_repository(
|
async def push_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -736,7 +1031,18 @@ async def push_repository(
|
|||||||
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),
|
||||||
) -> PushResponse:
|
) -> PushResponse:
|
||||||
"""Push changes to remote."""
|
"""Push changes to remote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
branch: Optional branch name to push.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -765,7 +1071,12 @@ class MergeResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse)
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/merge",
|
||||||
|
response_model=MergeResponse,
|
||||||
|
summary="Merge branches",
|
||||||
|
description="Merge one branch into another.",
|
||||||
|
)
|
||||||
async def merge_repository_branches(
|
async def merge_repository_branches(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -773,7 +1084,18 @@ async def merge_repository_branches(
|
|||||||
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),
|
||||||
) -> MergeResponse:
|
) -> MergeResponse:
|
||||||
"""Merge branches."""
|
"""Merge branches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
data: Merge request containing source branch, optional target branch, and message.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merge result with commit hash and message.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Health check endpoints and models."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from src.config import Settings
|
||||||
|
from src.database import SessionLocal
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Track start time for uptime
|
||||||
|
_start_time = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseHealth(BaseModel):
|
||||||
|
"""Database health check result."""
|
||||||
|
|
||||||
|
status: str = Field(description="Database health status", examples=["healthy"])
|
||||||
|
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||||
|
|
||||||
|
|
||||||
|
class DiskHealth(BaseModel):
|
||||||
|
"""Disk space health check result."""
|
||||||
|
|
||||||
|
status: str = Field(description="Disk health status", examples=["healthy"])
|
||||||
|
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
|
||||||
|
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
|
||||||
|
|
||||||
|
|
||||||
|
class HealthChecks(BaseModel):
|
||||||
|
"""Individual health checks."""
|
||||||
|
|
||||||
|
database: DatabaseHealth | None = None
|
||||||
|
disk: DiskHealth | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""Overall health check response."""
|
||||||
|
|
||||||
|
status: str = Field(description="Overall health status", examples=["healthy"])
|
||||||
|
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
|
||||||
|
version: str = Field(description="API version", examples=["0.1.0"])
|
||||||
|
checks: HealthChecks = Field(description="Individual health checks")
|
||||||
|
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseHealthResponse(BaseModel):
|
||||||
|
"""Database-specific health check response."""
|
||||||
|
|
||||||
|
status: str = Field(description="Database health status", examples=["healthy"])
|
||||||
|
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/health",
|
||||||
|
response_model=HealthResponse,
|
||||||
|
summary="Health check",
|
||||||
|
description="Returns overall system health status including database and disk checks.",
|
||||||
|
tags=["Health"],
|
||||||
|
)
|
||||||
|
async def health_check() -> dict[str, Any]:
|
||||||
|
"""Check overall system health.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HealthResponse with status, timestamp, version, checks, and uptime.
|
||||||
|
"""
|
||||||
|
checks = HealthChecks()
|
||||||
|
overall_status = "healthy"
|
||||||
|
|
||||||
|
# Database check
|
||||||
|
try:
|
||||||
|
import time as time_module
|
||||||
|
|
||||||
|
start = time_module.perf_counter()
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
db_time = (time_module.perf_counter() - start) * 1000
|
||||||
|
checks.database = DatabaseHealth(
|
||||||
|
status="healthy",
|
||||||
|
response_time_ms=round(db_time, 2),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
checks.database = DatabaseHealth(
|
||||||
|
status="unhealthy",
|
||||||
|
response_time_ms=0.0,
|
||||||
|
)
|
||||||
|
overall_status = "degraded"
|
||||||
|
|
||||||
|
# Disk check
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
disk = shutil.disk_usage("/")
|
||||||
|
free_gb = disk.free / (1024**3)
|
||||||
|
total_gb = disk.total / (1024**3)
|
||||||
|
disk_status = "healthy" if free_gb > 1.0 else "degraded"
|
||||||
|
if disk_status == "degraded":
|
||||||
|
overall_status = "degraded"
|
||||||
|
checks.disk = DiskHealth(
|
||||||
|
status=disk_status,
|
||||||
|
free_gb=round(free_gb, 2),
|
||||||
|
total_gb=round(total_gb, 2),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
checks.disk = None
|
||||||
|
|
||||||
|
return HealthResponse(
|
||||||
|
status=overall_status,
|
||||||
|
timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
version="0.1.0",
|
||||||
|
checks=checks,
|
||||||
|
uptime_seconds=round(time.time() - _start_time, 2),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/health/db",
|
||||||
|
response_model=DatabaseHealthResponse,
|
||||||
|
summary="Database health check",
|
||||||
|
description="Returns database-specific health status with response time.",
|
||||||
|
tags=["Health"],
|
||||||
|
)
|
||||||
|
async def health_check_db() -> dict[str, Any]:
|
||||||
|
"""Check database health.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DatabaseHealthResponse with status and response time.
|
||||||
|
"""
|
||||||
|
import time as time_module
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = time_module.perf_counter()
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
db_time = (time_module.perf_counter() - start) * 1000
|
||||||
|
return DatabaseHealthResponse(
|
||||||
|
status="healthy",
|
||||||
|
response_time_ms=round(db_time, 2),
|
||||||
|
).model_dump()
|
||||||
|
except Exception:
|
||||||
|
return DatabaseHealthResponse(
|
||||||
|
status="unhealthy",
|
||||||
|
response_time_ms=0.0,
|
||||||
|
).model_dump()
|
||||||
@@ -17,6 +17,7 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
@@ -47,12 +48,28 @@ class SetDefaultSSHKeyRequest(BaseModel):
|
|||||||
ssh_key_id: uuid.UUID
|
ssh_key_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=ProjectResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create a new project",
|
||||||
|
description="Create a new project for the authenticated user.",
|
||||||
|
)
|
||||||
async def create_project(
|
async def create_project(
|
||||||
data: ProjectCreate,
|
data: ProjectCreate,
|
||||||
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),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
"""Create a new project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Project creation data including name and optional description.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created project.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
project = Project(
|
project = Project(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
@@ -66,22 +83,51 @@ async def create_project(
|
|||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ProjectResponse])
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=list[ProjectResponse],
|
||||||
|
summary="List all projects",
|
||||||
|
description="Retrieve all projects owned by the authenticated user.",
|
||||||
|
)
|
||||||
async def list_projects(
|
async def list_projects(
|
||||||
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),
|
||||||
) -> list[Project]:
|
) -> list[Project]:
|
||||||
|
"""List all projects for the authenticated user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of projects owned by the user.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
@router.get(
|
||||||
|
"/{project_id}",
|
||||||
|
response_model=ProjectResponse,
|
||||||
|
summary="Get a project",
|
||||||
|
description="Retrieve a specific project by ID.",
|
||||||
|
)
|
||||||
async def get_project(
|
async def get_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
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),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
"""Get a specific project by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project to retrieve.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The requested project.
|
||||||
|
"""
|
||||||
await _get_user(session, user_id)
|
await _get_user(session, user_id)
|
||||||
return await _get_owned_project(project_id, user_id, session)
|
return await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -91,6 +137,19 @@ async def _get_owned_project(
|
|||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
"""Fetch a project and verify ownership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The project if found and owned by the user.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If project not found or user is not the owner.
|
||||||
|
"""
|
||||||
project = await session.get(Project, project_id)
|
project = await session.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||||
@@ -99,13 +158,29 @@ async def _get_owned_project(
|
|||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{project_id}", response_model=ProjectResponse)
|
@router.patch(
|
||||||
|
"/{project_id}",
|
||||||
|
response_model=ProjectResponse,
|
||||||
|
summary="Update a project",
|
||||||
|
description="Update a project's name or description.",
|
||||||
|
)
|
||||||
async def update_project(
|
async def update_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: ProjectUpdate,
|
data: ProjectUpdate,
|
||||||
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),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
"""Update a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project to update.
|
||||||
|
data: Project update data with optional name and description.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated project.
|
||||||
|
"""
|
||||||
await _get_user(session, user_id)
|
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)
|
||||||
|
|
||||||
@@ -119,12 +194,27 @@ async def update_project(
|
|||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete(
|
||||||
|
"/{project_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete a project",
|
||||||
|
description="Delete a project and all its associated repositories.",
|
||||||
|
)
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
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),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
|
"""Delete a project and all its repositories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project to delete.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Empty response with 204 status code.
|
||||||
|
"""
|
||||||
await _get_user(session, user_id)
|
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)
|
||||||
|
|
||||||
@@ -141,13 +231,29 @@ async def delete_project(
|
|||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{project_id}/default-ssh-key", response_model=ProjectResponse)
|
@router.patch(
|
||||||
|
"/{project_id}/default-ssh-key",
|
||||||
|
response_model=ProjectResponse,
|
||||||
|
summary="Set default SSH key",
|
||||||
|
description="Set the default SSH key for a project.",
|
||||||
|
)
|
||||||
async def set_default_ssh_key(
|
async def set_default_ssh_key(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: SetDefaultSSHKeyRequest,
|
data: SetDefaultSSHKeyRequest,
|
||||||
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),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
"""Set the default SSH key for a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
data: Request containing the SSH key ID to set as default.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated project.
|
||||||
|
"""
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
@@ -38,6 +39,11 @@ def _get_fernet() -> Fernet:
|
|||||||
|
|
||||||
|
|
||||||
def generate_ssh_key_pair() -> tuple[str, str]:
|
def generate_ssh_key_pair() -> tuple[str, str]:
|
||||||
|
"""Generate a new Ed25519 SSH key pair.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (private_key, public_key) as strings.
|
||||||
|
"""
|
||||||
private_key = Ed25519PrivateKey.generate()
|
private_key = Ed25519PrivateKey.generate()
|
||||||
public_key = private_key.public_key()
|
public_key = private_key.public_key()
|
||||||
|
|
||||||
@@ -68,12 +74,28 @@ class SSHKeyResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=SSHKeyResponse, status_code=status.HTTP_201_CREATED)
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=SSHKeyResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create SSH key",
|
||||||
|
description="Generate a new Ed25519 SSH key pair for the authenticated user.",
|
||||||
|
)
|
||||||
async def create_ssh_key(
|
async def create_ssh_key(
|
||||||
data: SSHKeyCreate,
|
data: SSHKeyCreate,
|
||||||
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),
|
||||||
) -> SSHKey:
|
) -> SSHKey:
|
||||||
|
"""Create a new SSH key pair.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: SSH key creation data including the key name.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created SSH key with public key exposed.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
private_key, public_key = generate_ssh_key_pair()
|
private_key, public_key = generate_ssh_key_pair()
|
||||||
|
|
||||||
@@ -92,22 +114,51 @@ async def create_ssh_key(
|
|||||||
return ssh_key
|
return ssh_key
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SSHKeyResponse])
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=list[SSHKeyResponse],
|
||||||
|
summary="List SSH keys",
|
||||||
|
description="List all SSH keys for the authenticated user.",
|
||||||
|
)
|
||||||
async def list_ssh_keys(
|
async def list_ssh_keys(
|
||||||
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),
|
||||||
) -> list[SSHKey]:
|
) -> list[SSHKey]:
|
||||||
|
"""List all SSH keys for the authenticated user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SSH keys owned by the user.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete(
|
||||||
|
"/{key_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete SSH key",
|
||||||
|
description="Delete an SSH key by ID.",
|
||||||
|
)
|
||||||
async def delete_ssh_key(
|
async def delete_ssh_key(
|
||||||
key_id: uuid.UUID,
|
key_id: uuid.UUID,
|
||||||
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:
|
||||||
|
"""Delete an SSH key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to delete.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None with 204 status code.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""WebSocket terminal endpoint for tool instances."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||||
|
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.tool_instance import ToolInstance
|
||||||
|
from src.services.terminal_manager import terminal_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket(
|
||||||
|
"/ws/tool-instances/{instance_id}/terminal",
|
||||||
|
)
|
||||||
|
async def terminal_websocket(
|
||||||
|
websocket: WebSocket,
|
||||||
|
instance_id: str,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""WebSocket endpoint for terminal access to a tool instance.
|
||||||
|
|
||||||
|
Provides an interactive terminal session inside a running tool instance container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket: The WebSocket connection.
|
||||||
|
instance_id: UUID string of the tool instance.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None. Communicates via WebSocket messages.
|
||||||
|
"""
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse instance_id
|
||||||
|
instance_uuid = uuid.UUID(instance_id)
|
||||||
|
except ValueError:
|
||||||
|
await websocket.close(code=4001, reason="Invalid instance ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Authenticate user from session cookie
|
||||||
|
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||||
|
if user_id is None:
|
||||||
|
await websocket.close(code=4003, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get instance and verify ownership
|
||||||
|
instance = await db_session.get(ToolInstance, instance_uuid)
|
||||||
|
if instance is None:
|
||||||
|
await websocket.close(code=4004, reason="Instance not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
if instance.owner_id != user_id:
|
||||||
|
await websocket.close(code=4003, reason="Forbidden")
|
||||||
|
return
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_id:
|
||||||
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create terminal session
|
||||||
|
try:
|
||||||
|
session = await terminal_manager.create_session(
|
||||||
|
instance_uuid,
|
||||||
|
instance.container_id,
|
||||||
|
websocket,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send connected status
|
||||||
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
|
||||||
|
# Keep connection alive until closed
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
break
|
||||||
|
except RuntimeError:
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||||
|
finally:
|
||||||
|
# Cleanup will be handled by the session manager
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user_from_websocket(
|
||||||
|
websocket: WebSocket,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> uuid.UUID | None:
|
||||||
|
"""Extract and validate user ID from session cookie in WebSocket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket: The WebSocket connection.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The user's UUID if authenticated, None otherwise.
|
||||||
|
"""
|
||||||
|
from src.auth.session import verify_session_token
|
||||||
|
|
||||||
|
session_cookie = websocket.cookies.get("session")
|
||||||
|
if not session_cookie:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id = verify_session_token(session_cookie)
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
@@ -30,6 +30,7 @@ router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 404 if not found."""
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -41,6 +42,19 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|||||||
async def _get_owned_project(
|
async def _get_owned_project(
|
||||||
project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession
|
project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession
|
||||||
) -> Project:
|
) -> Project:
|
||||||
|
"""Fetch a project and verify ownership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The project if found and owned by the user.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If project not found or user is not the owner.
|
||||||
|
"""
|
||||||
project = await session.get(Project, project_id)
|
project = await session.get(Project, project_id)
|
||||||
if project is None or project.owner_id != user_id:
|
if project is None or project.owner_id != user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -49,7 +63,11 @@ async def _get_owned_project(
|
|||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/instances")
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances",
|
||||||
|
summary="Create tool instance",
|
||||||
|
description="Create a new tool instance for a repository.",
|
||||||
|
)
|
||||||
async def create_instance(
|
async def create_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -58,7 +76,19 @@ async def create_instance(
|
|||||||
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:
|
||||||
"""Create a new tool instance for a repository."""
|
"""Create a new tool instance for a repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
tool_type_id: UUID of the tool type to instantiate.
|
||||||
|
display_name: Optional display name for the instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with instance details.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -123,14 +153,28 @@ async def create_instance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/instances")
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances",
|
||||||
|
summary="List instances",
|
||||||
|
description="List all tool instances for a repository.",
|
||||||
|
)
|
||||||
async def list_instances(
|
async def list_instances(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
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:
|
||||||
"""List all instances for a repository."""
|
"""List all instances for a repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing list of instances.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -165,7 +209,11 @@ async def list_instances(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
|
||||||
|
summary="Get instance",
|
||||||
|
description="Get a specific instance with real-time status from Docker.",
|
||||||
|
)
|
||||||
async def get_instance(
|
async def get_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -173,7 +221,18 @@ async def get_instance(
|
|||||||
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:
|
||||||
"""Get a specific instance with real-time status."""
|
"""Get a specific instance with real-time status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with instance details and current status.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -210,7 +269,11 @@ async def get_instance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/start")
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/start",
|
||||||
|
summary="Start instance",
|
||||||
|
description="Start a tool instance using Docker Compose.",
|
||||||
|
)
|
||||||
async def start_instance(
|
async def start_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -218,7 +281,18 @@ async def start_instance(
|
|||||||
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:
|
||||||
"""Start a tool instance."""
|
"""Start a tool instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the instance to start.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status and URL of the running instance.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -262,7 +336,11 @@ async def start_instance(
|
|||||||
return {"status": instance.status, "url": instance.url}
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop")
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop",
|
||||||
|
summary="Stop instance",
|
||||||
|
description="Stop a running tool instance.",
|
||||||
|
)
|
||||||
async def stop_instance(
|
async def stop_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -270,7 +348,18 @@ async def stop_instance(
|
|||||||
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:
|
||||||
"""Stop a tool instance."""
|
"""Stop a tool instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the instance to stop.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with the stopped status.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -291,7 +380,11 @@ async def stop_instance(
|
|||||||
return {"status": instance.status}
|
return {"status": instance.status}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart")
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart",
|
||||||
|
summary="Restart instance",
|
||||||
|
description="Restart a tool instance.",
|
||||||
|
)
|
||||||
async def restart_instance(
|
async def restart_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -299,7 +392,18 @@ async def restart_instance(
|
|||||||
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:
|
||||||
"""Restart a tool instance."""
|
"""Restart a tool instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the instance to restart.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status and URL of the restarted instance.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -326,7 +430,11 @@ async def restart_instance(
|
|||||||
return {"status": instance.status}
|
return {"status": instance.status}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
|
@router.delete(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
|
||||||
|
summary="Delete instance",
|
||||||
|
description="Delete a tool instance and remove its Docker containers and files.",
|
||||||
|
)
|
||||||
async def delete_instance(
|
async def delete_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -334,7 +442,18 @@ async def delete_instance(
|
|||||||
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:
|
||||||
"""Delete a tool instance."""
|
"""Delete a tool instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the instance to delete.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None with 204 status code.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -359,7 +478,11 @@ async def delete_instance(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs")
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs",
|
||||||
|
summary="Get instance logs",
|
||||||
|
description="Get container logs for a tool instance.",
|
||||||
|
)
|
||||||
async def get_instance_logs(
|
async def get_instance_logs(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -368,7 +491,19 @@ async def get_instance_logs(
|
|||||||
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:
|
||||||
"""Get container logs for an instance."""
|
"""Get container logs for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the instance.
|
||||||
|
tail: Number of log lines to return (default: 100).
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing the container logs.
|
||||||
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
@@ -389,12 +524,24 @@ from fastapi import APIRouter as FastAPIRouter
|
|||||||
|
|
||||||
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
|
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
|
||||||
|
|
||||||
@sessions_router.get("/me/sessions")
|
@sessions_router.get(
|
||||||
|
"/me/sessions",
|
||||||
|
summary="Get user sessions",
|
||||||
|
description="Get all active sessions (running instances) for the current user.",
|
||||||
|
)
|
||||||
async def get_user_sessions(
|
async def get_user_sessions(
|
||||||
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:
|
||||||
"""Get all active sessions (running instances) for the current user."""
|
"""Get all active sessions for the current user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing list of active sessions with instance details.
|
||||||
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
_user = await _get_user(session, user_id)
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
@@ -22,6 +23,11 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|||||||
|
|
||||||
|
|
||||||
async def _require_admin(user: User) -> None:
|
async def _require_admin(user: User) -> None:
|
||||||
|
"""Check if user has admin privileges.
|
||||||
|
|
||||||
|
For now, all authenticated users can manage tool types.
|
||||||
|
In production, this should check user.role or similar.
|
||||||
|
"""
|
||||||
# For now, all authenticated users can manage tool types
|
# For now, all authenticated users can manage tool types
|
||||||
# In production, check user.role or similar
|
# In production, check user.role or similar
|
||||||
pass
|
pass
|
||||||
@@ -117,12 +123,28 @@ class ToolTypeResponse(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ToolTypeResponse, status_code=status.HTTP_201_CREATED)
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=ToolTypeResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create tool type",
|
||||||
|
description="Create a new custom tool type with a Docker Compose template.",
|
||||||
|
)
|
||||||
async def create_tool_type(
|
async def create_tool_type(
|
||||||
data: ToolTypeCreate,
|
data: ToolTypeCreate,
|
||||||
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),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
|
"""Create a new tool type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Tool type creation data including name, display name, and compose template.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created tool type.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
@@ -146,22 +168,51 @@ async def create_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ToolTypeResponse])
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=list[ToolTypeResponse],
|
||||||
|
summary="List tool types",
|
||||||
|
description="List all available tool types including built-in and custom ones.",
|
||||||
|
)
|
||||||
async def list_tool_types(
|
async def list_tool_types(
|
||||||
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),
|
||||||
) -> list[ToolType]:
|
) -> list[ToolType]:
|
||||||
|
"""List all tool types.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all tool types ordered by name.
|
||||||
|
"""
|
||||||
await _get_user(session, user_id)
|
await _get_user(session, user_id)
|
||||||
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tool_type_id}", response_model=ToolTypeResponse)
|
@router.get(
|
||||||
|
"/{tool_type_id}",
|
||||||
|
response_model=ToolTypeResponse,
|
||||||
|
summary="Get tool type",
|
||||||
|
description="Get a specific tool type by ID.",
|
||||||
|
)
|
||||||
async def get_tool_type(
|
async def get_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
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),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
|
"""Get a specific tool type by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_type_id: UUID of the tool type to retrieve.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The requested tool type.
|
||||||
|
"""
|
||||||
await _get_user(session, user_id)
|
await _get_user(session, user_id)
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
@@ -169,13 +220,29 @@ async def get_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{tool_type_id}", response_model=ToolTypeResponse)
|
@router.put(
|
||||||
|
"/{tool_type_id}",
|
||||||
|
response_model=ToolTypeResponse,
|
||||||
|
summary="Update tool type",
|
||||||
|
description="Update a custom tool type. Built-in tool types cannot be modified.",
|
||||||
|
)
|
||||||
async def update_tool_type(
|
async def update_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
data: ToolTypeUpdate,
|
data: ToolTypeUpdate,
|
||||||
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),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
|
"""Update a tool type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_type_id: UUID of the tool type to update.
|
||||||
|
data: Tool type update data with optional fields.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated tool type.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
@@ -217,12 +284,27 @@ async def update_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tool_type_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete(
|
||||||
|
"/{tool_type_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete tool type",
|
||||||
|
description="Delete a custom tool type. Built-in tool types cannot be deleted.",
|
||||||
|
)
|
||||||
async def delete_tool_type(
|
async def delete_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
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:
|
||||||
|
"""Delete a tool type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_type_id: UUID of the tool type to delete.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None with 204 status code.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ router = APIRouter(prefix="/users/me", tags=["user-config"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
@@ -20,6 +21,15 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|||||||
|
|
||||||
|
|
||||||
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
||||||
|
"""Get or create user config record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: UUID of the user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The user's config, creating a new one if it doesn't exist.
|
||||||
|
"""
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
if config is None:
|
if config is None:
|
||||||
@@ -46,22 +56,51 @@ class UserConfigUpdate(BaseModel):
|
|||||||
git_user_email: str | None = None
|
git_user_email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=UserConfigResponse)
|
@router.get(
|
||||||
|
"/config",
|
||||||
|
response_model=UserConfigResponse,
|
||||||
|
summary="Get user config",
|
||||||
|
description="Get the current user's configuration settings.",
|
||||||
|
)
|
||||||
async def get_user_config(
|
async def get_user_config(
|
||||||
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),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
|
"""Get the current user's configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The user's configuration settings.
|
||||||
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
_user = await _get_user(session, user_id)
|
||||||
config = await _get_or_create_config(session, user_id)
|
config = await _get_or_create_config(session, user_id)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/config", response_model=UserConfigResponse)
|
@router.patch(
|
||||||
|
"/config",
|
||||||
|
response_model=UserConfigResponse,
|
||||||
|
summary="Update user config",
|
||||||
|
description="Update the current user's configuration settings.",
|
||||||
|
)
|
||||||
async def update_user_config(
|
async def update_user_config(
|
||||||
data: UserConfigUpdate,
|
data: UserConfigUpdate,
|
||||||
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),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
|
"""Update the current user's configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Configuration update data with optional fields.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated user configuration.
|
||||||
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
_user = await _get_user(session, user_id)
|
||||||
config = await _get_or_create_config(session, user_id)
|
config = await _get_or_create_config(session, user_id)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
@@ -37,20 +38,49 @@ class UserProfileUpdate(BaseModel):
|
|||||||
email: str | None = None
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserProfileResponse)
|
@router.get(
|
||||||
|
"/me",
|
||||||
|
response_model=UserProfileResponse,
|
||||||
|
summary="Get current user profile",
|
||||||
|
description="Retrieve the profile of the currently authenticated user.",
|
||||||
|
)
|
||||||
async def get_profile(
|
async def get_profile(
|
||||||
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),
|
||||||
) -> User:
|
) -> User:
|
||||||
|
"""Get the current user's profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The user's profile information.
|
||||||
|
"""
|
||||||
return await _get_user(session, user_id)
|
return await _get_user(session, user_id)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/me", response_model=UserProfileResponse)
|
@router.put(
|
||||||
|
"/me",
|
||||||
|
response_model=UserProfileResponse,
|
||||||
|
summary="Update user profile",
|
||||||
|
description="Update the current user's profile information.",
|
||||||
|
)
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
data: UserProfileUpdate,
|
data: UserProfileUpdate,
|
||||||
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),
|
||||||
) -> User:
|
) -> User:
|
||||||
|
"""Update the current user's profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Profile update data with optional name and email.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated user profile.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
@@ -68,12 +98,27 @@ async def update_profile(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/avatar", response_model=UserProfileResponse)
|
@router.post(
|
||||||
|
"/me/avatar",
|
||||||
|
response_model=UserProfileResponse,
|
||||||
|
summary="Upload avatar",
|
||||||
|
description="Upload a profile avatar image (PNG or JPG, max 2MB).",
|
||||||
|
)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
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),
|
||||||
) -> User:
|
) -> User:
|
||||||
|
"""Upload a profile avatar image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: The image file to upload (PNG or JPG, max 2MB).
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated user profile with new avatar URL.
|
||||||
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||||
|
|||||||
+4
-11
@@ -9,8 +9,10 @@ from sqlalchemy import select, 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
|
||||||
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.health import router as health_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.terminal import router as terminal_router
|
||||||
from src.api.tool_instances import router as tool_instances_router
|
from src.api.tool_instances import router as tool_instances_router
|
||||||
from src.api.tool_instances import sessions_router
|
from src.api.tool_instances import sessions_router
|
||||||
from src.api.tool_types import router as tool_types_router
|
from src.api.tool_types import router as tool_types_router
|
||||||
@@ -147,17 +149,7 @@ async def on_startup():
|
|||||||
await seed_builtin_tool_types()
|
await seed_builtin_tool_types()
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
@app.get("/health")
|
app.include_router(health_router)
|
||||||
async def health_check():
|
|
||||||
try:
|
|
||||||
from sqlalchemy import text
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
await session.execute(text("SELECT 1"))
|
|
||||||
return {"status": "healthy", "database": "connected"}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Health check failed: %s", exc)
|
|
||||||
return {"status": "unhealthy", "database": "disconnected", "error": str(exc)}
|
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
app.include_router(projects_router)
|
app.include_router(projects_router)
|
||||||
@@ -168,4 +160,5 @@ 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(tool_instances_router)
|
||||||
app.include_router(sessions_router)
|
app.include_router(sessions_router)
|
||||||
|
app.include_router(terminal_router)
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Terminal session manager for WebSocket connections."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from src.services.terminal_session import TerminalSession
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalManager:
|
||||||
|
"""Manages active terminal sessions."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._sessions: dict[str, TerminalSession] = {}
|
||||||
|
|
||||||
|
async def create_session(
|
||||||
|
self,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
container_id: str,
|
||||||
|
websocket: WebSocket,
|
||||||
|
) -> TerminalSession:
|
||||||
|
"""Create a new terminal session."""
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
session = TerminalSession(session_id, instance_id, container_id)
|
||||||
|
await session.start()
|
||||||
|
self._sessions[session_id] = session
|
||||||
|
|
||||||
|
# Start background tasks for I/O streaming
|
||||||
|
asyncio.create_task(self._read_loop(session, websocket))
|
||||||
|
asyncio.create_task(self._write_loop(session, websocket))
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
||||||
|
"""Read output from the container and send to WebSocket."""
|
||||||
|
try:
|
||||||
|
while session.is_alive() and not session._closed:
|
||||||
|
data = await session.read_output()
|
||||||
|
if data:
|
||||||
|
await websocket.send_bytes(data)
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await self._cleanup_session(session)
|
||||||
|
|
||||||
|
async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
||||||
|
"""Read input from WebSocket and send to container."""
|
||||||
|
try:
|
||||||
|
while session.is_alive() and not session._closed:
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message["type"] == "websocket.receive":
|
||||||
|
if "bytes" in message:
|
||||||
|
await session.write_input(message["bytes"])
|
||||||
|
elif "text" in message:
|
||||||
|
text = message["text"]
|
||||||
|
if text.startswith("{"):
|
||||||
|
# Control message (JSON)
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
ctrl = json.loads(text)
|
||||||
|
if ctrl.get("type") == "resize":
|
||||||
|
await session.resize(
|
||||||
|
ctrl.get("cols", 80),
|
||||||
|
ctrl.get("rows", 24),
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
elif message["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await self._cleanup_session(session)
|
||||||
|
|
||||||
|
async def _cleanup_session(self, session: TerminalSession) -> None:
|
||||||
|
"""Clean up a session."""
|
||||||
|
if session.session_id in self._sessions:
|
||||||
|
del self._sessions[session.session_id]
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
async def close_all(self) -> None:
|
||||||
|
"""Close all active sessions."""
|
||||||
|
sessions = list(self._sessions.values())
|
||||||
|
self._sessions.clear()
|
||||||
|
for session in sessions:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Global terminal manager instance
|
||||||
|
terminal_manager = TerminalManager()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Terminal session management for tool instances."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalSession:
|
||||||
|
"""Manages a single terminal session connected to a docker container."""
|
||||||
|
|
||||||
|
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
|
||||||
|
self.session_id = session_id
|
||||||
|
self.instance_id = instance_id
|
||||||
|
self.container_id = container_id
|
||||||
|
self.process: asyncio.subprocess.Process | None = None
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start the docker exec process with a shell."""
|
||||||
|
self.process = await asyncio.create_subprocess_exec(
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"-i",
|
||||||
|
self.container_id,
|
||||||
|
"/bin/sh",
|
||||||
|
"-c",
|
||||||
|
"exec bash -l || exec sh -l",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def read_output(self) -> bytes:
|
||||||
|
"""Read output from the process."""
|
||||||
|
if self.process is None or self.process.stdout is None:
|
||||||
|
return b""
|
||||||
|
try:
|
||||||
|
return await self.process.stdout.read(4096)
|
||||||
|
except (asyncio.CancelledError, BrokenPipeError):
|
||||||
|
return b""
|
||||||
|
|
||||||
|
async def write_input(self, data: bytes) -> None:
|
||||||
|
"""Write input to the process."""
|
||||||
|
if self.process is None or self.process.stdin is None or self._closed:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.process.stdin.write(data)
|
||||||
|
await self.process.stdin.drain()
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def resize(self, cols: int, rows: int) -> None:
|
||||||
|
"""Resize the terminal."""
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
self.container_id,
|
||||||
|
"stty",
|
||||||
|
"cols",
|
||||||
|
str(cols),
|
||||||
|
"rows",
|
||||||
|
str(rows),
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the session and cleanup."""
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
if self.process is not None:
|
||||||
|
try:
|
||||||
|
self.process.kill()
|
||||||
|
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
||||||
|
except (asyncio.TimeoutError, ProcessLookupError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
"""Check if the session process is still running."""
|
||||||
|
if self.process is None:
|
||||||
|
return False
|
||||||
|
return self.process.returncode is None
|
||||||
Generated
+31
-1
@@ -16,7 +16,10 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"tailwindcss": "^3.3.0"
|
"tailwindcss": "^3.3.0",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
|
"xterm-addon-fit": "^0.8.0",
|
||||||
|
"xterm-addon-web-links": "^0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -6352,6 +6355,33 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/xterm": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||||
|
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/xterm-addon-fit": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
|
||||||
|
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xterm-addon-web-links": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==",
|
||||||
|
"deprecated": "This package is now deprecated. Move to @xterm/addon-web-links instead.",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"tailwindcss": "^3.3.0"
|
"tailwindcss": "^3.3.0",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
|
"xterm-addon-fit": "^0.8.0",
|
||||||
|
"xterm-addon-web-links": "^0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
ArrowSquareOut,
|
ArrowSquareOut,
|
||||||
Play,
|
Play,
|
||||||
Stop,
|
Stop,
|
||||||
|
Terminal,
|
||||||
|
ArrowLeft,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
export type IconName =
|
export type IconName =
|
||||||
@@ -71,7 +73,9 @@ export type IconName =
|
|||||||
| "binary"
|
| "binary"
|
||||||
| "external"
|
| "external"
|
||||||
| "play"
|
| "play"
|
||||||
| "stop";
|
| "stop"
|
||||||
|
| "terminal"
|
||||||
|
| "arrow-left";
|
||||||
|
|
||||||
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
||||||
dashboard: House,
|
dashboard: House,
|
||||||
@@ -111,6 +115,8 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
|
|||||||
external: ArrowSquareOut,
|
external: ArrowSquareOut,
|
||||||
play: Play,
|
play: Play,
|
||||||
stop: Stop,
|
stop: Stop,
|
||||||
|
terminal: Terminal,
|
||||||
|
"arrow-left": ArrowLeft,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { ToolInstance } from "../api/sessions";
|
import type { ToolInstance } from "../api/sessions";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ interface InstanceListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
|
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
|
||||||
|
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);
|
||||||
@@ -154,6 +156,16 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
Open
|
Open
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{instance.status === "running" && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="terminal" size="sm" />
|
||||||
|
Terminal
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{instance.status !== "running" && (
|
{instance.status !== "running" && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { Terminal } from "xterm";
|
||||||
|
import { FitAddon } from "xterm-addon-fit";
|
||||||
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||||
|
import "xterm/css/xterm.css";
|
||||||
|
|
||||||
|
interface TerminalProps {
|
||||||
|
instanceId: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
|
||||||
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
|
||||||
|
"connecting",
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!terminalRef.current) return;
|
||||||
|
|
||||||
|
// Initialize terminal
|
||||||
|
const term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
|
theme: {
|
||||||
|
background: "#1e1e1e",
|
||||||
|
foreground: "#d4d4d4",
|
||||||
|
cursor: "#d4d4d4",
|
||||||
|
selectionBackground: "#264f78",
|
||||||
|
black: "#000000",
|
||||||
|
red: "#cd3131",
|
||||||
|
green: "#0dbc79",
|
||||||
|
yellow: "#e5e510",
|
||||||
|
blue: "#2472c8",
|
||||||
|
magenta: "#bc3fbc",
|
||||||
|
cyan: "#11a8cd",
|
||||||
|
white: "#e5e5e5",
|
||||||
|
brightBlack: "#666666",
|
||||||
|
brightRed: "#f14c4c",
|
||||||
|
brightGreen: "#23d18b",
|
||||||
|
brightYellow: "#f5f543",
|
||||||
|
brightBlue: "#3b8eea",
|
||||||
|
brightMagenta: "#d670d6",
|
||||||
|
brightCyan: "#29b8db",
|
||||||
|
brightWhite: "#e5e5e5",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.loadAddon(new WebLinksAddon());
|
||||||
|
|
||||||
|
term.open(terminalRef.current);
|
||||||
|
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
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
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
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
const handleResize = () => {
|
||||||
|
fitAddon.fit();
|
||||||
|
const { cols, rows } = term;
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "resize",
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
// Initial resize
|
||||||
|
setTimeout(handleResize, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
ws.close();
|
||||||
|
term.dispose();
|
||||||
|
};
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="terminal-wrapper">
|
||||||
|
<div className="terminal-header">
|
||||||
|
<div className="terminal-status">
|
||||||
|
<span
|
||||||
|
className={`status-dot ${status}`}
|
||||||
|
aria-label={`Terminal status: ${status}`}
|
||||||
|
/>
|
||||||
|
<span className="status-text">{status}</span>
|
||||||
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<button className="terminal-close" onClick={onClose} type="button">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <div className="terminal-error">{error}</div>}
|
||||||
|
<div ref={terminalRef} className="terminal-container" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { TerminalComponent } from "../components/terminal";
|
||||||
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
|
export const TerminalPage: React.FC = () => {
|
||||||
|
const { instanceId } = useParams<{ instanceId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!instanceId) {
|
||||||
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<h1>Terminal</h1>
|
||||||
|
<p className="muted">No instance ID provided.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="terminal-page">
|
||||||
|
<div className="terminal-page-header">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<h1>Terminal</h1>
|
||||||
|
</div>
|
||||||
|
<TerminalComponent
|
||||||
|
instanceId={instanceId}
|
||||||
|
onClose={() => navigate(-1)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import { ProjectSettingsPage } from "./pages/project-settings";
|
|||||||
import { RepoWorkspace } from "./pages/repo-workspace";
|
import { RepoWorkspace } from "./pages/repo-workspace";
|
||||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||||
import { SettingsPage } from "./pages/settings";
|
import { SettingsPage } from "./pages/settings";
|
||||||
|
import { TerminalPage } from "./pages/terminal";
|
||||||
import { ToolTypesPage } from "./pages/tool-types";
|
import { ToolTypesPage } from "./pages/tool-types";
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
@@ -36,6 +37,7 @@ export const AppRouter = () => {
|
|||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="tool-types" element={<ToolTypesPage />} />
|
<Route path="tool-types" element={<ToolTypesPage />} />
|
||||||
|
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/404" element={<NotFoundPage />} />
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||||
|
|||||||
@@ -2321,3 +2321,138 @@ a.nav-item,
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Terminal Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.terminal-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
padding: var(--space-4);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-bottom: 1px solid #3e3e3e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connecting {
|
||||||
|
background: #f5f543;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connected {
|
||||||
|
background: #0dbc79;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.disconnected,
|
||||||
|
.status-dot.error {
|
||||||
|
background: #cd3131;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #d4d4d4;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-close {
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-close:hover {
|
||||||
|
background: #3e3e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-error {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: #cd3131;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container .xterm {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container .xterm-viewport {
|
||||||
|
background: #1e1e1e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive terminal */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.terminal-page {
|
||||||
|
padding: var(--space-2);
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-page-header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
ArrowSquareOut,
|
ArrowSquareOut,
|
||||||
Play,
|
Play,
|
||||||
Stop,
|
Stop,
|
||||||
|
Terminal,
|
||||||
|
ArrowLeft,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
export type IconName =
|
export type IconName =
|
||||||
@@ -70,7 +72,9 @@ export type IconName =
|
|||||||
| "binary"
|
| "binary"
|
||||||
| "external"
|
| "external"
|
||||||
| "play"
|
| "play"
|
||||||
| "stop";
|
| "stop"
|
||||||
|
| "terminal"
|
||||||
|
| "arrow-left";
|
||||||
|
|
||||||
export const iconRegistry: Record<
|
export const iconRegistry: Record<
|
||||||
IconName,
|
IconName,
|
||||||
@@ -124,6 +128,8 @@ export const iconRegistry: Record<
|
|||||||
external: ArrowSquareOut,
|
external: ArrowSquareOut,
|
||||||
play: Play,
|
play: Play,
|
||||||
stop: Stop,
|
stop: Stop,
|
||||||
|
terminal: Terminal,
|
||||||
|
"arrow-left": ArrowLeft,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const iconCategories = {
|
export const iconCategories = {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR-001: Session-Based Authentication with httpOnly Cookies
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The application needs an authentication mechanism for the OAuth2 flow with Authentik. We need to decide between:
|
||||||
|
|
||||||
|
1. **JWT tokens** stored in localStorage (common SPA pattern)
|
||||||
|
2. **Session cookies** with httpOnly flag
|
||||||
|
3. **JWT tokens** in httpOnly cookies
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
|
||||||
|
- Frontend and API run on different subdomains in production (e.g., `app.example.com` and `api.example.com`)
|
||||||
|
- Must support OAuth2 authorization code flow
|
||||||
|
- Must work with Traefik reverse proxy
|
||||||
|
- Must be secure against XSS attacks
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We will use **session-based authentication with HMAC-signed httpOnly cookies**.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. After OAuth callback, the API creates a session token (HMAC-SHA256 signed)
|
||||||
|
2. Token is stored in an httpOnly, Secure, SameSite cookie
|
||||||
|
3. Frontend never sees or stores the token
|
||||||
|
4. Cookie is sent automatically with every request via `withCredentials: true`
|
||||||
|
5. Session is stateless - token contains user_id and expiry
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- **XSS protection**: Token is never exposed to JavaScript
|
||||||
|
- **Simpler frontend**: No token management, refresh logic, or storage
|
||||||
|
- **Immediate revocation**: Can invalidate sessions server-side if needed
|
||||||
|
- **Standards compliant**: Uses well-established cookie security mechanisms
|
||||||
|
- **Works across subdomains**: Cookie domain can be set to parent domain
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- **CSRF risk**: Requires CSRF protection for state-changing operations (mitigated by SameSite=Lax/Strict)
|
||||||
|
- **Less flexible**: Harder to use with non-browser clients (mitigated by API key support if needed)
|
||||||
|
- **Cookie size**: Session token adds ~100 bytes to every request
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
|
||||||
|
**JWT in localStorage**
|
||||||
|
- Pros: Simple implementation, works with any client
|
||||||
|
- Cons: Vulnerable to XSS, requires manual token refresh, complex frontend logic
|
||||||
|
- Rejected due to XSS vulnerability
|
||||||
|
|
||||||
|
**JWT in httpOnly cookies**
|
||||||
|
- Pros: XSS protection, standard JWT benefits
|
||||||
|
- Cons: Complex refresh token rotation, no easy revocation, larger token size
|
||||||
|
- Rejected in favor of simpler session cookies
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-05-18
|
||||||
|
|
||||||
|
## Participants
|
||||||
|
|
||||||
|
- Development Team
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# ADR-002: Async SQLAlchemy with PostgreSQL
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We need to choose an ORM and database for the application. The application will handle concurrent requests and potentially long-running operations (git operations, Docker commands).
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Support concurrent API requests without blocking
|
||||||
|
- Handle async operations (database queries + subprocess calls)
|
||||||
|
- Type safety and autocompletion
|
||||||
|
- Migration support
|
||||||
|
- Good Python ecosystem support
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We will use **SQLAlchemy 2.0 with async PostgreSQL** via `asyncpg`.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. **SQLAlchemy 2.0** with `AsyncSession` and declarative models
|
||||||
|
2. **PostgreSQL** as the primary database
|
||||||
|
3. **asyncpg** as the async driver
|
||||||
|
4. **Alembic** for database migrations
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- **Non-blocking I/O**: Database queries don't block the event loop
|
||||||
|
- **Scalability**: Can handle many concurrent connections
|
||||||
|
- **Type safety**: SQLAlchemy 2.0 has excellent type hint support
|
||||||
|
- **Ecosystem**: Large community, extensive documentation
|
||||||
|
- **Flexibility**: Can fall back to sync operations for complex migrations
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- **Complexity**: Async SQLAlchemy has a steeper learning curve
|
||||||
|
- **Debugging**: Harder to debug async code
|
||||||
|
- **Migration limitations**: Some Alembic operations require sync connections
|
||||||
|
- **Connection pool**: Requires careful configuration
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
|
||||||
|
**Prisma ORM**
|
||||||
|
- Pros: Modern, type-safe, auto-generated client
|
||||||
|
- Cons: Less mature Python support, custom query language
|
||||||
|
- Rejected due to less mature ecosystem
|
||||||
|
|
||||||
|
**Tortoise ORM**
|
||||||
|
- Pros: Built for async, Django-like syntax
|
||||||
|
- Cons: Smaller community, fewer features
|
||||||
|
- Rejected in favor of SQLAlchemy's maturity
|
||||||
|
|
||||||
|
**Sync SQLAlchemy with threading**
|
||||||
|
- Pros: Simpler, well-understood
|
||||||
|
- Cons: Thread overhead, harder to integrate with async code
|
||||||
|
- Rejected in favor of native async support
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-05-17
|
||||||
|
|
||||||
|
## Participants
|
||||||
|
|
||||||
|
- Development Team
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
name: api-documentation
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# API Documentation - Design
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
API Documentation
|
||||||
|
├── OpenAPI (FastAPI native)
|
||||||
|
│ ├── /docs (Swagger UI)
|
||||||
|
│ ├── /redoc (ReDoc)
|
||||||
|
│ └── /openapi.json
|
||||||
|
├── Health Endpoints
|
||||||
|
│ ├── GET /health
|
||||||
|
│ └── GET /health/db
|
||||||
|
├── API README
|
||||||
|
│ └── apps/api/README.md
|
||||||
|
└── Architecture Decision Records
|
||||||
|
└── docs/architecture/decisions/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Design
|
||||||
|
|
||||||
|
### OpenAPI Documentation
|
||||||
|
|
||||||
|
FastAPI automatically generates OpenAPI schema from:
|
||||||
|
- Pydantic models (request/response)
|
||||||
|
- Endpoint docstrings
|
||||||
|
- Path operation parameters
|
||||||
|
- Response status codes
|
||||||
|
|
||||||
|
**Enhancements needed:**
|
||||||
|
- Add descriptions to all Pydantic models
|
||||||
|
- Add docstrings to all endpoints
|
||||||
|
- Add response examples where helpful
|
||||||
|
- Add authentication requirements
|
||||||
|
|
||||||
|
### Health Endpoints
|
||||||
|
|
||||||
|
**GET /health**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": "2026-05-19T12:00:00Z",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"checks": {
|
||||||
|
"database": {
|
||||||
|
"status": "healthy",
|
||||||
|
"response_time_ms": 5.2
|
||||||
|
},
|
||||||
|
"disk": {
|
||||||
|
"status": "healthy",
|
||||||
|
"free_gb": 45.2,
|
||||||
|
"total_gb": 100.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uptime_seconds": 3600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**GET /health/db**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"response_time_ms": 5.2,
|
||||||
|
"connections": {
|
||||||
|
"active": 2,
|
||||||
|
"idle": 3,
|
||||||
|
"max": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API README
|
||||||
|
|
||||||
|
**Sections:**
|
||||||
|
1. Overview
|
||||||
|
2. Quick Start
|
||||||
|
3. Environment Variables
|
||||||
|
4. Development Setup
|
||||||
|
5. Running Tests
|
||||||
|
6. Architecture Overview
|
||||||
|
7. Common Commands
|
||||||
|
8. Deployment
|
||||||
|
|
||||||
|
### Architecture Decision Records
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
- Title
|
||||||
|
- Status (proposed, accepted, deprecated)
|
||||||
|
- Context
|
||||||
|
- Decision
|
||||||
|
- Consequences
|
||||||
|
- Date
|
||||||
|
|
||||||
|
**ADRs to create:**
|
||||||
|
1. Session-based authentication (vs JWT)
|
||||||
|
2. SQLAlchemy async with PostgreSQL
|
||||||
|
3. Docker-based tool instances
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
**Libraries:**
|
||||||
|
- FastAPI (built-in OpenAPI)
|
||||||
|
- Pydantic v2 (schemas)
|
||||||
|
- psutil (disk/health metrics)
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
- `apps/api/src/main.py` - Add health endpoints
|
||||||
|
- All `apps/api/src/api/*.py` - Add docstrings
|
||||||
|
- All `apps/api/src/models/*.py` - Add model descriptions
|
||||||
|
- `apps/api/src/schemas/*.py` - Add response schemas
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Health endpoints return 200 even if degraded
|
||||||
|
- Failed checks included in response with "degraded" status
|
||||||
|
- Never expose sensitive info in health responses
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# API Documentation
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The API lacks comprehensive documentation:
|
||||||
|
- No auto-generated OpenAPI/Swagger UI
|
||||||
|
- Health endpoint is minimal (only checks database)
|
||||||
|
- Missing endpoint documentation and docstrings
|
||||||
|
- No API README for developer onboarding
|
||||||
|
- No Architecture Decision Records (ADRs)
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Provide comprehensive API documentation:
|
||||||
|
|
||||||
|
1. **Auto-generated OpenAPI docs** - FastAPI native `/docs` and `/redoc` endpoints
|
||||||
|
2. **Enhanced health checks** - `/health` with full system status, `/health/db` for database
|
||||||
|
3. **Endpoint documentation** - Proper docstrings, Pydantic models, response codes
|
||||||
|
4. **API README** - Developer onboarding guide
|
||||||
|
5. **Architecture Decision Records** - Document key architectural choices
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### OpenAPI Documentation
|
||||||
|
- Interactive Swagger UI at `/docs`
|
||||||
|
- ReDoc at `/redoc`
|
||||||
|
- All endpoints with schemas and examples
|
||||||
|
- Authentication documented
|
||||||
|
|
||||||
|
### Health Monitoring
|
||||||
|
- `/health` - Overall system health (database, disk, uptime)
|
||||||
|
- `/health/db` - Database-specific health check
|
||||||
|
- JSON responses with status indicators
|
||||||
|
|
||||||
|
### Developer Documentation
|
||||||
|
- `apps/api/README.md` - Setup, env vars, testing
|
||||||
|
- ADRs in `docs/architecture/decisions/`
|
||||||
|
- Inline code documentation
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
- **Developer onboarding** - New devs can understand the API quickly
|
||||||
|
- **API discoverability** - Interactive docs for testing endpoints
|
||||||
|
- **Health monitoring** - Operations can monitor system health
|
||||||
|
- **Knowledge preservation** - ADRs capture why decisions were made
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] `/docs` loads Swagger UI with all endpoints
|
||||||
|
- [ ] `/health` returns comprehensive health data
|
||||||
|
- [ ] `/health/db` returns database status
|
||||||
|
- [ ] All endpoints have docstrings
|
||||||
|
- [ ] API README exists with setup instructions
|
||||||
|
- [ ] At least one ADR exists
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# API Documentation Specification
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
1. **OpenAPI Documentation**: Auto-generated at `/docs` and `/redoc`
|
||||||
|
2. **Health Endpoints**: `/health` and `/health/db` with comprehensive status
|
||||||
|
3. **Endpoint Documentation**: All endpoints have docstrings and Pydantic models
|
||||||
|
4. **API README**: `apps/api/README.md` with developer onboarding
|
||||||
|
5. **Architecture Decision Records**: Document key architectural choices
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
1. **Performance**: Health checks complete in < 100ms
|
||||||
|
2. **Security**: Health endpoints don't expose sensitive data
|
||||||
|
3. **Maintainability**: Documentation stays in sync with code
|
||||||
|
|
||||||
|
## API Specification
|
||||||
|
|
||||||
|
### GET /health
|
||||||
|
|
||||||
|
Returns overall system health status.
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": "2026-05-19T12:00:00Z",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"checks": {
|
||||||
|
"database": {
|
||||||
|
"status": "healthy",
|
||||||
|
"response_time_ms": 5.2
|
||||||
|
},
|
||||||
|
"disk": {
|
||||||
|
"status": "healthy",
|
||||||
|
"free_gb": 45.2,
|
||||||
|
"total_gb": 100.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uptime_seconds": 3600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /health/db
|
||||||
|
|
||||||
|
Returns database-specific health status.
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"response_time_ms": 5.2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /docs
|
||||||
|
|
||||||
|
FastAPI Swagger UI (auto-generated).
|
||||||
|
|
||||||
|
### GET /redoc
|
||||||
|
|
||||||
|
FastAPI ReDoc (auto-generated).
|
||||||
|
|
||||||
|
## Documentation Requirements
|
||||||
|
|
||||||
|
### Endpoint Docstrings
|
||||||
|
|
||||||
|
Every endpoint must have:
|
||||||
|
- Description of what it does
|
||||||
|
- Request/response model descriptions
|
||||||
|
- Authentication requirements
|
||||||
|
- Error responses
|
||||||
|
|
||||||
|
### Pydantic Models
|
||||||
|
|
||||||
|
Every model must have:
|
||||||
|
- `description` field metadata
|
||||||
|
- Example values where helpful
|
||||||
|
- Proper typing
|
||||||
|
|
||||||
|
### API README Structure
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Headquarter API
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
## Quick Start
|
||||||
|
## Environment Variables
|
||||||
|
## Development
|
||||||
|
## Testing
|
||||||
|
## Architecture
|
||||||
|
## Deployment
|
||||||
|
```
|
||||||
|
|
||||||
|
## ADR Template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# ADR-XXX: Title
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
What is the issue we're facing?
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
What did we decide?
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
What are the trade-offs?
|
||||||
|
|
||||||
|
## Date
|
||||||
|
2026-05-19
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
- `/docs` loads successfully
|
||||||
|
- `/health` returns 200 with valid JSON
|
||||||
|
- `/health/db` returns database status
|
||||||
|
- All endpoints have docstrings
|
||||||
|
- API README is complete
|
||||||
|
- At least one ADR exists
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# API Documentation - Tasks
|
||||||
|
|
||||||
|
## Phase 1: Health Endpoints
|
||||||
|
|
||||||
|
- [x] **Task 1.1**: Enhance `/health` endpoint
|
||||||
|
- Add timestamp, version, uptime
|
||||||
|
- Add disk space check
|
||||||
|
- Add comprehensive checks object
|
||||||
|
- Create HealthCheck Pydantic models
|
||||||
|
|
||||||
|
- [x] **Task 1.2**: Create `/health/db` endpoint
|
||||||
|
- Database connection check
|
||||||
|
- Response time measurement
|
||||||
|
- Connection pool status
|
||||||
|
|
||||||
|
## Phase 2: Endpoint Documentation
|
||||||
|
|
||||||
|
- [x] **Task 2.1**: Document auth endpoints
|
||||||
|
- Add docstrings to `src/api/auth.py`
|
||||||
|
- Add response model descriptions
|
||||||
|
- Add error responses
|
||||||
|
|
||||||
|
- [x] **Task 2.2**: Document projects endpoints
|
||||||
|
- Add docstrings to `src/api/projects.py`
|
||||||
|
- Document request/response models
|
||||||
|
|
||||||
|
- [x] **Task 2.3**: Document repositories endpoints
|
||||||
|
- Add docstrings to `src/api/git_repositories.py`
|
||||||
|
- Document file operations
|
||||||
|
|
||||||
|
- [x] **Task 2.4**: Document user endpoints
|
||||||
|
- Add docstrings to `src/api/users.py`
|
||||||
|
- Document profile endpoints
|
||||||
|
|
||||||
|
- [x] **Task 2.5**: Document tool endpoints
|
||||||
|
- Add docstrings to `src/api/tool_types.py`
|
||||||
|
- Add docstrings to `src/api/tool_instances.py`
|
||||||
|
|
||||||
|
- [x] **Task 2.6**: Document SSH keys endpoints
|
||||||
|
- Add docstrings to `src/api/ssh_keys.py`
|
||||||
|
|
||||||
|
- [x] **Task 2.7**: Document config endpoints
|
||||||
|
- Add docstrings to `src/api/user_config.py`
|
||||||
|
|
||||||
|
- [x] **Task 2.8**: Document dashboard endpoint
|
||||||
|
- Add docstrings to `src/api/dashboard.py`
|
||||||
|
|
||||||
|
- [x] **Task 2.9**: Document terminal endpoint
|
||||||
|
- Add docstrings to `src/api/terminal.py`
|
||||||
|
|
||||||
|
## Phase 3: Model Documentation
|
||||||
|
|
||||||
|
- [x] **Task 3.1**: Document Pydantic models
|
||||||
|
- Add descriptions to all response models
|
||||||
|
- Add example values
|
||||||
|
- Document in `src/schemas/` or inline
|
||||||
|
|
||||||
|
## Phase 4: API README
|
||||||
|
|
||||||
|
- [x] **Task 4.1**: Create `apps/api/README.md`
|
||||||
|
- Overview section
|
||||||
|
- Quick start guide
|
||||||
|
- Environment variables table
|
||||||
|
- Development setup
|
||||||
|
- Testing instructions
|
||||||
|
- Architecture overview
|
||||||
|
- Common commands
|
||||||
|
|
||||||
|
## Phase 5: Architecture Decision Records
|
||||||
|
|
||||||
|
- [x] **Task 5.1**: Create ADR for session auth
|
||||||
|
- Document why cookies vs JWT
|
||||||
|
- Trade-offs and risks
|
||||||
|
|
||||||
|
- [x] **Task 5.2**: Create ADR for async SQLAlchemy
|
||||||
|
- Document async pattern choice
|
||||||
|
- PostgreSQL decision
|
||||||
|
|
||||||
|
## Phase 6: Quality Gates
|
||||||
|
|
||||||
|
- [x] **Task 6.1**: Verify `/docs` endpoint
|
||||||
|
- Check all endpoints appear
|
||||||
|
- Verify schemas are documented
|
||||||
|
|
||||||
|
- [x] **Task 6.2**: Verify health endpoints
|
||||||
|
- Test `/health`
|
||||||
|
- Test `/health/db`
|
||||||
|
|
||||||
|
- [x] **Task 6.3**: Run linting
|
||||||
|
- ruff check
|
||||||
|
- mypy
|
||||||
|
|
||||||
|
- [x] **Task 6.4**: Run tests
|
||||||
|
- pytest
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
name: tool-terminal
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Tool Terminal - Design
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Backend Container
|
||||||
|
│ │ │
|
||||||
|
│ WebSocket connect │ │
|
||||||
|
│─────────────────────────>│ │
|
||||||
|
│ │ docker exec -it bash │
|
||||||
|
│ │───────────────────────────>│
|
||||||
|
│ │ │
|
||||||
|
│ stdin (keystrokes) │ stdin │
|
||||||
|
│─────────────────────────>│───────────────────────────>│
|
||||||
|
│ │ │
|
||||||
|
│ stdout/stderr │ stdout/stderr │
|
||||||
|
│<─────────────────────────│<───────────────────────────│
|
||||||
|
│ │ │
|
||||||
|
│ resize (cols, rows) │ pty resize │
|
||||||
|
│─────────────────────────>│───────────────────────────>│
|
||||||
|
│ │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Design
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
**TerminalManager:**
|
||||||
|
- Manages active terminal sessions
|
||||||
|
- Maps WebSocket connections to container processes
|
||||||
|
- Handles session lifecycle (create, resize, cleanup)
|
||||||
|
|
||||||
|
**WebSocket Endpoint:**
|
||||||
|
- `GET /ws/tool-instances/{instance_id}/terminal`
|
||||||
|
- Authenticates user via session cookie
|
||||||
|
- Establishes bidirectional WebSocket
|
||||||
|
- Spawns `docker exec -it` with pseudo-TTY
|
||||||
|
|
||||||
|
**Docker PTY:**
|
||||||
|
- Uses `docker exec` with TTY allocation
|
||||||
|
- Streams stdin/stdout/stderr via subprocess
|
||||||
|
- Handles resize via `stty` or docker API
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
**TerminalComponent:**
|
||||||
|
- Wraps xterm.js terminal
|
||||||
|
- Manages WebSocket connection
|
||||||
|
- Handles terminal resize
|
||||||
|
- Fits container to parent element
|
||||||
|
|
||||||
|
**TerminalPage:**
|
||||||
|
- Full-page terminal view
|
||||||
|
- Shows instance name in header
|
||||||
|
- Connection status indicator
|
||||||
|
- Reconnect on disconnect
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. User clicks "Terminal" on running instance
|
||||||
|
2. Frontend opens WebSocket connection
|
||||||
|
3. Backend verifies ownership and spawns shell
|
||||||
|
4. Bidirectional streaming begins
|
||||||
|
5. User types → WebSocket → docker exec stdin
|
||||||
|
6. Container output → docker exec stdout → WebSocket → xterm.js
|
||||||
|
7. Resize events forwarded to adjust PTY dimensions
|
||||||
|
|
||||||
|
## Session Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Connect
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Authenticate ──> Reject (403)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Spawn Shell
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Stream I/O ◄───> Resize
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Disconnect
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Cleanup Process
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|
- WebSocket handshake validates session cookie
|
||||||
|
- Backend verifies user owns the instance
|
||||||
|
- Reject connection with 403 if unauthorized
|
||||||
|
- Close connection if instance stops running
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
**Backend Libraries:**
|
||||||
|
- `asyncio` for WebSocket handling
|
||||||
|
- `subprocess` with `docker exec -it`
|
||||||
|
- `fcntl` for PTY resize (Linux)
|
||||||
|
|
||||||
|
**Frontend Libraries:**
|
||||||
|
- `xterm` - Terminal emulator
|
||||||
|
- `xterm-addon-fit` - Auto-fit to container
|
||||||
|
- `xterm-addon-web-links` - Clickable URLs
|
||||||
|
|
||||||
|
**Docker Commands:**
|
||||||
|
```bash
|
||||||
|
# Spawn shell
|
||||||
|
docker exec -it {container_id} /bin/bash
|
||||||
|
|
||||||
|
# Alternative with explicit TTY
|
||||||
|
docker exec -i {container_id} sh -c 'exec bash'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Connection refused → Show error message
|
||||||
|
- Container not running → Disable terminal button
|
||||||
|
- Shell spawn failed → Show error and close
|
||||||
|
- Network disconnect → Attempt reconnect
|
||||||
|
|
||||||
|
## CSS Integration
|
||||||
|
|
||||||
|
```css
|
||||||
|
.terminal-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container .xterm {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Tool Terminal
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Tool instances (code-server, jupyter-notebook, etc.) run in Docker containers but users have no way to access a shell inside those containers. This limits debugging, running ad-hoc commands, and managing the container environment.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Provide browser-based terminal access to running tool containers via WebSocket:
|
||||||
|
|
||||||
|
1. **WebSocket terminal sessions** - Real-time bidirectional communication
|
||||||
|
2. **Pseudo-TTY** - Full terminal emulation with proper shell behavior
|
||||||
|
3. **xterm.js frontend** - Professional terminal UI in the browser
|
||||||
|
4. **Session management** - Multiple independent terminals per instance
|
||||||
|
5. **Access control** - Only instance owners can access terminals
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Terminal Access
|
||||||
|
- Open terminal from any running tool instance
|
||||||
|
- Full bash/zsh shell inside the container
|
||||||
|
- Standard terminal features (colors, cursor, history, etc.)
|
||||||
|
|
||||||
|
### Real-time I/O
|
||||||
|
- Instant character-by-character streaming
|
||||||
|
- Stdout/stderr combined output
|
||||||
|
- Support for interactive programs (vim, nano, etc.)
|
||||||
|
|
||||||
|
### Terminal Resize
|
||||||
|
- Dynamic column/row adjustment
|
||||||
|
- Window resize handled gracefully
|
||||||
|
- Proper text wrapping and scrolling
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
- Multiple terminals per instance
|
||||||
|
- Independent sessions with isolation
|
||||||
|
- Cleanup on disconnect
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
- **Debug containers** - Inspect running processes, check logs
|
||||||
|
- **Run commands** - Execute ad-hoc scripts or tools
|
||||||
|
- **Manage environment** - Install packages, edit config files
|
||||||
|
- **No SSH needed** - Browser-based access from anywhere
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Terminal opens for any running instance
|
||||||
|
- [ ] Commands execute and display output in real-time
|
||||||
|
- [ ] Terminal resizes with browser window
|
||||||
|
- [ ] Multiple terminals work independently
|
||||||
|
- [ ] Sessions clean up on disconnect
|
||||||
|
- [ ] Unauthorized users cannot access terminals
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Tool Terminal Specification
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
1. **WebSocket Terminal**: Provide terminal sessions via WebSocket at `/ws/tool-instances/{instance_id}/terminal`
|
||||||
|
2. **Terminal I/O**: Stream stdin/stdout/stderr bidirectionally in real-time
|
||||||
|
3. **Terminal Resize**: Support dynamic resize with COLS/ROWS updates
|
||||||
|
4. **Session Management**: Multiple independent sessions per instance, cleanup on disconnect
|
||||||
|
5. **Access Control**: Only instance owners can access, reject unauthorized with 403
|
||||||
|
6. **Shell Spawn**: Spawn `/bin/bash` or `/bin/sh` inside container via `docker exec`
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
1. **Latency**: Character input to display < 50ms
|
||||||
|
2. **Concurrent Sessions**: Support 10+ simultaneous terminal sessions
|
||||||
|
3. **Browser Support**: Chrome, Firefox, Safari, Edge
|
||||||
|
4. **Container Lifecycle**: Terminal closes when container stops
|
||||||
|
|
||||||
|
## API Specification
|
||||||
|
|
||||||
|
### WebSocket Endpoint
|
||||||
|
|
||||||
|
**URL:** `wss://{api_host}/ws/tool-instances/{instance_id}/terminal`
|
||||||
|
|
||||||
|
**Protocol:**
|
||||||
|
- Connection requires valid session cookie
|
||||||
|
- Binary frame: terminal output (stdout/stderr)
|
||||||
|
- Text frame: control messages (JSON)
|
||||||
|
|
||||||
|
**Control Messages:**
|
||||||
|
|
||||||
|
Request (Client → Server):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "resize",
|
||||||
|
"cols": 80,
|
||||||
|
"rows": 24
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (Server → Client):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "status",
|
||||||
|
"status": "connected"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### REST Endpoint
|
||||||
|
|
||||||
|
**GET /tool-instances/{instance_id}/terminal** (HTML page)
|
||||||
|
- Returns terminal page for the instance
|
||||||
|
- Verifies ownership
|
||||||
|
- Returns 404 if instance not found
|
||||||
|
- Returns 403 if unauthorized
|
||||||
|
|
||||||
|
## Frontend Specification
|
||||||
|
|
||||||
|
### TerminalComponent
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
```typescript
|
||||||
|
interface TerminalProps {
|
||||||
|
instanceId: string;
|
||||||
|
instanceName: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- xterm.js terminal with custom theme
|
||||||
|
- WebSocket connection management
|
||||||
|
- Auto-fit to parent container
|
||||||
|
- Connection status indicator
|
||||||
|
- Reconnect on disconnect (3 retries)
|
||||||
|
|
||||||
|
### TerminalPage
|
||||||
|
|
||||||
|
**Route:** `/instances/:instanceId/terminal`
|
||||||
|
- Full-page terminal view
|
||||||
|
- Shows instance name in header
|
||||||
|
- Back button to instance list
|
||||||
|
- Connection status badge
|
||||||
|
|
||||||
|
## Backend Specification
|
||||||
|
|
||||||
|
### TerminalManager
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
```python
|
||||||
|
class TerminalManager:
|
||||||
|
async def create_session(
|
||||||
|
self,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
websocket: WebSocket
|
||||||
|
) -> TerminalSession
|
||||||
|
|
||||||
|
async def handle_resize(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
cols: int,
|
||||||
|
rows: int
|
||||||
|
) -> None
|
||||||
|
|
||||||
|
async def close_session(self, session_id: str) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
### TerminalSession
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- Manage docker exec subprocess
|
||||||
|
- Stream I/O between WebSocket and PTY
|
||||||
|
- Handle resize signals
|
||||||
|
- Cleanup on disconnect
|
||||||
|
|
||||||
|
**Docker Command:**
|
||||||
|
```python
|
||||||
|
async def spawn_shell(container_id: str) -> subprocess.Process:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"docker", "exec", "-i", container_id, "/bin/bash",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
return proc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- FastAPI WebSocket support
|
||||||
|
- asyncio subprocess
|
||||||
|
- docker CLI
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `xterm` (v5.x)
|
||||||
|
- `xterm-addon-fit`
|
||||||
|
- `xterm-addon-web-links`
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Install xterm.js dependencies
|
||||||
|
2. Create backend WebSocket endpoint
|
||||||
|
3. Create TerminalManager and TerminalSession
|
||||||
|
4. Create frontend TerminalComponent
|
||||||
|
5. Add terminal route and navigation
|
||||||
|
6. Test with running instances
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit: TerminalSession I/O streaming
|
||||||
|
- Integration: WebSocket connection lifecycle
|
||||||
|
- Manual: Terminal functionality with real containers
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
- pytest
|
||||||
|
- mypy
|
||||||
|
- ruff
|
||||||
|
- npm run typecheck
|
||||||
|
- npm run lint
|
||||||
|
- npm run build
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Tool Terminal - Tasks
|
||||||
|
|
||||||
|
## Phase 1: Backend Setup
|
||||||
|
|
||||||
|
- [x] **Task 1.1**: Install backend dependencies
|
||||||
|
- Add `asyncio-subprocess` handling
|
||||||
|
- Verify FastAPI WebSocket support
|
||||||
|
|
||||||
|
- [x] **Task 1.2**: Create TerminalSession class
|
||||||
|
- Create `src/services/terminal_session.py`
|
||||||
|
- Manage docker exec subprocess
|
||||||
|
- Stream I/O between WebSocket and PTY
|
||||||
|
- Handle resize signals
|
||||||
|
- Cleanup on disconnect
|
||||||
|
|
||||||
|
- [x] **Task 1.3**: Create TerminalManager
|
||||||
|
- Create `src/services/terminal_manager.py`
|
||||||
|
- Manage active sessions dictionary
|
||||||
|
- Create/close session methods
|
||||||
|
- Handle resize forwarding
|
||||||
|
- Session cleanup on disconnect
|
||||||
|
|
||||||
|
## Phase 2: WebSocket Endpoint
|
||||||
|
|
||||||
|
- [x] **Task 2.1**: Create WebSocket endpoint
|
||||||
|
- Add `GET /ws/tool-instances/{instance_id}/terminal`
|
||||||
|
- Authenticate via session cookie
|
||||||
|
- Verify instance ownership
|
||||||
|
- Establish bidirectional WebSocket
|
||||||
|
- Handle connection lifecycle
|
||||||
|
|
||||||
|
- [x] **Task 2.2**: Add WebSocket to main app
|
||||||
|
- Register WebSocket router in `main.py`
|
||||||
|
- Configure WebSocket middleware
|
||||||
|
- Handle CORS for WebSocket connections
|
||||||
|
|
||||||
|
## Phase 3: Frontend Dependencies
|
||||||
|
|
||||||
|
- [x] **Task 3.1**: Install xterm.js
|
||||||
|
- `npm install xterm xterm-addon-fit xterm-addon-web-links`
|
||||||
|
- Add to package.json
|
||||||
|
|
||||||
|
## Phase 4: Frontend Components
|
||||||
|
|
||||||
|
- [x] **Task 4.1**: Create TerminalComponent
|
||||||
|
- Create `components/terminal.tsx`
|
||||||
|
- Initialize xterm.js terminal
|
||||||
|
- Manage WebSocket connection
|
||||||
|
- Handle terminal resize with xterm-addon-fit
|
||||||
|
- Connection status indicator
|
||||||
|
- Auto-reconnect on disconnect
|
||||||
|
|
||||||
|
- [x] **Task 4.2**: Create TerminalPage
|
||||||
|
- Create `pages/terminal.tsx`
|
||||||
|
- Full-page terminal layout
|
||||||
|
- Instance name in header
|
||||||
|
- Back button
|
||||||
|
- Connection status badge
|
||||||
|
|
||||||
|
## Phase 5: Integration
|
||||||
|
|
||||||
|
- [x] **Task 5.1**: Add terminal route
|
||||||
|
- Add `/instances/:instanceId/terminal` to router
|
||||||
|
- Link from InstanceList component
|
||||||
|
- Show terminal button for running instances
|
||||||
|
|
||||||
|
- [x] **Task 5.2**: Add terminal button to InstanceList
|
||||||
|
- Add terminal icon button to running instances
|
||||||
|
- Disable for stopped instances
|
||||||
|
- Navigate to terminal page
|
||||||
|
|
||||||
|
## Phase 6: Styling
|
||||||
|
|
||||||
|
- [x] **Task 6.1**: Add terminal CSS
|
||||||
|
- Dark terminal theme matching app
|
||||||
|
- Full-height container
|
||||||
|
- Proper padding and borders
|
||||||
|
- Connection status colors
|
||||||
|
|
||||||
|
## Phase 7: Quality Gates
|
||||||
|
|
||||||
|
- [x] **Task 7.1**: Backend tests
|
||||||
|
- ruff check
|
||||||
|
- mypy
|
||||||
|
- pytest
|
||||||
|
|
||||||
|
- [x] **Task 7.2**: Frontend tests
|
||||||
|
- typecheck
|
||||||
|
- lint
|
||||||
|
- build
|
||||||
|
|
||||||
|
- [x] **Task 7.3**: Manual testing
|
||||||
|
- Open terminal for running instance
|
||||||
|
- Execute commands
|
||||||
|
- Test resize
|
||||||
|
- Verify access control
|
||||||
Reference in New Issue
Block a user