Compare commits
23 Commits
d894cd9723
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ed5e1c84b | |||
| 543fee5d56 | |||
| be29da667f | |||
| 5696480538 | |||
| e434c439c9 | |||
| 3f5159fb8a | |||
| 5d5b23894c | |||
| a6eb6ec788 | |||
| 6bd7443e68 | |||
| ae420708f2 | |||
| dd69bd69fc | |||
| cccf4379d8 | |||
| c8c490eb2b | |||
| dd7696b5a4 | |||
| 58a9728d5e | |||
| fdd1d21bc7 | |||
| f6003b75ca | |||
| c527393d2e | |||
| c5fbb6722b | |||
| c50d6663d5 | |||
| aee3987c24 | |||
| 985ca538e3 | |||
| ee1fa6bee5 |
@@ -4,111 +4,23 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.config_folder import ConfigFolder
|
from src.models.config_folder import ConfigFolder
|
||||||
|
from src.schemas.config_folder import (
|
||||||
|
ConfigFolderCreate,
|
||||||
|
ConfigFolderUpdate,
|
||||||
|
ConfigFolderResponse,
|
||||||
|
ProjectOverrideCreate,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
||||||
|
|
||||||
MAX_FOLDER_SIZE_MB = 10
|
|
||||||
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderCreate(BaseModel):
|
|
||||||
name: str = Field(description="Folder name (unique per user)")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
mount_path: str = Field(description="Default mount path in container")
|
|
||||||
files: dict = Field(default_factory=dict, description="Files as {path: content}")
|
|
||||||
|
|
||||||
@field_validator("mount_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_mount_path(cls, v: str) -> str:
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Mount path must be absolute (start with /)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("files")
|
|
||||||
@classmethod
|
|
||||||
def validate_files(cls, v: dict) -> dict:
|
|
||||||
total_size = 0
|
|
||||||
for path, content in v.items():
|
|
||||||
# Check for path traversal
|
|
||||||
if ".." in path or path.startswith("/"):
|
|
||||||
raise ValueError(f"Invalid file path: {path}")
|
|
||||||
total_size += len(content.encode("utf-8"))
|
|
||||||
|
|
||||||
if total_size > MAX_FOLDER_SIZE_BYTES:
|
|
||||||
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderUpdate(BaseModel):
|
|
||||||
name: str | None = Field(default=None, description="Folder name")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
mount_path: str | None = Field(default=None, description="Default mount path")
|
|
||||||
files: dict | None = Field(default=None, description="Files as {path: content}")
|
|
||||||
is_active: bool | None = Field(default=None, description="Active/inactive toggle")
|
|
||||||
|
|
||||||
@field_validator("mount_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Mount path must be absolute (start with /)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("files")
|
|
||||||
@classmethod
|
|
||||||
def validate_files(cls, v: dict | None) -> dict | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
|
|
||||||
total_size = 0
|
|
||||||
for path, content in v.items():
|
|
||||||
# Check for path traversal
|
|
||||||
if ".." in path or path.startswith("/"):
|
|
||||||
raise ValueError(f"Invalid file path: {path}")
|
|
||||||
total_size += len(content.encode("utf-8"))
|
|
||||||
|
|
||||||
if total_size > MAX_FOLDER_SIZE_BYTES:
|
|
||||||
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectOverrideCreate(BaseModel):
|
|
||||||
mount_path: str | None = Field(default=None, description="Override mount path")
|
|
||||||
files: dict = Field(default_factory=dict, description="Override files")
|
|
||||||
|
|
||||||
@field_validator("mount_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Mount path must be absolute (start with /)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_id: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
mount_path: str
|
|
||||||
files: dict
|
|
||||||
project_overrides: dict | None
|
|
||||||
is_active: bool
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
|
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
|
||||||
async def list_config_folders(
|
async def list_config_folders(
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -16,606 +14,174 @@ from src.models.config_mount import ConfigMount
|
|||||||
from src.models.config_profile import ConfigProfile
|
from src.models.config_profile import ConfigProfile
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
from src.schemas.config_profile import (
|
||||||
|
ConfigIncludeCreate,
|
||||||
|
ConfigIncludeUpdate,
|
||||||
|
ConfigMountCreate,
|
||||||
|
ConfigMountUpdate,
|
||||||
|
ConfigProfileCreate,
|
||||||
|
ConfigProfileUpdate,
|
||||||
|
DefaultProfilesUpdate,
|
||||||
|
)
|
||||||
|
from src.services.config_profiles import (
|
||||||
|
check_duplicate_include,
|
||||||
|
check_duplicate_mount_path,
|
||||||
|
check_duplicate_name,
|
||||||
|
get_default_profile_for_tool_type,
|
||||||
|
get_default_profiles,
|
||||||
|
get_owned_profile,
|
||||||
|
include_to_dict,
|
||||||
|
list_includes_for_profile,
|
||||||
|
list_mounts_for_profile,
|
||||||
|
mount_to_dict,
|
||||||
|
profile_to_dict,
|
||||||
|
set_default_profiles,
|
||||||
|
validate_includes_no_cycle,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
MAX_MOUNT_PATH_LENGTH = 1024
|
|
||||||
MAX_CONTENT_LENGTH = 1024 * 1024 # 1MB
|
|
||||||
MAX_INCLUDES_DEPTH = 10
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
@router.get("")
|
||||||
# Pydantic schemas
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ConfigProfileCreate(BaseModel):
|
|
||||||
name: str = Field(description="Profile name (unique per user)")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, v: str) -> str:
|
|
||||||
v = v.strip()
|
|
||||||
if not v:
|
|
||||||
raise ValueError("Profile name cannot be empty")
|
|
||||||
if len(v) > 255:
|
|
||||||
raise ValueError("Profile name must be 255 characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileUpdate(BaseModel):
|
|
||||||
name: str | None = Field(default=None, description="Profile name")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
v = v.strip()
|
|
||||||
if not v:
|
|
||||||
raise ValueError("Profile name cannot be empty")
|
|
||||||
if len(v) > 255:
|
|
||||||
raise ValueError("Profile name must be 255 characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_id: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileDetailResponse(ConfigProfileResponse):
|
|
||||||
includes: list[dict[str, Any]]
|
|
||||||
mounts: list[dict[str, Any]]
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeCreate(BaseModel):
|
|
||||||
included_profile_id: str = Field(description="UUID of the profile to include")
|
|
||||||
order_index: int = Field(default=0, description="Order index for include resolution")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeUpdate(BaseModel):
|
|
||||||
order_index: int = Field(description="Order index for include resolution")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
profile_id: str
|
|
||||||
included_profile_id: str
|
|
||||||
included_profile_name: str | None
|
|
||||||
order_index: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountCreate(BaseModel):
|
|
||||||
target_path: str = Field(description="Absolute target path in container")
|
|
||||||
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
|
|
||||||
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
|
|
||||||
order_index: int = Field(default=0, description="Order index for mount resolution")
|
|
||||||
|
|
||||||
@field_validator("target_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_target_path(cls, v: str) -> str:
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Target path must be absolute (start with /)")
|
|
||||||
if ".." in v:
|
|
||||||
raise ValueError("Target path cannot contain parent directory references (..)")
|
|
||||||
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
|
||||||
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountUpdate(BaseModel):
|
|
||||||
target_path: str | None = Field(default=None, description="Absolute target path in container")
|
|
||||||
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
|
|
||||||
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
|
|
||||||
order_index: int | None = Field(default=None, description="Order index for mount resolution")
|
|
||||||
|
|
||||||
@field_validator("target_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_target_path(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Target path must be absolute (start with /)")
|
|
||||||
if ".." in v:
|
|
||||||
raise ValueError("Target path cannot contain parent directory references (..)")
|
|
||||||
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
|
||||||
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
profile_id: str
|
|
||||||
target_path: str
|
|
||||||
mode: str
|
|
||||||
files: dict[str, str] | None
|
|
||||||
order_index: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class DefaultProfilesUpdate(BaseModel):
|
|
||||||
default_profiles: dict[str, str] = Field(description="Mapping of tool_type_id to profile_id")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _get_owned_profile(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> ConfigProfile:
|
|
||||||
"""Fetch a config profile and verify ownership."""
|
|
||||||
profile = await session.get(ConfigProfile, profile_id)
|
|
||||||
if profile is None or profile.user_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="config profile not found",
|
|
||||||
)
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
async def _detect_cycle(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
visited: set[uuid.UUID] | None = None,
|
|
||||||
depth: int = 0,
|
|
||||||
) -> bool:
|
|
||||||
"""Detect cycles in profile includes using DFS.
|
|
||||||
|
|
||||||
Returns True if a cycle is detected.
|
|
||||||
"""
|
|
||||||
if depth > MAX_INCLUDES_DEPTH:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if visited is None:
|
|
||||||
visited = set()
|
|
||||||
|
|
||||||
if profile_id in visited:
|
|
||||||
return True
|
|
||||||
|
|
||||||
visited.add(profile_id)
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigInclude.included_profile_id).where(
|
|
||||||
ConfigInclude.profile_id == profile_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
included_ids = result.scalars().all()
|
|
||||||
|
|
||||||
for included_id in included_ids:
|
|
||||||
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_includes_no_cycle(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
new_included_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Validate that adding an include wouldn't create a cycle."""
|
|
||||||
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="adding this include would create a circular reference",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Profile CRUD
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"",
|
|
||||||
summary="List config profiles",
|
|
||||||
description="Get all config profiles for the current user. Optionally filter by tool type compatibility.",
|
|
||||||
)
|
|
||||||
async def list_config_profiles(
|
async def list_config_profiles(
|
||||||
tool_type_id: str | None = None,
|
tool_type_id: str | None = None,
|
||||||
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 config profiles for the current user."""
|
|
||||||
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
|
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
|
||||||
|
|
||||||
# If tool_type_id is provided, filter to compatible profiles
|
|
||||||
# For now, all profiles are considered compatible with all tool types
|
|
||||||
# since there's no explicit compatibility matrix. Future enhancement:
|
|
||||||
# could filter by profile tags or mount path patterns.
|
|
||||||
if tool_type_id:
|
if tool_type_id:
|
||||||
# Validate the tool type exists
|
|
||||||
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="tool type not found",
|
|
||||||
)
|
|
||||||
# All profiles are compatible; just return user's profiles
|
|
||||||
pass
|
|
||||||
|
|
||||||
result = await session.execute(query.order_by(ConfigProfile.name))
|
result = await session.execute(query.order_by(ConfigProfile.name))
|
||||||
profiles = result.scalars().all()
|
return {"profiles": [profile_to_dict(p) for p in result.scalars().all()]}
|
||||||
|
|
||||||
return {
|
|
||||||
"profiles": [
|
|
||||||
{
|
|
||||||
"id": str(p.id),
|
|
||||||
"user_id": str(p.user_id),
|
|
||||||
"name": p.name,
|
|
||||||
"description": p.description,
|
|
||||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
|
||||||
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
|
||||||
}
|
|
||||||
for p in profiles
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
"",
|
|
||||||
summary="Create config profile",
|
|
||||||
description="Create a new config profile.",
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def create_config_profile(
|
async def create_config_profile(
|
||||||
data: ConfigProfileCreate,
|
data: ConfigProfileCreate,
|
||||||
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 config profile."""
|
await check_duplicate_name(session, user_id, data.name)
|
||||||
# Check for duplicate name
|
profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description)
|
||||||
existing = await session.scalar(
|
|
||||||
select(ConfigProfile).where(
|
|
||||||
ConfigProfile.user_id == user_id,
|
|
||||||
ConfigProfile.name == data.name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"config profile with name '{data.name}' already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
profile = ConfigProfile(
|
|
||||||
user_id=user_id,
|
|
||||||
name=data.name,
|
|
||||||
description=data.description,
|
|
||||||
)
|
|
||||||
session.add(profile)
|
session.add(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(profile)
|
await session.refresh(profile)
|
||||||
|
return profile_to_dict(profile)
|
||||||
return {
|
|
||||||
"id": str(profile.id),
|
|
||||||
"user_id": str(profile.user_id),
|
|
||||||
"name": profile.name,
|
|
||||||
"description": profile.description,
|
|
||||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
|
||||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/defaults")
|
||||||
"/defaults",
|
async def get_default_profiles_endpoint(
|
||||||
summary="Get default profiles",
|
|
||||||
description="Get the current user's default profile assignments per tool type.",
|
|
||||||
)
|
|
||||||
async def get_default_profiles(
|
|
||||||
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 default profiles for the current user."""
|
return await get_default_profiles(session, user_id)
|
||||||
result = await session.execute(
|
|
||||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
|
||||||
)
|
|
||||||
user_config = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if user_config is None:
|
|
||||||
return {"default_profiles": {}}
|
|
||||||
|
|
||||||
return {"default_profiles": user_config.default_profiles}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/defaults")
|
||||||
"/defaults",
|
async def set_default_profiles_endpoint(
|
||||||
summary="Set default profiles",
|
|
||||||
description="Set the current user's default profile assignments per tool type.",
|
|
||||||
)
|
|
||||||
async def set_default_profiles(
|
|
||||||
data: DefaultProfilesUpdate,
|
data: DefaultProfilesUpdate,
|
||||||
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:
|
||||||
"""Set default profiles for the current user."""
|
return await set_default_profiles(session, user_id, data.default_profiles)
|
||||||
result = await session.execute(
|
|
||||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
|
||||||
)
|
|
||||||
user_config = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if user_config is None:
|
|
||||||
user_config = UserConfig(user_id=user_id, config={})
|
|
||||||
session.add(user_config)
|
|
||||||
|
|
||||||
# Validate all profile IDs belong to the user
|
|
||||||
for tool_type_id, profile_id_str in data.default_profiles.items():
|
|
||||||
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
|
|
||||||
if profile is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"profile {profile_id_str} not found",
|
|
||||||
)
|
|
||||||
if profile.user_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"profile {profile_id_str} does not belong to user",
|
|
||||||
)
|
|
||||||
|
|
||||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
|
||||||
user_config.config = {**user_config.config, "default_profiles": data.default_profiles}
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(user_config)
|
|
||||||
|
|
||||||
return {"default_profiles": user_config.default_profiles}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/defaults/{tool_type_id}")
|
||||||
"/defaults/{tool_type_id}",
|
async def get_default_profile_for_tool_type_endpoint(
|
||||||
summary="Get default profile for tool type",
|
|
||||||
description="Get the default profile ID for a specific tool type.",
|
|
||||||
)
|
|
||||||
async def get_default_profile_for_tool_type(
|
|
||||||
tool_type_id: str,
|
tool_type_id: str,
|
||||||
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 default profile for a specific tool type."""
|
return await get_default_profile_for_tool_type(session, user_id, tool_type_id)
|
||||||
result = await session.execute(
|
|
||||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
|
||||||
)
|
|
||||||
user_config = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if user_config is None:
|
|
||||||
return {"tool_type_id": tool_type_id, "profile_id": None}
|
|
||||||
|
|
||||||
profile_id = user_config.default_profiles.get(tool_type_id)
|
|
||||||
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{profile_id}")
|
||||||
"/{profile_id}",
|
|
||||||
summary="Get config profile",
|
|
||||||
description="Get a config profile with its includes and mounts.",
|
|
||||||
)
|
|
||||||
async def get_config_profile(
|
async def get_config_profile(
|
||||||
profile_id: uuid.UUID,
|
profile_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:
|
||||||
"""Get a config profile with includes and mounts."""
|
|
||||||
profile = await session.get(
|
profile = await session.get(
|
||||||
ConfigProfile,
|
ConfigProfile,
|
||||||
profile_id,
|
profile_id,
|
||||||
options=[
|
options=[selectinload(ConfigProfile.includes), selectinload(ConfigProfile.mounts)],
|
||||||
selectinload(ConfigProfile.includes),
|
|
||||||
selectinload(ConfigProfile.mounts),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
if profile is None or profile.user_id != user_id:
|
if profile is None or profile.user_id != user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="config profile not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch included profile names
|
|
||||||
includes_data = []
|
includes_data = []
|
||||||
for inc in profile.includes:
|
for inc in profile.includes:
|
||||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
||||||
includes_data.append({
|
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
|
||||||
"id": str(inc.id),
|
|
||||||
"profile_id": str(inc.profile_id),
|
|
||||||
"included_profile_id": str(inc.included_profile_id),
|
|
||||||
"included_profile_name": included_profile.name if included_profile else None,
|
|
||||||
"order_index": inc.order_index,
|
|
||||||
"created_at": inc.created_at.isoformat() if inc.created_at else None,
|
|
||||||
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
mounts_data = [
|
|
||||||
{
|
|
||||||
"id": str(m.id),
|
|
||||||
"profile_id": str(m.profile_id),
|
|
||||||
"target_path": m.target_path,
|
|
||||||
"mode": m.mode,
|
|
||||||
"files": m.files,
|
|
||||||
"order_index": m.order_index,
|
|
||||||
"created_at": m.created_at.isoformat() if m.created_at else None,
|
|
||||||
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
|
|
||||||
}
|
|
||||||
for m in profile.mounts
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(profile.id),
|
**profile_to_dict(profile),
|
||||||
"user_id": str(profile.user_id),
|
|
||||||
"name": profile.name,
|
|
||||||
"description": profile.description,
|
|
||||||
"includes": includes_data,
|
"includes": includes_data,
|
||||||
"mounts": mounts_data,
|
"mounts": [mount_to_dict(m) for m in profile.mounts],
|
||||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
|
||||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{profile_id}")
|
||||||
"/{profile_id}",
|
|
||||||
summary="Update config profile",
|
|
||||||
description="Update an existing config profile.",
|
|
||||||
)
|
|
||||||
async def update_config_profile(
|
async def update_config_profile(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
data: ConfigProfileUpdate,
|
data: ConfigProfileUpdate,
|
||||||
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:
|
||||||
"""Update a config profile."""
|
profile = await get_owned_profile(profile_id, user_id, session)
|
||||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
# Check for duplicate name
|
await check_duplicate_name(session, user_id, data.name, exclude_id=profile_id)
|
||||||
existing = await session.scalar(
|
|
||||||
select(ConfigProfile).where(
|
|
||||||
ConfigProfile.user_id == user_id,
|
|
||||||
ConfigProfile.name == data.name,
|
|
||||||
ConfigProfile.id != profile_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"config profile with name '{data.name}' already exists",
|
|
||||||
)
|
|
||||||
profile.name = data.name
|
profile.name = data.name
|
||||||
|
|
||||||
if data.description is not None:
|
if data.description is not None:
|
||||||
profile.description = data.description
|
profile.description = data.description
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(profile)
|
await session.refresh(profile)
|
||||||
|
return profile_to_dict(profile)
|
||||||
return {
|
|
||||||
"id": str(profile.id),
|
|
||||||
"user_id": str(profile.user_id),
|
|
||||||
"name": profile.name,
|
|
||||||
"description": profile.description,
|
|
||||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
|
||||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
"/{profile_id}",
|
|
||||||
summary="Delete config profile",
|
|
||||||
description="Delete a config profile and all its includes and mounts.",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
)
|
|
||||||
async def delete_config_profile(
|
async def delete_config_profile(
|
||||||
profile_id: uuid.UUID,
|
profile_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 config profile."""
|
profile = await get_owned_profile(profile_id, user_id, session)
|
||||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
await session.delete(profile)
|
await session.delete(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Include management
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{profile_id}/includes")
|
||||||
"/{profile_id}/includes",
|
|
||||||
summary="List profile includes",
|
|
||||||
description="Get all includes for a config profile.",
|
|
||||||
)
|
|
||||||
async def list_profile_includes(
|
async def list_profile_includes(
|
||||||
profile_id: uuid.UUID,
|
profile_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 includes for a config profile."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
return await list_includes_for_profile(session, profile_id)
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigInclude)
|
|
||||||
.where(ConfigInclude.profile_id == profile_id)
|
|
||||||
.order_by(ConfigInclude.order_index)
|
|
||||||
)
|
|
||||||
includes = result.scalars().all()
|
|
||||||
|
|
||||||
includes_data = []
|
|
||||||
for inc in includes:
|
|
||||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
|
||||||
includes_data.append({
|
|
||||||
"id": str(inc.id),
|
|
||||||
"profile_id": str(inc.profile_id),
|
|
||||||
"included_profile_id": str(inc.included_profile_id),
|
|
||||||
"included_profile_name": included_profile.name if included_profile else None,
|
|
||||||
"order_index": inc.order_index,
|
|
||||||
"created_at": inc.created_at.isoformat() if inc.created_at else None,
|
|
||||||
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"includes": includes_data}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED)
|
||||||
"/{profile_id}/includes",
|
|
||||||
summary="Add profile include",
|
|
||||||
description="Add an include to a config profile.",
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def add_profile_include(
|
async def add_profile_include(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
data: ConfigIncludeCreate,
|
data: ConfigIncludeCreate,
|
||||||
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:
|
||||||
"""Add an include to a config profile."""
|
profile = await get_owned_profile(profile_id, user_id, session)
|
||||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
included_profile_id = uuid.UUID(data.included_profile_id)
|
included_profile_id = uuid.UUID(data.included_profile_id)
|
||||||
|
|
||||||
# Cannot include self
|
|
||||||
if included_profile_id == profile_id:
|
if included_profile_id == profile_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself")
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="a profile cannot include itself",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the included profile exists and belongs to the user
|
|
||||||
included_profile = await session.get(ConfigProfile, included_profile_id)
|
included_profile = await session.get(ConfigProfile, included_profile_id)
|
||||||
if included_profile is None:
|
if included_profile is None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="included profile not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="included profile not found",
|
|
||||||
)
|
|
||||||
if included_profile.user_id != user_id:
|
if included_profile.user_id != user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user")
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
await check_duplicate_include(session, profile_id, included_profile_id)
|
||||||
detail="included profile does not belong to user",
|
await validate_includes_no_cycle(session, profile_id, included_profile_id)
|
||||||
)
|
|
||||||
|
|
||||||
# Check for duplicate include
|
|
||||||
existing = await session.scalar(
|
|
||||||
select(ConfigInclude).where(
|
|
||||||
ConfigInclude.profile_id == profile_id,
|
|
||||||
ConfigInclude.included_profile_id == included_profile_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="this include already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate no cycles
|
|
||||||
await _validate_includes_no_cycle(session, profile_id, included_profile_id)
|
|
||||||
|
|
||||||
include = ConfigInclude(
|
include = ConfigInclude(
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
included_profile_id=included_profile_id,
|
included_profile_id=included_profile_id,
|
||||||
@@ -624,23 +190,10 @@ async def add_profile_include(
|
|||||||
session.add(include)
|
session.add(include)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(include)
|
await session.refresh(include)
|
||||||
|
return include_to_dict(include, included_profile.name)
|
||||||
return {
|
|
||||||
"id": str(include.id),
|
|
||||||
"profile_id": str(include.profile_id),
|
|
||||||
"included_profile_id": str(include.included_profile_id),
|
|
||||||
"included_profile_name": included_profile.name,
|
|
||||||
"order_index": include.order_index,
|
|
||||||
"created_at": include.created_at.isoformat() if include.created_at else None,
|
|
||||||
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{profile_id}/includes/{include_id}")
|
||||||
"/{profile_id}/includes/{include_id}",
|
|
||||||
summary="Update profile include",
|
|
||||||
description="Update the order index of a profile include.",
|
|
||||||
)
|
|
||||||
async def update_profile_include(
|
async def update_profile_include(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
include_id: uuid.UUID,
|
include_id: uuid.UUID,
|
||||||
@@ -648,127 +201,52 @@ async def update_profile_include(
|
|||||||
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:
|
||||||
"""Update a profile include."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
include = await session.get(ConfigInclude, include_id)
|
include = await session.get(ConfigInclude, include_id)
|
||||||
if include is None or include.profile_id != profile_id:
|
if include is None or include.profile_id != profile_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="include not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
include.order_index = data.order_index
|
include.order_index = data.order_index
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(include)
|
await session.refresh(include)
|
||||||
|
|
||||||
included_profile = await session.get(ConfigProfile, include.included_profile_id)
|
included_profile = await session.get(ConfigProfile, include.included_profile_id)
|
||||||
return {
|
return include_to_dict(include, included_profile.name if included_profile else None)
|
||||||
"id": str(include.id),
|
|
||||||
"profile_id": str(include.profile_id),
|
|
||||||
"included_profile_id": str(include.included_profile_id),
|
|
||||||
"included_profile_name": included_profile.name if included_profile else None,
|
|
||||||
"order_index": include.order_index,
|
|
||||||
"created_at": include.created_at.isoformat() if include.created_at else None,
|
|
||||||
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
"/{profile_id}/includes/{include_id}",
|
|
||||||
summary="Remove profile include",
|
|
||||||
description="Remove an include from a config profile.",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
)
|
|
||||||
async def remove_profile_include(
|
async def remove_profile_include(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
include_id: uuid.UUID,
|
include_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:
|
||||||
"""Remove an include from a config profile."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
include = await session.get(ConfigInclude, include_id)
|
include = await session.get(ConfigInclude, include_id)
|
||||||
if include is None or include.profile_id != profile_id:
|
if include is None or include.profile_id != profile_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="include not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.delete(include)
|
await session.delete(include)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Mount management
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/{profile_id}/mounts")
|
||||||
"/{profile_id}/mounts",
|
|
||||||
summary="List profile mounts",
|
|
||||||
description="Get all mounts for a config profile.",
|
|
||||||
)
|
|
||||||
async def list_profile_mounts(
|
async def list_profile_mounts(
|
||||||
profile_id: uuid.UUID,
|
profile_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 mounts for a config profile."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
return await list_mounts_for_profile(session, profile_id)
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigMount)
|
|
||||||
.where(ConfigMount.profile_id == profile_id)
|
|
||||||
.order_by(ConfigMount.order_index)
|
|
||||||
)
|
|
||||||
mounts = result.scalars().all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"mounts": [
|
|
||||||
{
|
|
||||||
"id": str(m.id),
|
|
||||||
"profile_id": str(m.profile_id),
|
|
||||||
"target_path": m.target_path,
|
|
||||||
"files": m.files,
|
|
||||||
"mode": m.mode,
|
|
||||||
"order_index": m.order_index,
|
|
||||||
"created_at": m.created_at.isoformat() if m.created_at else None,
|
|
||||||
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
|
|
||||||
}
|
|
||||||
for m in mounts
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED)
|
||||||
"/{profile_id}/mounts",
|
|
||||||
summary="Add profile mount",
|
|
||||||
description="Add a mount to a config profile.",
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def add_profile_mount(
|
async def add_profile_mount(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
data: ConfigMountCreate,
|
data: ConfigMountCreate,
|
||||||
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:
|
||||||
"""Add a mount to a config profile."""
|
profile = await get_owned_profile(profile_id, user_id, session)
|
||||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
await check_duplicate_mount_path(session, profile_id, data.target_path)
|
||||||
|
|
||||||
# Check for duplicate target_path
|
|
||||||
existing = await session.scalar(
|
|
||||||
select(ConfigMount).where(
|
|
||||||
ConfigMount.profile_id == profile_id,
|
|
||||||
ConfigMount.target_path == data.target_path,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"mount with path '{data.target_path}' already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
mount = ConfigMount(
|
mount = ConfigMount(
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
target_path=data.target_path,
|
target_path=data.target_path,
|
||||||
@@ -779,24 +257,10 @@ async def add_profile_mount(
|
|||||||
session.add(mount)
|
session.add(mount)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(mount)
|
await session.refresh(mount)
|
||||||
|
return mount_to_dict(mount)
|
||||||
return {
|
|
||||||
"id": str(mount.id),
|
|
||||||
"profile_id": str(mount.profile_id),
|
|
||||||
"target_path": mount.target_path,
|
|
||||||
"files": mount.files,
|
|
||||||
"mode": mount.mode,
|
|
||||||
"order_index": mount.order_index,
|
|
||||||
"created_at": mount.created_at.isoformat() if mount.created_at else None,
|
|
||||||
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put("/{profile_id}/mounts/{mount_id}")
|
||||||
"/{profile_id}/mounts/{mount_id}",
|
|
||||||
summary="Update profile mount",
|
|
||||||
description="Update a mount in a config profile.",
|
|
||||||
)
|
|
||||||
async def update_profile_mount(
|
async def update_profile_mount(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
mount_id: uuid.UUID,
|
mount_id: uuid.UUID,
|
||||||
@@ -804,74 +268,32 @@ async def update_profile_mount(
|
|||||||
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:
|
||||||
"""Update a profile mount."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
mount = await session.get(ConfigMount, mount_id)
|
mount = await session.get(ConfigMount, mount_id)
|
||||||
if mount is None or mount.profile_id != profile_id:
|
if mount is None or mount.profile_id != profile_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="mount not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.target_path is not None:
|
if data.target_path is not None:
|
||||||
# Check for duplicate target_path
|
await check_duplicate_mount_path(session, profile_id, data.target_path, exclude_id=mount_id)
|
||||||
existing = await session.scalar(
|
|
||||||
select(ConfigMount).where(
|
|
||||||
ConfigMount.profile_id == profile_id,
|
|
||||||
ConfigMount.target_path == data.target_path,
|
|
||||||
ConfigMount.id != mount_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"mount with path '{data.target_path}' already exists",
|
|
||||||
)
|
|
||||||
mount.target_path = data.target_path
|
mount.target_path = data.target_path
|
||||||
|
|
||||||
if data.files is not None:
|
if data.files is not None:
|
||||||
mount.files = data.files
|
mount.files = data.files
|
||||||
|
|
||||||
if data.order_index is not None:
|
if data.order_index is not None:
|
||||||
mount.order_index = data.order_index
|
mount.order_index = data.order_index
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(mount)
|
await session.refresh(mount)
|
||||||
|
return mount_to_dict(mount)
|
||||||
return {
|
|
||||||
"id": str(mount.id),
|
|
||||||
"profile_id": str(mount.profile_id),
|
|
||||||
"target_path": mount.target_path,
|
|
||||||
"files": mount.files,
|
|
||||||
"mode": mount.mode,
|
|
||||||
"order_index": mount.order_index,
|
|
||||||
"created_at": mount.created_at.isoformat() if mount.created_at else None,
|
|
||||||
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
"/{profile_id}/mounts/{mount_id}",
|
|
||||||
summary="Remove profile mount",
|
|
||||||
description="Remove a mount from a config profile.",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
)
|
|
||||||
async def remove_profile_mount(
|
async def remove_profile_mount(
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
mount_id: uuid.UUID,
|
mount_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:
|
||||||
"""Remove a mount from a config profile."""
|
await get_owned_profile(profile_id, user_id, session)
|
||||||
await _get_owned_profile(profile_id, user_id, session)
|
|
||||||
|
|
||||||
mount = await session.get(ConfigMount, mount_id)
|
mount = await session.get(ConfigMount, mount_id)
|
||||||
if mount is None or mount.profile_id != profile_id:
|
if mount is None or mount.profile_id != profile_id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="mount not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.delete(mount)
|
await session.delete(mount)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
+142
-1050
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,17 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
from src.schemas.health import (
|
||||||
|
DatabaseHealth,
|
||||||
|
DatabaseHealthResponse,
|
||||||
|
DiskHealth,
|
||||||
|
HealthChecks,
|
||||||
|
HealthResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -17,45 +23,6 @@ router = APIRouter()
|
|||||||
_start_time = time.time()
|
_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(
|
@router.get(
|
||||||
"/health",
|
"/health",
|
||||||
response_model=HealthResponse,
|
response_model=HealthResponse,
|
||||||
|
|||||||
@@ -3,50 +3,24 @@ import shutil
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
from src.schemas.project import (
|
||||||
|
ProjectCreate,
|
||||||
|
ProjectUpdate,
|
||||||
|
ProjectResponse,
|
||||||
|
SetDefaultSSHKeyRequest,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
owner_id: uuid.UUID
|
|
||||||
default_ssh_key_id: uuid.UUID | None
|
|
||||||
|
|
||||||
|
|
||||||
class SetDefaultSSHKeyRequest(BaseModel):
|
|
||||||
ssh_key_id: uuid.UUID
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
@@ -57,7 +31,7 @@ class SetDefaultSSHKeyRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
async def create_project(
|
async def create_project(
|
||||||
data: ProjectCreate,
|
data: ProjectCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Create a new project.
|
"""Create a new project.
|
||||||
@@ -70,7 +44,6 @@ async def create_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created project.
|
The newly created project.
|
||||||
"""
|
"""
|
||||||
user = await _get_user(session, user_id)
|
|
||||||
project = Project(
|
project = Project(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -90,7 +63,7 @@ async def create_project(
|
|||||||
description="Retrieve all projects owned by the authenticated user.",
|
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: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[Project]:
|
) -> list[Project]:
|
||||||
"""List all projects for the authenticated user.
|
"""List all projects for the authenticated user.
|
||||||
@@ -102,7 +75,6 @@ async def list_projects(
|
|||||||
Returns:
|
Returns:
|
||||||
List of projects owned by the user.
|
List of projects owned by the user.
|
||||||
"""
|
"""
|
||||||
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())
|
||||||
|
|
||||||
@@ -115,7 +87,8 @@ async def list_projects(
|
|||||||
)
|
)
|
||||||
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: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Get a specific project by ID.
|
"""Get a specific project by ID.
|
||||||
@@ -128,36 +101,10 @@ async def get_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The requested project.
|
The requested project.
|
||||||
"""
|
"""
|
||||||
await _get_user(session, user_id)
|
|
||||||
return await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned_project(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> 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)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
|
||||||
if project.owner_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/{project_id}",
|
"/{project_id}",
|
||||||
response_model=ProjectResponse,
|
response_model=ProjectResponse,
|
||||||
@@ -167,7 +114,8 @@ async def _get_owned_project(
|
|||||||
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: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Update a project.
|
"""Update a project.
|
||||||
@@ -181,8 +129,6 @@ async def update_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated project.
|
The updated project.
|
||||||
"""
|
"""
|
||||||
await _get_user(session, user_id)
|
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
project.name = data.name
|
project.name = data.name
|
||||||
@@ -202,7 +148,8 @@ async def update_project(
|
|||||||
)
|
)
|
||||||
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: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Delete a project and all its repositories.
|
"""Delete a project and all its repositories.
|
||||||
@@ -215,8 +162,6 @@ async def delete_project(
|
|||||||
Returns:
|
Returns:
|
||||||
Empty response with 204 status code.
|
Empty response with 204 status code.
|
||||||
"""
|
"""
|
||||||
await _get_user(session, user_id)
|
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
# Delete repositories from disk and database
|
# Delete repositories from disk and database
|
||||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
||||||
@@ -240,7 +185,8 @@ async def delete_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: User = Depends(get_current_user),
|
||||||
|
project: Project = Depends(get_owned_project),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Set the default SSH key for a project.
|
"""Set the default SSH key for a project.
|
||||||
@@ -254,8 +200,6 @@ async def set_default_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated project.
|
The updated project.
|
||||||
"""
|
"""
|
||||||
user = await _get_user(session, user_id)
|
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
ssh_key = await session.get(SSHKey, data.ssh_key_id)
|
ssh_key = await session.get(SSHKey, data.ssh_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:
|
||||||
|
|||||||
@@ -5,25 +5,18 @@ from cryptography.fernet import Fernet
|
|||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user, get_db_session
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
from src.schemas.ssh_key import SSHKeyCreate, SSHKeyResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def _get_fernet() -> Fernet:
|
def _get_fernet() -> Fernet:
|
||||||
"""Generate a valid Fernet key from the session secret."""
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
@@ -61,19 +54,6 @@ def generate_ssh_key_pair() -> tuple[str, str]:
|
|||||||
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
|
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
class SSHKeyCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
class SSHKeyResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
public_key: str
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=SSHKeyResponse,
|
response_model=SSHKeyResponse,
|
||||||
@@ -83,7 +63,7 @@ class SSHKeyResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
async def create_ssh_key(
|
async def create_ssh_key(
|
||||||
data: SSHKeyCreate,
|
data: SSHKeyCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> SSHKey:
|
) -> SSHKey:
|
||||||
"""Create a new SSH key pair.
|
"""Create a new SSH key pair.
|
||||||
@@ -96,7 +76,6 @@ async def create_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created SSH key with public key exposed.
|
The newly created SSH key with public key exposed.
|
||||||
"""
|
"""
|
||||||
user = await _get_user(session, user_id)
|
|
||||||
private_key, public_key = generate_ssh_key_pair()
|
private_key, public_key = generate_ssh_key_pair()
|
||||||
|
|
||||||
fernet = _get_fernet()
|
fernet = _get_fernet()
|
||||||
@@ -121,7 +100,7 @@ async def create_ssh_key(
|
|||||||
description="List all SSH keys for the authenticated user.",
|
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: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[SSHKey]:
|
) -> list[SSHKey]:
|
||||||
"""List all SSH keys for the authenticated user.
|
"""List all SSH keys for the authenticated user.
|
||||||
@@ -133,7 +112,6 @@ async def list_ssh_keys(
|
|||||||
Returns:
|
Returns:
|
||||||
List of SSH keys owned by the user.
|
List of SSH keys owned by the user.
|
||||||
"""
|
"""
|
||||||
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())
|
||||||
|
|
||||||
@@ -146,7 +124,7 @@ async def list_ssh_keys(
|
|||||||
)
|
)
|
||||||
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: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete an SSH key.
|
"""Delete an SSH key.
|
||||||
@@ -159,7 +137,6 @@ async def delete_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
None with 204 status code.
|
None with 204 status code.
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
|||||||
@@ -4,128 +4,19 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.tool_config import ToolConfig
|
from src.models.tool_config import ToolConfig
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
|
from src.schemas.tool_config import ToolConfigCreate, ToolConfigUpdate, ToolConfigResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigCreate(BaseModel):
|
|
||||||
tool_type_id: str = Field(description="UUID of the tool type")
|
|
||||||
project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config")
|
|
||||||
key: str = Field(description="Config key name")
|
|
||||||
value: str = Field(description="Config value")
|
|
||||||
config_type: str = Field(default="env", description="Type: env or file")
|
|
||||||
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
|
||||||
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
|
||||||
start_command: str | None = Field(default=None, description="Override container start command")
|
|
||||||
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
|
||||||
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
|
||||||
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
|
||||||
|
|
||||||
@field_validator("port_override")
|
|
||||||
@classmethod
|
|
||||||
def validate_port(cls, v: int | None) -> int | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if v < 1 or v > 65535:
|
|
||||||
raise ValueError("Port must be between 1 and 65535")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("environment_variables")
|
|
||||||
@classmethod
|
|
||||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not isinstance(v, dict):
|
|
||||||
raise ValueError("environment_variables must be a JSON object")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("volumes")
|
|
||||||
@classmethod
|
|
||||||
def validate_volumes(cls, v: list | None) -> list | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not isinstance(v, list):
|
|
||||||
raise ValueError("volumes must be a JSON array")
|
|
||||||
for i, vol in enumerate(v):
|
|
||||||
if not isinstance(vol, dict):
|
|
||||||
raise ValueError(f"Volume at index {i} must be an object")
|
|
||||||
if "source" not in vol:
|
|
||||||
raise ValueError(f"Volume at index {i} must have 'source' field")
|
|
||||||
if "target" not in vol:
|
|
||||||
raise ValueError(f"Volume at index {i} must have 'target' field")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigUpdate(BaseModel):
|
|
||||||
key: str | None = Field(default=None, description="Config key name")
|
|
||||||
value: str | None = Field(default=None, description="Config value")
|
|
||||||
config_type: str | None = Field(default=None, description="Type: env or file")
|
|
||||||
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
|
||||||
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
|
||||||
start_command: str | None = Field(default=None, description="Override container start command")
|
|
||||||
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
|
||||||
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
|
||||||
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
|
||||||
|
|
||||||
@field_validator("port_override")
|
|
||||||
@classmethod
|
|
||||||
def validate_port(cls, v: int | None) -> int | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if v < 1 or v > 65535:
|
|
||||||
raise ValueError("Port must be between 1 and 65535")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("environment_variables")
|
|
||||||
@classmethod
|
|
||||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not isinstance(v, dict):
|
|
||||||
raise ValueError("environment_variables must be a JSON object")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("volumes")
|
|
||||||
@classmethod
|
|
||||||
def validate_volumes(cls, v: list | None) -> list | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not isinstance(v, list):
|
|
||||||
raise ValueError("volumes must be a JSON array")
|
|
||||||
for i, vol in enumerate(v):
|
|
||||||
if not isinstance(vol, dict):
|
|
||||||
raise ValueError(f"Volume at index {i} must be an object")
|
|
||||||
if "source" not in vol:
|
|
||||||
raise ValueError(f"Volume at index {i} must have 'source' field")
|
|
||||||
if "target" not in vol:
|
|
||||||
raise ValueError(f"Volume at index {i} must have 'target' field")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
tool_type_id: str
|
|
||||||
project_id: str | None
|
|
||||||
key: str
|
|
||||||
value: str
|
|
||||||
config_type: str
|
|
||||||
file_path: str | None
|
|
||||||
port_override: int | None
|
|
||||||
start_command: str | None
|
|
||||||
working_directory: str | None
|
|
||||||
environment_variables: dict | None
|
|
||||||
volumes: list[dict] | None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
||||||
async def list_configs(
|
async def list_configs(
|
||||||
tool_type_id: str | None = None,
|
tool_type_id: str | None = None,
|
||||||
|
|||||||
+125
-1303
File diff suppressed because it is too large
Load Diff
@@ -3,25 +3,17 @@ from datetime import datetime
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user, get_db_session
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
from src.schemas.tool_type import ToolTypeCreate, ToolTypeResponse, ToolTypeUpdate, ToolTypeValidateRequest
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_admin(user: User) -> None:
|
async def _require_admin(user: User) -> None:
|
||||||
"""Check if user has admin privileges.
|
"""Check if user has admin privileges.
|
||||||
|
|
||||||
@@ -33,230 +25,6 @@ async def _require_admin(user: User) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
display_name: str
|
|
||||||
description: str | None = None
|
|
||||||
default_port: int
|
|
||||||
definition_type: str = "compose"
|
|
||||||
compose_template: str | None = None
|
|
||||||
dockerfile_template: str | None = None
|
|
||||||
build_context: dict | None = None
|
|
||||||
readiness_probe: dict | None = None
|
|
||||||
required_variables: list[str] = []
|
|
||||||
category: str = "other"
|
|
||||||
interfaces: list[str] = ["web"]
|
|
||||||
|
|
||||||
@field_validator("definition_type")
|
|
||||||
@classmethod
|
|
||||||
def validate_definition_type(cls, v: str) -> str:
|
|
||||||
if v not in ("compose", "dockerfile"):
|
|
||||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("compose_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "compose":
|
|
||||||
return v
|
|
||||||
|
|
||||||
if v is None:
|
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(v)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
|
||||||
|
|
||||||
if "services" not in parsed:
|
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
|
||||||
|
|
||||||
if not parsed["services"]:
|
|
||||||
raise ValueError("Compose template must define at least one service")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "dockerfile":
|
|
||||||
return v
|
|
||||||
|
|
||||||
if v is None:
|
|
||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
|
||||||
|
|
||||||
if not v.strip().startswith("FROM"):
|
|
||||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("default_port")
|
|
||||||
@classmethod
|
|
||||||
def validate_default_port(cls, v: int, info) -> int:
|
|
||||||
if v <= 0 or v > 65535:
|
|
||||||
raise ValueError("Port must be between 1 and 65535")
|
|
||||||
|
|
||||||
# Get compose_template from the model data
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "compose":
|
|
||||||
return v
|
|
||||||
|
|
||||||
template = data.get("compose_template")
|
|
||||||
if not template:
|
|
||||||
return v
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(template)
|
|
||||||
except yaml.YAMLError:
|
|
||||||
return v
|
|
||||||
|
|
||||||
# Check if the port is exposed in any service
|
|
||||||
port_str = str(v)
|
|
||||||
port_exposed = False
|
|
||||||
|
|
||||||
if isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
for service_name, service_config in parsed["services"].items():
|
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if isinstance(port_mapping, str):
|
|
||||||
# Format: "8443:8443" or "8443"
|
|
||||||
if port_str in port_mapping:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif isinstance(port_mapping, int) and port_mapping == v:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not port_exposed:
|
|
||||||
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("required_variables")
|
|
||||||
@classmethod
|
|
||||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
|
||||||
if not v:
|
|
||||||
return v
|
|
||||||
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "compose":
|
|
||||||
return v
|
|
||||||
|
|
||||||
template = data.get("compose_template")
|
|
||||||
if not template:
|
|
||||||
return v
|
|
||||||
|
|
||||||
for var in v:
|
|
||||||
placeholder = f"{{{{{var}}}}}"
|
|
||||||
if placeholder not in template:
|
|
||||||
raise ValueError(f"Required variable '{var}' not found in compose template")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def validate_templates(self) -> "ToolTypeCreate":
|
|
||||||
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
|
|
||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
|
||||||
if self.definition_type == "compose" and self.compose_template is None:
|
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeUpdate(BaseModel):
|
|
||||||
display_name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
default_port: int | None = None
|
|
||||||
definition_type: str | None = None
|
|
||||||
compose_template: str | None = None
|
|
||||||
dockerfile_template: str | None = None
|
|
||||||
build_context: dict | None = None
|
|
||||||
readiness_probe: dict | None = None
|
|
||||||
required_variables: list[str] | None = None
|
|
||||||
category: str | None = None
|
|
||||||
interfaces: list[str] | None = None
|
|
||||||
|
|
||||||
@field_validator("definition_type")
|
|
||||||
@classmethod
|
|
||||||
def validate_definition_type(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if v not in ("compose", "dockerfile"):
|
|
||||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("compose_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
|
|
||||||
data = info.data
|
|
||||||
definition_type = data.get("definition_type")
|
|
||||||
if definition_type and definition_type != "compose":
|
|
||||||
return v
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(v)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
|
||||||
|
|
||||||
if "services" not in parsed:
|
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
|
||||||
|
|
||||||
if not parsed["services"]:
|
|
||||||
raise ValueError("Compose template must define at least one service")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
|
|
||||||
data = info.data
|
|
||||||
definition_type = data.get("definition_type")
|
|
||||||
if definition_type and definition_type != "dockerfile":
|
|
||||||
return v
|
|
||||||
|
|
||||||
if not v.strip().startswith("FROM"):
|
|
||||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
display_name: str
|
|
||||||
description: str | None
|
|
||||||
category: str
|
|
||||||
interfaces: list[str]
|
|
||||||
default_port: int
|
|
||||||
definition_type: str
|
|
||||||
compose_template: str | None
|
|
||||||
dockerfile_template: str | None
|
|
||||||
build_context: dict | None
|
|
||||||
readiness_probe: dict | None
|
|
||||||
required_variables: list[str]
|
|
||||||
is_builtin: bool
|
|
||||||
created_by_id: uuid.UUID | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=ToolTypeResponse,
|
response_model=ToolTypeResponse,
|
||||||
@@ -266,7 +34,7 @@ class ToolTypeResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
async def create_tool_type(
|
async def create_tool_type(
|
||||||
data: ToolTypeCreate,
|
data: ToolTypeCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Create a new tool type.
|
"""Create a new tool type.
|
||||||
@@ -279,7 +47,6 @@ async def create_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created tool type.
|
The newly created tool type.
|
||||||
"""
|
"""
|
||||||
user = await _get_user(session, user_id)
|
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
@@ -316,7 +83,7 @@ async def create_tool_type(
|
|||||||
description="List all available tool types including built-in and custom ones.",
|
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: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[ToolType]:
|
) -> list[ToolType]:
|
||||||
"""List all tool types.
|
"""List all tool types.
|
||||||
@@ -328,7 +95,6 @@ async def list_tool_types(
|
|||||||
Returns:
|
Returns:
|
||||||
List of all tool types ordered by name.
|
List of all tool types ordered by name.
|
||||||
"""
|
"""
|
||||||
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())
|
||||||
|
|
||||||
@@ -341,7 +107,7 @@ async def list_tool_types(
|
|||||||
)
|
)
|
||||||
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: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Get a specific tool type by ID.
|
"""Get a specific tool type by ID.
|
||||||
@@ -354,7 +120,6 @@ async def get_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The requested tool type.
|
The requested tool type.
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
@@ -370,7 +135,7 @@ async def get_tool_type(
|
|||||||
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: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Update a tool type.
|
"""Update a tool type.
|
||||||
@@ -384,7 +149,6 @@ async def update_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated tool type.
|
The updated tool type.
|
||||||
"""
|
"""
|
||||||
user = await _get_user(session, user_id)
|
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
@@ -467,12 +231,6 @@ async def update_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeValidateRequest(BaseModel):
|
|
||||||
definition_type: str
|
|
||||||
compose_template: str | None = None
|
|
||||||
dockerfile_template: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/validate",
|
"/validate",
|
||||||
summary="Validate tool type template",
|
summary="Validate tool type template",
|
||||||
@@ -480,7 +238,7 @@ class ToolTypeValidateRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
async def validate_tool_type_template(
|
async def validate_tool_type_template(
|
||||||
data: ToolTypeValidateRequest,
|
data: ToolTypeValidateRequest,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate a tool type template syntax.
|
"""Validate a tool type template syntax.
|
||||||
@@ -493,7 +251,6 @@ async def validate_tool_type_template(
|
|||||||
Returns:
|
Returns:
|
||||||
Validation result with success status and any errors.
|
Validation result with success status and any errors.
|
||||||
"""
|
"""
|
||||||
await _get_user(session, user_id)
|
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -534,7 +291,7 @@ async def validate_tool_type_template(
|
|||||||
)
|
)
|
||||||
async def validate_tool_type(
|
async def validate_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate a tool type's template syntax.
|
"""Validate a tool type's template syntax.
|
||||||
@@ -547,7 +304,6 @@ async def validate_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
Validation result with success status and any errors.
|
Validation result with success status and any errors.
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
@@ -589,7 +345,7 @@ async def validate_tool_type(
|
|||||||
)
|
)
|
||||||
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: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a tool type.
|
"""Delete a tool type.
|
||||||
@@ -602,7 +358,6 @@ async def delete_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
None with 204 status code.
|
None with 204 status code.
|
||||||
"""
|
"""
|
||||||
user = await _get_user(session, user_id)
|
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
|
|||||||
@@ -2,26 +2,17 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user, get_db_session
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
from src.schemas.user_config import UserConfigResponse, UserConfigUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return 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.
|
"""Get or create user config record.
|
||||||
@@ -33,34 +24,16 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
|||||||
Returns:
|
Returns:
|
||||||
The user's config, creating a new one if it doesn't exist.
|
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:
|
||||||
config = UserConfig(user_id=user_id, config={})
|
config = UserConfig(user_id=user.id, config={})
|
||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
class UserConfigResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
default_editor: str | None = None
|
|
||||||
theme: str = "system"
|
|
||||||
git_user_name: str | None = None
|
|
||||||
git_user_email: str | None = None
|
|
||||||
last_session_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class UserConfigUpdate(BaseModel):
|
|
||||||
default_editor: str | None = None
|
|
||||||
theme: str | None = None
|
|
||||||
git_user_name: str | None = None
|
|
||||||
git_user_email: str | None = None
|
|
||||||
last_session_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/config",
|
"/config",
|
||||||
response_model=UserConfigResponse,
|
response_model=UserConfigResponse,
|
||||||
@@ -68,7 +41,7 @@ class UserConfigUpdate(BaseModel):
|
|||||||
description="Get the current user's configuration settings.",
|
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: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
"""Get the current user's configuration.
|
"""Get the current user's configuration.
|
||||||
@@ -80,8 +53,7 @@ async def get_user_config(
|
|||||||
Returns:
|
Returns:
|
||||||
The user's configuration settings.
|
The user's configuration settings.
|
||||||
"""
|
"""
|
||||||
_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)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +65,7 @@ async def get_user_config(
|
|||||||
)
|
)
|
||||||
async def update_user_config(
|
async def update_user_config(
|
||||||
data: UserConfigUpdate,
|
data: UserConfigUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
"""Update the current user's configuration.
|
"""Update the current user's configuration.
|
||||||
@@ -106,12 +78,11 @@ async def update_user_config(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user configuration.
|
The updated user configuration.
|
||||||
"""
|
"""
|
||||||
_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)
|
|
||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
logger.info("Updating user config for user %s: %s", user_id, update_data)
|
logger.info("Updating user config for user %s: %s", user.id, update_data)
|
||||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
config.config = {**config.config, **update_data}
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
|
|||||||
+47
-29
@@ -2,11 +2,14 @@ import uuid
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user, get_db_session
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
from src.schemas.tool_instance import SessionItemResponse, SessionListResponse
|
||||||
|
from src.schemas.user import UserProfileResponse, UserProfileUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
@@ -16,27 +19,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
|||||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
email: str
|
|
||||||
name: str
|
|
||||||
avatar_url: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/me",
|
"/me",
|
||||||
@@ -45,7 +27,7 @@ class UserProfileUpdate(BaseModel):
|
|||||||
description="Retrieve the profile of the currently authenticated user.",
|
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: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Get the current user's profile.
|
"""Get the current user's profile.
|
||||||
@@ -57,7 +39,7 @@ async def get_profile(
|
|||||||
Returns:
|
Returns:
|
||||||
The user's profile information.
|
The user's profile information.
|
||||||
"""
|
"""
|
||||||
return await _get_user(session, user_id)
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
@@ -68,7 +50,7 @@ async def get_profile(
|
|||||||
)
|
)
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
data: UserProfileUpdate,
|
data: UserProfileUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Update the current user's profile.
|
"""Update the current user's profile.
|
||||||
@@ -81,7 +63,6 @@ async def update_profile(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user profile.
|
The updated user profile.
|
||||||
"""
|
"""
|
||||||
user = await _get_user(session, user_id)
|
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
if len(data.name.strip()) == 0:
|
if len(data.name.strip()) == 0:
|
||||||
@@ -106,7 +87,7 @@ async def update_profile(
|
|||||||
)
|
)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Upload a profile avatar image.
|
"""Upload a profile avatar image.
|
||||||
@@ -119,7 +100,6 @@ async def upload_avatar(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user profile with new avatar URL.
|
The updated user profile with new avatar URL.
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -154,3 +134,41 @@ async def upload_avatar(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/me/sessions",
|
||||||
|
response_model=SessionListResponse,
|
||||||
|
summary="Get current user sessions",
|
||||||
|
description="Retrieve all tool instances (sessions) for the authenticated user.",
|
||||||
|
)
|
||||||
|
async def get_user_sessions(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> SessionListResponse:
|
||||||
|
"""Return all tool instances for the current user with related names."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance)
|
||||||
|
.where(ToolInstance.owner_id == user.id)
|
||||||
|
.order_by(ToolInstance.created_at.desc())
|
||||||
|
)
|
||||||
|
instances = result.scalars().all()
|
||||||
|
|
||||||
|
sessions = [
|
||||||
|
SessionItemResponse(
|
||||||
|
id=str(inst.id),
|
||||||
|
display_name=inst.display_name,
|
||||||
|
tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown",
|
||||||
|
tool_icon=inst.tool_type.icon if inst.tool_type else None,
|
||||||
|
tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [],
|
||||||
|
repository_name=inst.repository.name if inst.repository else "Unknown",
|
||||||
|
repository_id=str(inst.repository_id),
|
||||||
|
project_name=inst.project.name if inst.project else "Unknown",
|
||||||
|
project_id=str(inst.project_id),
|
||||||
|
status=inst.status,
|
||||||
|
url=inst.url,
|
||||||
|
)
|
||||||
|
for inst in instances
|
||||||
|
]
|
||||||
|
|
||||||
|
return SessionListResponse(sessions=sessions)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.auth.session import decode_session_cookie
|
from src.auth.session import decode_session_cookie
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
from src.models.project import Project
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -47,3 +48,29 @@ async def get_current_user(
|
|||||||
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")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_owned_project(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Project:
|
||||||
|
"""Fetch a project and verify ownership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project (injected from path parameter).
|
||||||
|
user: The currently authenticated user.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The project if found and owned by the user.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if project not found, 403 if user is not the owner.
|
||||||
|
"""
|
||||||
|
project = await db_session.get(Project, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||||
|
if project.owner_id != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||||
|
return project
|
||||||
|
|||||||
+1
-152
@@ -7,8 +7,6 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import select, text
|
|
||||||
|
|
||||||
from 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
|
||||||
@@ -32,7 +30,7 @@ from src.logging_config import (
|
|||||||
RequestLoggingMiddleware,
|
RequestLoggingMiddleware,
|
||||||
configure_logging,
|
configure_logging,
|
||||||
)
|
)
|
||||||
from src.models.tool_type import ToolType
|
from src.seeds.builtin_tool_types import seed_builtin_tool_types
|
||||||
|
|
||||||
# Configure logging early
|
# Configure logging early
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
@@ -104,155 +102,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _table_exists(session, table_name: str) -> bool:
|
|
||||||
"""Check if a table exists in the database."""
|
|
||||||
try:
|
|
||||||
result = await session.execute(
|
|
||||||
text("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = :table_name
|
|
||||||
)
|
|
||||||
"""),
|
|
||||||
{"table_name": table_name},
|
|
||||||
)
|
|
||||||
return result.scalar() or False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def seed_builtin_tool_types():
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
# Check if tool_types table exists before attempting to seed
|
|
||||||
if not await _table_exists(session, "tool_types"):
|
|
||||||
logger.warning(
|
|
||||||
"tool_types table does not exist. Skipping seeding. "
|
|
||||||
"Migrations may not have run yet."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
builtin_types = [
|
|
||||||
{
|
|
||||||
"name": "code-server",
|
|
||||||
"display_name": "VS Code Server",
|
|
||||||
"description": "VS Code running in the browser via code-server",
|
|
||||||
"category": "editor",
|
|
||||||
"interfaces": ["web"],
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
code-server:
|
|
||||||
image: lscr.io/linuxserver/code-server:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- PUID=1000
|
|
||||||
- PGID=1000
|
|
||||||
- TZ=Europe/London
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/config/workspace
|
|
||||||
ports:
|
|
||||||
- "8443:8443"
|
|
||||||
restart: unless-stopped""",
|
|
||||||
"default_port": 8443,
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "jupyter-notebook",
|
|
||||||
"display_name": "Jupyter Notebook",
|
|
||||||
"description": "Jupyter Lab for interactive development",
|
|
||||||
"category": "notebook",
|
|
||||||
"interfaces": ["web"],
|
|
||||||
"default_port": 8888,
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
jupyter:
|
|
||||||
image: jupyter/scipy-notebook:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- JUPYTER_ENABLE_LAB=yes
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/home/jovyan/work
|
|
||||||
ports:
|
|
||||||
- "8888:8888"
|
|
||||||
restart: unless-stopped""",
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "opencode",
|
|
||||||
"display_name": "OpenCode",
|
|
||||||
"description": "AI coding assistant - run opencode in terminal",
|
|
||||||
"category": "ai-assistant",
|
|
||||||
"interfaces": ["terminal"],
|
|
||||||
"default_port": 3000,
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
opencode:
|
|
||||||
image: node:20-slim
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
working_dir: /workspace
|
|
||||||
environment:
|
|
||||||
- HOME=/tmp
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/workspace
|
|
||||||
- opencode_home:/tmp
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
command: >
|
|
||||||
sh -c "set -x &&
|
|
||||||
apt-get update && apt-get install -y git ca-certificates &&
|
|
||||||
echo 'Installing opencode...' &&
|
|
||||||
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
|
||||||
which opencode || echo 'ERROR: opencode not in PATH' &&
|
|
||||||
npm bin -g &&
|
|
||||||
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
|
||||||
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
|
|
||||||
echo 'cd /workspace' >> /root/.bashrc &&
|
|
||||||
echo 'OpenCode installation complete' &&
|
|
||||||
cd /workspace &&
|
|
||||||
exec tail -f /dev/null"
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
opencode_home:""",
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for tool_data in builtin_types:
|
|
||||||
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
|
||||||
if not existing:
|
|
||||||
tool_type = ToolType(
|
|
||||||
name=tool_data["name"],
|
|
||||||
display_name=tool_data["display_name"],
|
|
||||||
description=tool_data["description"],
|
|
||||||
category=tool_data["category"],
|
|
||||||
interfaces=tool_data["interfaces"],
|
|
||||||
definition_type="compose",
|
|
||||||
compose_template=tool_data["compose_template"],
|
|
||||||
required_variables=tool_data["required_variables"],
|
|
||||||
default_port=tool_data.get("default_port"),
|
|
||||||
is_builtin=True,
|
|
||||||
)
|
|
||||||
session.add(tool_type)
|
|
||||||
logger.info("Created built-in tool type: %s", tool_data["name"])
|
|
||||||
else:
|
|
||||||
# Update existing built-in tool types to reflect code changes
|
|
||||||
existing.display_name = tool_data["display_name"]
|
|
||||||
existing.description = tool_data["description"]
|
|
||||||
existing.category = tool_data["category"]
|
|
||||||
existing.interfaces = tool_data["interfaces"]
|
|
||||||
existing.definition_type = "compose"
|
|
||||||
existing.compose_template = tool_data["compose_template"]
|
|
||||||
existing.required_variables = tool_data["required_variables"]
|
|
||||||
existing.default_port = tool_data.get("default_port")
|
|
||||||
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
logger.info("Built-in tool types seeded successfully.")
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def on_startup():
|
async def on_startup():
|
||||||
logger.info("Starting up Headquarter API...")
|
logger.info("Starting up Headquarter API...")
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Pydantic request/response schemas."""
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Config folder request/response schemas."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderCreate(BaseModel):
|
||||||
|
name: str = Field(description="Folder name")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
mount_path: str = Field(description="Mount path in container")
|
||||||
|
files: dict[str, str] | None = Field(
|
||||||
|
default=None, description="Files as {path: content}"
|
||||||
|
)
|
||||||
|
is_active: bool = Field(default=True, description="Whether folder is active")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
mount_path: str | None = None
|
||||||
|
files: dict[str, str] | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectOverrideCreate(BaseModel):
|
||||||
|
project_id: str = Field(description="Project ID to override for")
|
||||||
|
mount_path: str | None = Field(default=None, description="Override mount path")
|
||||||
|
files: dict[str, str] | None = Field(
|
||||||
|
default=None, description="Override files"
|
||||||
|
)
|
||||||
|
is_active: bool | None = Field(default=None, description="Override active state")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
mount_path: str
|
||||||
|
files: dict[str, str] | None
|
||||||
|
is_active: bool
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Config profile request/response schemas."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
MAX_MOUNT_PATH_LENGTH = 1024
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileCreate(BaseModel):
|
||||||
|
name: str = Field(description="Profile name (unique per user)")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def validate_name(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("Profile name cannot be empty")
|
||||||
|
if len(v) > 255:
|
||||||
|
raise ValueError("Profile name must be 255 characters or less")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, description="Profile name")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def validate_name(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("Profile name cannot be empty")
|
||||||
|
if len(v) > 255:
|
||||||
|
raise ValueError("Profile name must be 255 characters or less")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileDetailResponse(ConfigProfileResponse):
|
||||||
|
includes: list[dict[str, Any]]
|
||||||
|
mounts: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigIncludeCreate(BaseModel):
|
||||||
|
included_profile_id: str = Field(description="UUID of the profile to include")
|
||||||
|
order_index: int = Field(default=0, description="Order index for include resolution")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigIncludeUpdate(BaseModel):
|
||||||
|
order_index: int = Field(description="Order index for include resolution")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigIncludeResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
profile_id: str
|
||||||
|
included_profile_id: str
|
||||||
|
included_profile_name: str | None
|
||||||
|
order_index: int
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigMountCreate(BaseModel):
|
||||||
|
target_path: str = Field(description="Absolute target path in container")
|
||||||
|
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
|
||||||
|
files: dict[str, str] | None = Field(
|
||||||
|
default=None, description="Files as {path: content}"
|
||||||
|
)
|
||||||
|
order_index: int = Field(default=0, description="Order index for mount resolution")
|
||||||
|
|
||||||
|
@field_validator("target_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_target_path(cls, v: str) -> str:
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Target path must be absolute (start with /)")
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("Target path cannot contain parent directory references (..)")
|
||||||
|
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
||||||
|
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigMountUpdate(BaseModel):
|
||||||
|
target_path: str | None = Field(default=None, description="Absolute target path in container")
|
||||||
|
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
|
||||||
|
files: dict[str, str] | None = Field(
|
||||||
|
default=None, description="Files as {path: content}"
|
||||||
|
)
|
||||||
|
order_index: int | None = Field(default=None, description="Order index for mount resolution")
|
||||||
|
|
||||||
|
@field_validator("target_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_target_path(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Target path must be absolute (start with /)")
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("Target path cannot contain parent directory references (..)")
|
||||||
|
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
||||||
|
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigMountResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
profile_id: str
|
||||||
|
target_path: str
|
||||||
|
mode: str
|
||||||
|
files: dict[str, str] | None
|
||||||
|
order_index: int
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultProfilesUpdate(BaseModel):
|
||||||
|
default_profiles: dict[str, str] = Field(
|
||||||
|
description="Mapping of tool_type_id to profile_id"
|
||||||
|
)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Git repository request/response schemas."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class GitRepositoryCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
remote_url: str | None = None
|
||||||
|
force_original_url: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class URLParseRequest(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class URLParseResponse(BaseModel):
|
||||||
|
original_url: str
|
||||||
|
base_url: str | None
|
||||||
|
is_valid_clone_url: bool
|
||||||
|
needs_parsing: bool
|
||||||
|
host: str | None
|
||||||
|
message: str
|
||||||
|
error_code: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class GitRepositoryResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
project_id: uuid.UUID
|
||||||
|
owner_id: uuid.UUID
|
||||||
|
is_mirror: bool
|
||||||
|
remote_url: str | None
|
||||||
|
last_push: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FileListResponse(BaseModel):
|
||||||
|
path: str
|
||||||
|
branch: str
|
||||||
|
entries: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class FileContentResponse(BaseModel):
|
||||||
|
path: str
|
||||||
|
branch: str
|
||||||
|
content: str
|
||||||
|
size: int
|
||||||
|
encoding: str
|
||||||
|
language: str | None
|
||||||
|
is_binary: bool
|
||||||
|
last_commit: dict | None
|
||||||
|
|
||||||
|
|
||||||
|
class BranchesResponse(BaseModel):
|
||||||
|
branches: list[dict]
|
||||||
|
default_branch: str
|
||||||
|
|
||||||
|
|
||||||
|
class FileUpdateRequest(BaseModel):
|
||||||
|
path: str
|
||||||
|
branch: str
|
||||||
|
content: str
|
||||||
|
commit_message: str
|
||||||
|
|
||||||
|
|
||||||
|
class FileUpdateResponse(BaseModel):
|
||||||
|
commit_hash: str
|
||||||
|
message: str
|
||||||
|
branch: str
|
||||||
|
|
||||||
|
|
||||||
|
class StatusResponse(BaseModel):
|
||||||
|
branch: str
|
||||||
|
modified: list[str]
|
||||||
|
added: list[str]
|
||||||
|
deleted: list[str]
|
||||||
|
untracked: list[str]
|
||||||
|
renamed: list[str]
|
||||||
|
ahead: int
|
||||||
|
behind: int
|
||||||
|
|
||||||
|
|
||||||
|
class BranchCreateRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
base_branch: str = "HEAD"
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutRequest(BaseModel):
|
||||||
|
branch: str
|
||||||
|
|
||||||
|
|
||||||
|
class CommitRequest(BaseModel):
|
||||||
|
message: str
|
||||||
|
files: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommitResponse(BaseModel):
|
||||||
|
commit_hash: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class FetchResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class PullResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class PushResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class MergeRequest(BaseModel):
|
||||||
|
source_branch: str
|
||||||
|
target_branch: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MergeResponse(BaseModel):
|
||||||
|
commit_hash: str
|
||||||
|
message: str
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Health check response schemas."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Project request/response schemas."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class SetDefaultSSHKeyRequest(BaseModel):
|
||||||
|
ssh_key_id: str
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""SSH key request/response schemas."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SSHKeyCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
public_key: str
|
||||||
|
|
||||||
|
|
||||||
|
class SSHKeyResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
public_key: str
|
||||||
|
fingerprint: str
|
||||||
|
created_at: str
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tool config request/response schemas."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ToolConfigCreate(BaseModel):
|
||||||
|
tool_type_id: str = Field(description="UUID of the tool type")
|
||||||
|
key: str = Field(description="Configuration key")
|
||||||
|
value: str = Field(description="Configuration value")
|
||||||
|
config_type: str = Field(default="env", description="Config type: env or file")
|
||||||
|
file_path: str | None = Field(default=None, description="File path for file configs")
|
||||||
|
port_override: int | None = Field(default=None, description="Port override")
|
||||||
|
start_command: str | None = Field(default=None, description="Start command override")
|
||||||
|
working_directory: str | None = Field(default=None, description="Working directory")
|
||||||
|
environment_variables: dict[str, str] | None = Field(
|
||||||
|
default=None, description="Additional environment variables"
|
||||||
|
)
|
||||||
|
volumes: list[dict] | None = Field(default=None, description="Volume mounts")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolConfigUpdate(BaseModel):
|
||||||
|
value: str | None = None
|
||||||
|
config_type: str | None = None
|
||||||
|
file_path: str | None = None
|
||||||
|
port_override: int | None = None
|
||||||
|
start_command: str | None = None
|
||||||
|
working_directory: str | None = None
|
||||||
|
environment_variables: dict[str, str] | None = None
|
||||||
|
volumes: list[dict] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ToolConfigResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tool_type_id: str
|
||||||
|
user_id: str
|
||||||
|
project_id: str | None
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
config_type: str
|
||||||
|
file_path: str | None
|
||||||
|
port_override: int | None
|
||||||
|
start_command: str | None
|
||||||
|
working_directory: str | None
|
||||||
|
environment_variables: dict[str, str] | None
|
||||||
|
volumes: list[dict] | None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Tool instance request/response schemas."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInstanceRequest(BaseModel):
|
||||||
|
"""Request body for creating a tool instance."""
|
||||||
|
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||||
|
display_name: str | None = Field(
|
||||||
|
default=None, description="Optional display name for the instance"
|
||||||
|
)
|
||||||
|
config_profile_id: str | None = Field(
|
||||||
|
default=None, description="Optional config profile ID to apply to the instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionItemResponse(BaseModel):
|
||||||
|
"""Lightweight session summary for sidebar and dashboard."""
|
||||||
|
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
id: str = Field(description="Session (tool instance) ID")
|
||||||
|
display_name: str = Field(description="Display name of the session")
|
||||||
|
tool_type_name: str = Field(description="Name of the tool type")
|
||||||
|
tool_icon: str | None = Field(default=None, description="Icon URL for the tool type")
|
||||||
|
tool_type_interfaces: list[str] = Field(default_factory=list, description="Supported interfaces")
|
||||||
|
repository_name: str = Field(description="Name of the repository")
|
||||||
|
repository_id: str = Field(description="Repository ID")
|
||||||
|
project_name: str = Field(description="Name of the project")
|
||||||
|
project_id: str = Field(description="Project ID")
|
||||||
|
status: str = Field(description="Current status")
|
||||||
|
url: str | None = Field(default=None, description="Access URL")
|
||||||
|
|
||||||
|
|
||||||
|
class SessionListResponse(BaseModel):
|
||||||
|
"""Response wrapping a list of session summaries."""
|
||||||
|
|
||||||
|
sessions: list[SessionItemResponse]
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Tool type request/response schemas."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str | None = None
|
||||||
|
default_port: int
|
||||||
|
definition_type: str = "compose"
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
|
required_variables: list[str] = []
|
||||||
|
category: str = "other"
|
||||||
|
interfaces: list[str] = ["web"]
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str) -> str:
|
||||||
|
if v not in ("compose", "dockerfile"):
|
||||||
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("compose_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
if v is None:
|
||||||
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(v)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise ValueError(f"Invalid YAML: {e}")
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("Compose template must be a YAML mapping")
|
||||||
|
if "services" not in parsed:
|
||||||
|
raise ValueError("Compose template must contain 'services' key")
|
||||||
|
if not parsed["services"]:
|
||||||
|
raise ValueError("Compose template must define at least one service")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("dockerfile_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "dockerfile":
|
||||||
|
return v
|
||||||
|
if v is None:
|
||||||
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("default_port")
|
||||||
|
@classmethod
|
||||||
|
def validate_default_port(cls, v: int, info) -> int:
|
||||||
|
if v <= 0 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
template = data.get("compose_template")
|
||||||
|
if not template:
|
||||||
|
return v
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(template)
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return v
|
||||||
|
port_str = str(v)
|
||||||
|
port_exposed = False
|
||||||
|
if isinstance(parsed, dict) and "services" in parsed:
|
||||||
|
for service_config in parsed["services"].values():
|
||||||
|
if isinstance(service_config, dict) and "ports" in service_config:
|
||||||
|
for port_mapping in service_config["ports"]:
|
||||||
|
if isinstance(port_mapping, str) and port_str in port_mapping:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
elif isinstance(port_mapping, int) and port_mapping == v:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
if port_exposed:
|
||||||
|
break
|
||||||
|
if not port_exposed:
|
||||||
|
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("required_variables")
|
||||||
|
@classmethod
|
||||||
|
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
||||||
|
if not v:
|
||||||
|
return v
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
template = data.get("compose_template")
|
||||||
|
if not template:
|
||||||
|
return v
|
||||||
|
for var in v:
|
||||||
|
placeholder = f"{{{{{var}}}}}"
|
||||||
|
if placeholder not in template:
|
||||||
|
raise ValueError(f"Required variable '{var}' not found in compose template")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_templates(self) -> "ToolTypeCreate":
|
||||||
|
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
|
||||||
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
|
if self.definition_type == "compose" and self.compose_template is None:
|
||||||
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeUpdate(BaseModel):
|
||||||
|
display_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
default_port: int | None = None
|
||||||
|
definition_type: str | None = None
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
|
required_variables: list[str] | None = None
|
||||||
|
category: str | None = None
|
||||||
|
interfaces: list[str] | None = None
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v not in ("compose", "dockerfile"):
|
||||||
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("compose_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
data = info.data
|
||||||
|
definition_type = data.get("definition_type")
|
||||||
|
if definition_type and definition_type != "compose":
|
||||||
|
return v
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(v)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise ValueError(f"Invalid YAML: {e}")
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("Compose template must be a YAML mapping")
|
||||||
|
if "services" not in parsed:
|
||||||
|
raise ValueError("Compose template must contain 'services' key")
|
||||||
|
if not parsed["services"]:
|
||||||
|
raise ValueError("Compose template must define at least one service")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("dockerfile_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
data = info.data
|
||||||
|
definition_type = data.get("definition_type")
|
||||||
|
if definition_type and definition_type != "dockerfile":
|
||||||
|
return v
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str | None
|
||||||
|
category: str
|
||||||
|
interfaces: list[str]
|
||||||
|
default_port: int
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None
|
||||||
|
dockerfile_template: str | None
|
||||||
|
build_context: dict | None
|
||||||
|
readiness_probe: dict | None
|
||||||
|
required_variables: list[str]
|
||||||
|
is_builtin: bool
|
||||||
|
created_by_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeValidateRequest(BaseModel):
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""User request/response schemas."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
name: str
|
||||||
|
avatar_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""User config request/response schemas."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class UserConfigResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
default_editor: str | None = None
|
||||||
|
theme: str = "system"
|
||||||
|
git_user_name: str | None = None
|
||||||
|
git_user_email: str | None = None
|
||||||
|
last_session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserConfigUpdate(BaseModel):
|
||||||
|
default_editor: str | None = None
|
||||||
|
theme: str | None = None
|
||||||
|
git_user_name: str | None = None
|
||||||
|
git_user_email: str | None = None
|
||||||
|
last_session_id: str | None = None
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _table_exists(session, table_name: str) -> bool:
|
||||||
|
"""Check if a table exists in the database."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = :table_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"table_name": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() or False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_builtin_tool_types():
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
# Check if tool_types table exists before attempting to seed
|
||||||
|
if not await _table_exists(session, "tool_types"):
|
||||||
|
logger.warning(
|
||||||
|
"tool_types table does not exist. Skipping seeding. "
|
||||||
|
"Migrations may not have run yet."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
builtin_types = [
|
||||||
|
{
|
||||||
|
"name": "code-server",
|
||||||
|
"display_name": "VS Code Server",
|
||||||
|
"description": "VS Code running in the browser via code-server",
|
||||||
|
"category": "editor",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"compose_template": """version: "3.8"
|
||||||
|
services:
|
||||||
|
code-server:
|
||||||
|
image: lscr.io/linuxserver/code-server:latest
|
||||||
|
container_name: {{TOOL_NAME}}
|
||||||
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
|
- TZ=Europe/London
|
||||||
|
volumes:
|
||||||
|
- {{REPO_PATH}}:/config/workspace
|
||||||
|
ports:
|
||||||
|
- "8443:8443"
|
||||||
|
restart: unless-stopped""",
|
||||||
|
"default_port": 8443,
|
||||||
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "jupyter-notebook",
|
||||||
|
"display_name": "Jupyter Notebook",
|
||||||
|
"description": "Jupyter Lab for interactive development",
|
||||||
|
"category": "notebook",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"default_port": 8888,
|
||||||
|
"compose_template": """version: "3.8"
|
||||||
|
services:
|
||||||
|
jupyter:
|
||||||
|
image: jupyter/scipy-notebook:latest
|
||||||
|
container_name: {{TOOL_NAME}}
|
||||||
|
environment:
|
||||||
|
- JUPYTER_ENABLE_LAB=yes
|
||||||
|
volumes:
|
||||||
|
- {{REPO_PATH}}:/home/jovyan/work
|
||||||
|
ports:
|
||||||
|
- "8888:8888"
|
||||||
|
restart: unless-stopped""",
|
||||||
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "opencode",
|
||||||
|
"display_name": "OpenCode",
|
||||||
|
"description": "AI coding assistant - run opencode in terminal",
|
||||||
|
"category": "ai-assistant",
|
||||||
|
"interfaces": ["terminal"],
|
||||||
|
"default_port": 3000,
|
||||||
|
"compose_template": """version: "3.8"
|
||||||
|
services:
|
||||||
|
opencode:
|
||||||
|
image: node:20-slim
|
||||||
|
container_name: {{TOOL_NAME}}
|
||||||
|
working_dir: /workspace
|
||||||
|
environment:
|
||||||
|
- HOME=/tmp
|
||||||
|
volumes:
|
||||||
|
- {{REPO_PATH}}:/workspace
|
||||||
|
- opencode_home:/tmp
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
command: >
|
||||||
|
sh -c "set -x &&
|
||||||
|
apt-get update && apt-get install -y git ca-certificates &&
|
||||||
|
echo 'Installing opencode...' &&
|
||||||
|
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
||||||
|
which opencode || echo 'ERROR: opencode not in PATH' &&
|
||||||
|
npm bin -g &&
|
||||||
|
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
||||||
|
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
|
||||||
|
echo 'cd /workspace' >> /root/.bashrc &&
|
||||||
|
echo 'OpenCode installation complete' &&
|
||||||
|
cd /workspace &&
|
||||||
|
exec tail -f /dev/null"
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
opencode_home:""",
|
||||||
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for tool_data in builtin_types:
|
||||||
|
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
||||||
|
if not existing:
|
||||||
|
tool_type = ToolType(
|
||||||
|
name=tool_data["name"],
|
||||||
|
display_name=tool_data["display_name"],
|
||||||
|
description=tool_data["description"],
|
||||||
|
category=tool_data["category"],
|
||||||
|
interfaces=tool_data["interfaces"],
|
||||||
|
definition_type="compose",
|
||||||
|
compose_template=tool_data["compose_template"],
|
||||||
|
required_variables=tool_data["required_variables"],
|
||||||
|
default_port=tool_data.get("default_port"),
|
||||||
|
is_builtin=True,
|
||||||
|
)
|
||||||
|
session.add(tool_type)
|
||||||
|
logger.info("Created built-in tool type: %s", tool_data["name"])
|
||||||
|
else:
|
||||||
|
# Update existing built-in tool types to reflect code changes
|
||||||
|
existing.display_name = tool_data["display_name"]
|
||||||
|
existing.description = tool_data["description"]
|
||||||
|
existing.category = tool_data["category"]
|
||||||
|
existing.interfaces = tool_data["interfaces"]
|
||||||
|
existing.definition_type = "compose"
|
||||||
|
existing.compose_template = tool_data["compose_template"]
|
||||||
|
existing.required_variables = tool_data["required_variables"]
|
||||||
|
existing.default_port = tool_data.get("default_port")
|
||||||
|
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
logger.info("Built-in tool types seeded successfully.")
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Config profile business logic."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.models.config_include import ConfigInclude
|
||||||
|
from src.models.config_mount import ConfigMount
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_INCLUDES_DEPTH = 10
|
||||||
|
|
||||||
|
|
||||||
|
async def get_owned_profile(
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> ConfigProfile:
|
||||||
|
"""Fetch a config profile and verify ownership."""
|
||||||
|
profile = await session.get(ConfigProfile, profile_id)
|
||||||
|
if profile is None or profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="config profile not found",
|
||||||
|
)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
async def _detect_cycle(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
visited: set[uuid.UUID] | None = None,
|
||||||
|
depth: int = 0,
|
||||||
|
) -> bool:
|
||||||
|
"""Detect cycles in profile includes using DFS.
|
||||||
|
|
||||||
|
Returns True if a cycle is detected.
|
||||||
|
"""
|
||||||
|
if depth > MAX_INCLUDES_DEPTH:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if visited is None:
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
if profile_id in visited:
|
||||||
|
return True
|
||||||
|
|
||||||
|
visited.add(profile_id)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigInclude.included_profile_id).where(
|
||||||
|
ConfigInclude.profile_id == profile_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
included_ids = result.scalars().all()
|
||||||
|
|
||||||
|
for included_id in included_ids:
|
||||||
|
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_includes_no_cycle(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
new_included_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Validate that adding an include wouldn't create a cycle."""
|
||||||
|
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="adding this include would create a circular reference",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Profile CRUD helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def check_duplicate_name(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
name: str,
|
||||||
|
exclude_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Raise 409 if a profile with the given name already exists."""
|
||||||
|
query = select(ConfigProfile).where(
|
||||||
|
ConfigProfile.user_id == user_id,
|
||||||
|
ConfigProfile.name == name,
|
||||||
|
)
|
||||||
|
if exclude_id:
|
||||||
|
query = query.where(ConfigProfile.id != exclude_id)
|
||||||
|
existing = await session.scalar(query)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"config profile with name '{name}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_to_dict(profile: ConfigProfile) -> dict:
|
||||||
|
"""Serialize a ConfigProfile to a dict."""
|
||||||
|
return {
|
||||||
|
"id": str(profile.id),
|
||||||
|
"user_id": str(profile.user_id),
|
||||||
|
"name": profile.name,
|
||||||
|
"description": profile.description,
|
||||||
|
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||||
|
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Include helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def check_duplicate_include(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
included_profile_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
"""Raise 409 if the include already exists."""
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ConfigInclude).where(
|
||||||
|
ConfigInclude.profile_id == profile_id,
|
||||||
|
ConfigInclude.included_profile_id == included_profile_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="this include already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def include_to_dict(inc: ConfigInclude, included_name: str | None) -> dict:
|
||||||
|
"""Serialize a ConfigInclude to a dict."""
|
||||||
|
return {
|
||||||
|
"id": str(inc.id),
|
||||||
|
"profile_id": str(inc.profile_id),
|
||||||
|
"included_profile_id": str(inc.included_profile_id),
|
||||||
|
"included_profile_name": included_name,
|
||||||
|
"order_index": inc.order_index,
|
||||||
|
"created_at": inc.created_at.isoformat() if inc.created_at else None,
|
||||||
|
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mount helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def check_duplicate_mount_path(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
target_path: str,
|
||||||
|
exclude_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Raise 409 if a mount with the given path already exists."""
|
||||||
|
query = select(ConfigMount).where(
|
||||||
|
ConfigMount.profile_id == profile_id,
|
||||||
|
ConfigMount.target_path == target_path,
|
||||||
|
)
|
||||||
|
if exclude_id:
|
||||||
|
query = query.where(ConfigMount.id != exclude_id)
|
||||||
|
existing = await session.scalar(query)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"mount with path '{target_path}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mount_to_dict(mount: ConfigMount) -> dict:
|
||||||
|
"""Serialize a ConfigMount to a dict."""
|
||||||
|
return {
|
||||||
|
"id": str(mount.id),
|
||||||
|
"profile_id": str(mount.profile_id),
|
||||||
|
"target_path": mount.target_path,
|
||||||
|
"files": mount.files,
|
||||||
|
"mode": mount.mode,
|
||||||
|
"order_index": mount.order_index,
|
||||||
|
"created_at": mount.created_at.isoformat() if mount.created_at else None,
|
||||||
|
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default profile helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_or_create_user_config(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> UserConfig:
|
||||||
|
"""Get existing user config or create a new one."""
|
||||||
|
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
if user_config is None:
|
||||||
|
user_config = UserConfig(user_id=user_id, config={})
|
||||||
|
session.add(user_config)
|
||||||
|
return user_config
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_default_profiles(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
default_profiles: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""Validate that all profile IDs in default_profiles belong to the user."""
|
||||||
|
for tool_type_id, profile_id_str in default_profiles.items():
|
||||||
|
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found")
|
||||||
|
if profile.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_default_profiles(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> dict:
|
||||||
|
"""Get default profiles for a user."""
|
||||||
|
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
return {"default_profiles": user_config.default_profiles if user_config else {}}
|
||||||
|
|
||||||
|
|
||||||
|
async def set_default_profiles(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
default_profiles: dict[str, str],
|
||||||
|
) -> dict:
|
||||||
|
"""Set default profiles for a user."""
|
||||||
|
user_config = await get_or_create_user_config(session, user_id)
|
||||||
|
await validate_default_profiles(session, user_id, default_profiles)
|
||||||
|
user_config.config = {**user_config.config, "default_profiles": default_profiles}
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user_config)
|
||||||
|
return {"default_profiles": user_config.default_profiles}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_default_profile_for_tool_type(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
tool_type_id: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Get default profile for a specific tool type."""
|
||||||
|
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||||
|
user_config = result.scalar_one_or_none()
|
||||||
|
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
|
||||||
|
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Include list helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_includes_for_profile(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
) -> dict:
|
||||||
|
"""List all includes for a profile."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigInclude)
|
||||||
|
.where(ConfigInclude.profile_id == profile_id)
|
||||||
|
.order_by(ConfigInclude.order_index)
|
||||||
|
)
|
||||||
|
includes_data = []
|
||||||
|
for inc in result.scalars().all():
|
||||||
|
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
||||||
|
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
|
||||||
|
return {"includes": includes_data}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mount list helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_mounts_for_profile(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
) -> dict:
|
||||||
|
"""List all mounts for a profile."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigMount)
|
||||||
|
.where(ConfigMount.profile_id == profile_id)
|
||||||
|
.order_by(ConfigMount.order_index)
|
||||||
|
)
|
||||||
|
return {"mounts": [mount_to_dict(m) for m in result.scalars().all()]}
|
||||||
@@ -1,456 +0,0 @@
|
|||||||
"""Docker service for managing tool instances."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
|
||||||
"""Render a Docker Compose template with variable substitution.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template: The compose template string
|
|
||||||
variables: Dictionary of variable names to values
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered compose file content
|
|
||||||
"""
|
|
||||||
result = template
|
|
||||||
for key, value in variables.items():
|
|
||||||
placeholder = f"{{{{{key}}}}}"
|
|
||||||
result = result.replace(placeholder, str(value))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
|
||||||
"""Create and return the instance directory path.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_id: Unique instance identifier
|
|
||||||
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Absolute path to instance directory
|
|
||||||
"""
|
|
||||||
if base_path is None:
|
|
||||||
from src.config import Settings
|
|
||||||
base_path = Settings().instance_base_path
|
|
||||||
instance_dir = Path(base_path) / instance_id
|
|
||||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return str(instance_dir.absolute())
|
|
||||||
|
|
||||||
|
|
||||||
def write_compose_file(instance_dir: str, content: str) -> str:
|
|
||||||
"""Write the rendered compose file to the instance directory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_dir: Path to instance directory
|
|
||||||
content: Rendered compose content
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the compose file
|
|
||||||
"""
|
|
||||||
compose_path = Path(instance_dir) / "docker-compose.yml"
|
|
||||||
compose_path.write_text(content)
|
|
||||||
return str(compose_path)
|
|
||||||
|
|
||||||
|
|
||||||
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
|
||||||
"""Write environment variables to a .env file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_dir: Path to instance directory
|
|
||||||
env_vars: Dictionary of env var names to values
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the env file
|
|
||||||
"""
|
|
||||||
env_path = Path(instance_dir) / ".env"
|
|
||||||
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
|
||||||
env_path.write_text("\n".join(lines) + "\n")
|
|
||||||
return str(env_path)
|
|
||||||
|
|
||||||
|
|
||||||
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
|
||||||
"""Write config files to the instance directory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_dir: Path to instance directory
|
|
||||||
files: Dictionary of file paths (relative to instance dir) to content
|
|
||||||
"""
|
|
||||||
instance_path = Path(instance_dir)
|
|
||||||
for file_path, content in files.items():
|
|
||||||
# Ensure the path is within the instance directory (security)
|
|
||||||
full_path = instance_path / file_path
|
|
||||||
try:
|
|
||||||
full_path.resolve().relative_to(instance_path.resolve())
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
|
||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
|
|
||||||
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
|
||||||
"""Write config folder files to the instance directory and return volume mounts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_dir: Path to instance directory
|
|
||||||
folders: List of ConfigFolder objects
|
|
||||||
project_id: Optional project ID for applying overrides
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
|
||||||
"""
|
|
||||||
instance_path = Path(instance_dir)
|
|
||||||
volume_mounts = []
|
|
||||||
|
|
||||||
for folder in folders:
|
|
||||||
# Determine mount path (with project override if applicable)
|
|
||||||
mount_path = folder.mount_path
|
|
||||||
files = folder.files.copy()
|
|
||||||
|
|
||||||
if project_id and folder.project_overrides:
|
|
||||||
override = folder.project_overrides.get(str(project_id))
|
|
||||||
if override:
|
|
||||||
if override.get("mount_path"):
|
|
||||||
mount_path = override["mount_path"]
|
|
||||||
if override.get("files"):
|
|
||||||
files.update(override["files"])
|
|
||||||
|
|
||||||
# Write files to instance directory
|
|
||||||
folder_dir = instance_path / "volumes" / folder.name
|
|
||||||
folder_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for file_path, content in files.items():
|
|
||||||
# Security: ensure path doesn't escape folder_dir
|
|
||||||
full_path = folder_dir / file_path
|
|
||||||
try:
|
|
||||||
full_path.resolve().relative_to(folder_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Config folder file path escapes directory: %s", file_path)
|
|
||||||
continue
|
|
||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
# Add volume mount
|
|
||||||
volume_mounts.append({
|
|
||||||
"source": str(folder_dir),
|
|
||||||
"target": mount_path,
|
|
||||||
"type": "bind",
|
|
||||||
})
|
|
||||||
|
|
||||||
return volume_mounts
|
|
||||||
|
|
||||||
|
|
||||||
def execute_compose_command(
|
|
||||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
|
||||||
) -> tuple[int, str, str]:
|
|
||||||
"""Execute a docker compose command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
compose_path: Path to docker-compose.yml
|
|
||||||
action: The compose action (up, down, start, stop, restart)
|
|
||||||
timeout: Command timeout in seconds
|
|
||||||
env_file: Optional path to .env file for environment variables
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (returncode, stdout, stderr)
|
|
||||||
"""
|
|
||||||
instance_dir = Path(compose_path).parent
|
|
||||||
|
|
||||||
cmd = ["docker", "compose", "-f", compose_path]
|
|
||||||
|
|
||||||
if env_file:
|
|
||||||
cmd.extend(["--env-file", env_file])
|
|
||||||
|
|
||||||
if action == "up":
|
|
||||||
cmd.extend(["up", "-d"])
|
|
||||||
elif action == "down":
|
|
||||||
cmd.extend(["down", "-v"])
|
|
||||||
elif action in ("start", "stop", "restart"):
|
|
||||||
cmd.append(action)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown compose action: {action}")
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
cwd=str(instance_dir),
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.returncode, result.stdout, result.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def get_container_id(instance_name: str) -> str | None:
|
|
||||||
"""Get the container ID for a compose service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_name: The service name in compose
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container ID or None if not found
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
return result.stdout.strip().split("\n")[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_container_name(instance_name: str) -> str | None:
|
|
||||||
"""Get the full container name for a compose service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_name: The service name in compose
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container name or None if not found
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
return result.stdout.strip().split("\n")[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
|
||||||
"""Connect a Docker container to an existing network.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_name: Name or ID of the container
|
|
||||||
network_name: Name of the Docker network (default: backend)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "network", "connect", network_name, container_name],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def get_container_status(container_id: str) -> str:
|
|
||||||
"""Get the status of a Docker container.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_id: Docker container ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container status string (running, exited, etc.)
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
return result.stdout.strip()
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
|
||||||
"""Get the logs of a Docker container.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_id: Docker container ID
|
|
||||||
tail: Number of lines to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container logs
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "logs", "--tail", str(tail), container_id],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
return result.stdout
|
|
||||||
return f"Failed to get logs: {result.stderr}"
|
|
||||||
|
|
||||||
|
|
||||||
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
|
||||||
"""Find a free TCP port in the given range.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start: Start of port range
|
|
||||||
end: End of port range
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Free port number
|
|
||||||
"""
|
|
||||||
import socket
|
|
||||||
|
|
||||||
for port in range(start, end):
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
||||||
if s.connect_ex(("localhost", port)) != 0:
|
|
||||||
return port
|
|
||||||
|
|
||||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
|
||||||
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def start_cloudflared_tunnel(
|
|
||||||
container_name: str, port: int, timeout: int = 30
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Start a temporary Cloudflare tunnel for a container.
|
|
||||||
|
|
||||||
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
|
||||||
with a random trycloudflare.com URL.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_name: Name of the Docker container to tunnel to
|
|
||||||
port: Port number the container listens on
|
|
||||||
timeout: Maximum seconds to wait for tunnel URL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import re
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# First verify the container is accessible
|
|
||||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
|
||||||
for attempt in range(10):
|
|
||||||
check = subprocess.run(
|
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
|
||||||
f"http://{container_name}:{port}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
|
||||||
if check.returncode == 0:
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
else:
|
|
||||||
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
|
||||||
|
|
||||||
# Run cloudflared in background, capture output
|
|
||||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for the URL to appear in output
|
|
||||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
||||||
start_time = time.time()
|
|
||||||
url = None
|
|
||||||
|
|
||||||
while time.time() - start_time < timeout:
|
|
||||||
# Read available output
|
|
||||||
import select
|
|
||||||
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
|
||||||
if readable:
|
|
||||||
line = proc.stdout.readline()
|
|
||||||
if line:
|
|
||||||
match = url_pattern.search(line)
|
|
||||||
if match:
|
|
||||||
url = match.group(0)
|
|
||||||
break
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
proc.terminate()
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to get tunnel URL within {timeout}s. "
|
|
||||||
f"cloudflared output may contain errors."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"url": url, "pid": str(proc.pid)}
|
|
||||||
|
|
||||||
|
|
||||||
def stop_cloudflared_tunnel(pid: str) -> None:
|
|
||||||
"""Stop a cloudflared tunnel process.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pid: Process ID of the cloudflared tunnel
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
|
|
||||||
try:
|
|
||||||
os.kill(int(pid), signal.SIGTERM)
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass # Already stopped
|
|
||||||
|
|
||||||
|
|
||||||
def recreate_tunnel(
|
|
||||||
container_name: str, port: int, old_pid: str | None = None
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Recreate a temporary Cloudflare tunnel.
|
|
||||||
|
|
||||||
Stops the old tunnel (if pid provided) and starts a new one.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_name: Name of the Docker container to tunnel to
|
|
||||||
port: Port number the container listens on
|
|
||||||
old_pid: Optional PID of the old tunnel process to stop
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'url' and 'pid' for the new tunnel
|
|
||||||
"""
|
|
||||||
if old_pid:
|
|
||||||
stop_cloudflared_tunnel(old_pid)
|
|
||||||
|
|
||||||
return start_cloudflared_tunnel(container_name, port)
|
|
||||||
|
|
||||||
|
|
||||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|
||||||
"""Check if a tunnel URL is healthy.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: The tunnel URL to check
|
|
||||||
timeout: Request timeout in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
|
||||||
"--max-time", str(timeout), url],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout + 5,
|
|
||||||
)
|
|
||||||
status_code = int(result.stdout.strip())
|
|
||||||
return {
|
|
||||||
"healthy": 200 <= status_code < 400,
|
|
||||||
"status_code": status_code,
|
|
||||||
}
|
|
||||||
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
|
||||||
return {
|
|
||||||
"healthy": False,
|
|
||||||
"status_code": None,
|
|
||||||
"error": str(e),
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Docker services for container and tunnel management."""
|
||||||
|
|
||||||
|
from .compose import (
|
||||||
|
ensure_instance_directory,
|
||||||
|
execute_compose_command,
|
||||||
|
render_compose_template,
|
||||||
|
write_compose_file,
|
||||||
|
write_env_file,
|
||||||
|
)
|
||||||
|
from .config_staging import write_config_files, write_config_folder_files
|
||||||
|
from .container import (
|
||||||
|
connect_container_to_network,
|
||||||
|
find_free_port,
|
||||||
|
get_container_id,
|
||||||
|
get_container_logs,
|
||||||
|
get_container_name,
|
||||||
|
get_container_status,
|
||||||
|
)
|
||||||
|
from .tunnel import (
|
||||||
|
check_tunnel_health,
|
||||||
|
recreate_tunnel,
|
||||||
|
start_cloudflared_tunnel,
|
||||||
|
stop_cloudflared_tunnel,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"render_compose_template",
|
||||||
|
"ensure_instance_directory",
|
||||||
|
"write_compose_file",
|
||||||
|
"write_env_file",
|
||||||
|
"execute_compose_command",
|
||||||
|
"write_config_files",
|
||||||
|
"write_config_folder_files",
|
||||||
|
"get_container_id",
|
||||||
|
"get_container_name",
|
||||||
|
"connect_container_to_network",
|
||||||
|
"get_container_status",
|
||||||
|
"get_container_logs",
|
||||||
|
"find_free_port",
|
||||||
|
"start_cloudflared_tunnel",
|
||||||
|
"stop_cloudflared_tunnel",
|
||||||
|
"recreate_tunnel",
|
||||||
|
"check_tunnel_health",
|
||||||
|
]
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Docker Compose file generation and command execution."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.services.profile_resolver import resolve_profile
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_name(name: str) -> str:
|
||||||
|
"""Sanitize a string for use in Docker/container names."""
|
||||||
|
sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower())
|
||||||
|
sanitized = re.sub(r"-+", "-", sanitized)
|
||||||
|
return sanitized.strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_instance_name(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_name: str,
|
||||||
|
tool_type_name: str,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a unique instance name: project-tool-NUM."""
|
||||||
|
base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}"
|
||||||
|
base = base.strip("-") or "instance"
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%"))
|
||||||
|
)
|
||||||
|
names = result.scalars().all()
|
||||||
|
max_num = 0
|
||||||
|
for name in names:
|
||||||
|
parts = name.rsplit("-", 1)
|
||||||
|
if len(parts) == 2 and parts[0] == base and parts[1].isdigit():
|
||||||
|
max_num = max(max_num, int(parts[1]))
|
||||||
|
return f"{base}-{max_num + 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _modify_compose_file(
|
||||||
|
compose_path: str,
|
||||||
|
port_override: int | None = None,
|
||||||
|
start_command: str | None = None,
|
||||||
|
working_directory: str | None = None,
|
||||||
|
extra_volumes: list[dict] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Modify compose file with runtime overrides."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
compose_file = Path(compose_path)
|
||||||
|
content = compose_file.read_text()
|
||||||
|
compose_data = yaml.safe_load(content)
|
||||||
|
|
||||||
|
if not compose_data or "services" not in compose_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
for service_name, service_config in compose_data["services"].items():
|
||||||
|
if port_override and "ports" in service_config:
|
||||||
|
for i, port_mapping in enumerate(service_config["ports"]):
|
||||||
|
if isinstance(port_mapping, str) and ":" in port_mapping:
|
||||||
|
_host_port, container_port = port_mapping.split(":", 1)
|
||||||
|
service_config["ports"][i] = f"{port_override}:{container_port}"
|
||||||
|
break
|
||||||
|
|
||||||
|
if start_command:
|
||||||
|
service_config["command"] = start_command
|
||||||
|
|
||||||
|
if working_directory:
|
||||||
|
service_config["working_dir"] = working_directory
|
||||||
|
|
||||||
|
if extra_volumes:
|
||||||
|
if "volumes" not in service_config:
|
||||||
|
service_config["volumes"] = []
|
||||||
|
for vol in extra_volumes:
|
||||||
|
source = vol.get("source", "")
|
||||||
|
target = vol.get("target", "")
|
||||||
|
vol_type = vol.get("type", "bind")
|
||||||
|
if vol_type == "bind":
|
||||||
|
service_config["volumes"].append(f"{source}:{target}")
|
||||||
|
else:
|
||||||
|
service_config["volumes"].append(f"{source}:{target}:{vol_type}")
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_resolved_profile(
|
||||||
|
profile: ConfigProfile,
|
||||||
|
instance_dir: str,
|
||||||
|
env_vars: dict[str, str],
|
||||||
|
port_override: int | None,
|
||||||
|
start_command: str | None,
|
||||||
|
working_directory: str | None,
|
||||||
|
extra_volumes: list[dict],
|
||||||
|
) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]:
|
||||||
|
"""Resolve a profile and apply its output to instance configuration."""
|
||||||
|
resolved = resolve_profile(profile)
|
||||||
|
|
||||||
|
if resolved.environment_variables:
|
||||||
|
env_vars.update(resolved.environment_variables)
|
||||||
|
|
||||||
|
if resolved.runtime_hints.start_command is not None:
|
||||||
|
start_command = resolved.runtime_hints.start_command
|
||||||
|
if resolved.runtime_hints.working_directory is not None:
|
||||||
|
working_directory = resolved.runtime_hints.working_directory
|
||||||
|
if resolved.runtime_hints.port is not None:
|
||||||
|
port_override = resolved.runtime_hints.port
|
||||||
|
|
||||||
|
for target_path, mount in resolved.mounts.items():
|
||||||
|
safe_name = target_path.strip("/").replace("/", "_")
|
||||||
|
mount_dir = Path(instance_dir) / "mounts" / safe_name
|
||||||
|
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for rel_path, content in mount.files.items():
|
||||||
|
file_path = mount_dir / rel_path
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path.write_text(content)
|
||||||
|
|
||||||
|
extra_volumes.append({
|
||||||
|
"source": str(mount_dir),
|
||||||
|
"target": target_path,
|
||||||
|
"type": mount.mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return env_vars, port_override, start_command, working_directory, extra_volumes
|
||||||
|
|
||||||
|
|
||||||
|
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||||
|
"""Render a Docker Compose template with variable substitution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: The compose template string
|
||||||
|
variables: Dictionary of variable names to values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered compose file content
|
||||||
|
"""
|
||||||
|
result = template
|
||||||
|
for key, value in variables.items():
|
||||||
|
placeholder = f"{{{{{key}}}}}"
|
||||||
|
result = result.replace(placeholder, str(value))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
||||||
|
"""Create and return the instance directory path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: Unique instance identifier
|
||||||
|
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to instance directory
|
||||||
|
"""
|
||||||
|
if base_path is None:
|
||||||
|
from src.config import Settings
|
||||||
|
base_path = Settings().instance_base_path
|
||||||
|
instance_dir = Path(base_path) / instance_id
|
||||||
|
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(instance_dir.absolute())
|
||||||
|
|
||||||
|
|
||||||
|
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||||
|
"""Write the rendered compose file to the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
content: Rendered compose content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the compose file
|
||||||
|
"""
|
||||||
|
compose_path = Path(instance_dir) / "docker-compose.yml"
|
||||||
|
compose_path.write_text(content)
|
||||||
|
return str(compose_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||||
|
"""Write environment variables to a .env file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
env_vars: Dictionary of env var names to values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the env file
|
||||||
|
"""
|
||||||
|
env_path = Path(instance_dir) / ".env"
|
||||||
|
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||||
|
env_path.write_text("\n".join(lines) + "\n")
|
||||||
|
return str(env_path)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_compose_command(
|
||||||
|
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
"""Execute a docker compose command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
compose_path: Path to docker-compose.yml
|
||||||
|
action: The compose action (up, down, start, stop, restart)
|
||||||
|
timeout: Command timeout in seconds
|
||||||
|
env_file: Optional path to .env file for environment variables
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (returncode, stdout, stderr)
|
||||||
|
"""
|
||||||
|
instance_dir = Path(compose_path).parent
|
||||||
|
|
||||||
|
cmd = ["docker", "compose", "-f", compose_path]
|
||||||
|
|
||||||
|
if env_file:
|
||||||
|
cmd.extend(["--env-file", env_file])
|
||||||
|
|
||||||
|
if action == "up":
|
||||||
|
cmd.extend(["up", "-d"])
|
||||||
|
elif action == "down":
|
||||||
|
cmd.extend(["down", "-v"])
|
||||||
|
elif action in ("start", "stop", "restart"):
|
||||||
|
cmd.append(action)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown compose action: {action}")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(instance_dir),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Config folder file staging for Docker instances."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||||
|
"""Write config files to the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
files: Dictionary of file paths (relative to instance dir) to content
|
||||||
|
"""
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
for file_path, content in files.items():
|
||||||
|
# Ensure the path is within the instance directory (security)
|
||||||
|
full_path = instance_path / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||||
|
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
||||||
|
"""Write config folder files to the instance directory and return volume mounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
folders: List of ConfigFolder objects
|
||||||
|
project_id: Optional project ID for applying overrides
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
||||||
|
"""
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
volume_mounts = []
|
||||||
|
|
||||||
|
for folder in folders:
|
||||||
|
# Determine mount path (with project override if applicable)
|
||||||
|
mount_path = folder.mount_path
|
||||||
|
files = folder.files.copy()
|
||||||
|
|
||||||
|
if project_id and folder.project_overrides:
|
||||||
|
override = folder.project_overrides.get(str(project_id))
|
||||||
|
if override:
|
||||||
|
if override.get("mount_path"):
|
||||||
|
mount_path = override["mount_path"]
|
||||||
|
if override.get("files"):
|
||||||
|
files.update(override["files"])
|
||||||
|
|
||||||
|
# Write files to instance directory
|
||||||
|
folder_dir = instance_path / "volumes" / folder.name
|
||||||
|
folder_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for file_path, content in files.items():
|
||||||
|
# Security: ensure path doesn't escape folder_dir
|
||||||
|
full_path = folder_dir / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(folder_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Config folder file path escapes directory: %s", file_path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
# Add volume mount
|
||||||
|
volume_mounts.append({
|
||||||
|
"source": str(folder_dir),
|
||||||
|
"target": mount_path,
|
||||||
|
"type": "bind",
|
||||||
|
})
|
||||||
|
|
||||||
|
return volume_mounts
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Docker container lifecycle and query operations."""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_id(instance_name: str) -> str | None:
|
||||||
|
"""Get the container ID for a compose service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The service name in compose
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container ID or None if not found
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip().split("\n")[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_name(instance_name: str) -> str | None:
|
||||||
|
"""Get the full container name for a compose service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The service name in compose
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container name or None if not found
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip().split("\n")[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
||||||
|
"""Connect a Docker container to an existing network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name or ID of the container
|
||||||
|
network_name: Name of the Docker network (default: backend)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "network", "connect", network_name, container_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_status(container_id: str) -> str:
|
||||||
|
"""Get the status of a Docker container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container status string (running, exited, etc.)
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||||
|
"""Get the logs of a Docker container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
tail: Number of lines to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container logs
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "logs", "--tail", str(tail), container_id],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout
|
||||||
|
return f"Failed to get logs: {result.stderr}"
|
||||||
|
|
||||||
|
|
||||||
|
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||||
|
"""Find a free TCP port in the given range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start: Start of port range
|
||||||
|
end: End of port range
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Free port number
|
||||||
|
"""
|
||||||
|
for port in range(start, end):
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
if s.connect_ex(("localhost", port)) != 0:
|
||||||
|
return port
|
||||||
|
|
||||||
|
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Cloudflare tunnel management for Docker instances."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def start_cloudflared_tunnel(
|
||||||
|
container_name: str, port: int, timeout: int = 30
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Start a temporary Cloudflare tunnel for a container.
|
||||||
|
|
||||||
|
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
||||||
|
with a random trycloudflare.com URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name of the Docker container to tunnel to
|
||||||
|
port: Port number the container listens on
|
||||||
|
timeout: Maximum seconds to wait for tunnel URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||||
|
"""
|
||||||
|
import select as sel
|
||||||
|
|
||||||
|
# First verify the container is accessible
|
||||||
|
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||||
|
for attempt in range(10):
|
||||||
|
check = subprocess.run(
|
||||||
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
f"http://{container_name}:{port}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
||||||
|
if check.returncode == 0:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
||||||
|
|
||||||
|
# Run cloudflared in background, capture output
|
||||||
|
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for the URL to appear in output
|
||||||
|
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||||
|
start_time = time.time()
|
||||||
|
url = None
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
# Read available output
|
||||||
|
readable, _, _ = sel.select([proc.stdout], [], [], 1.0)
|
||||||
|
if readable:
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if line:
|
||||||
|
match = url_pattern.search(line)
|
||||||
|
if match:
|
||||||
|
url = match.group(0)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to get tunnel URL within {timeout}s. "
|
||||||
|
f"cloudflared output may contain errors."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"url": url, "pid": str(proc.pid)}
|
||||||
|
|
||||||
|
|
||||||
|
def stop_cloudflared_tunnel(pid: str) -> None:
|
||||||
|
"""Stop a cloudflared tunnel process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid: Process ID of the cloudflared tunnel
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
os.kill(int(pid), signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass # Already stopped
|
||||||
|
|
||||||
|
|
||||||
|
def recreate_tunnel(
|
||||||
|
container_name: str, port: int, old_pid: str | None = None
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Recreate a temporary Cloudflare tunnel.
|
||||||
|
|
||||||
|
Stops the old tunnel (if pid provided) and starts a new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name of the Docker container to tunnel to
|
||||||
|
port: Port number the container listens on
|
||||||
|
old_pid: Optional PID of the old tunnel process to stop
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' and 'pid' for the new tunnel
|
||||||
|
"""
|
||||||
|
if old_pid:
|
||||||
|
stop_cloudflared_tunnel(old_pid)
|
||||||
|
|
||||||
|
return start_cloudflared_tunnel(container_name, port)
|
||||||
|
|
||||||
|
|
||||||
|
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||||
|
"""Check if a tunnel URL is healthy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The tunnel URL to check
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
"--max-time", str(timeout), url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout + 5,
|
||||||
|
)
|
||||||
|
status_code = int(result.stdout.strip())
|
||||||
|
return {
|
||||||
|
"healthy": 200 <= status_code < 400,
|
||||||
|
"status_code": status_code,
|
||||||
|
}
|
||||||
|
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"status_code": None,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Git services package."""
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Git control operations with repo validation."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.user import User
|
||||||
|
from src.schemas.git_repository import (
|
||||||
|
BranchCreateRequest,
|
||||||
|
CheckoutRequest,
|
||||||
|
CommitRequest,
|
||||||
|
FetchResponse,
|
||||||
|
MergeRequest,
|
||||||
|
MergeResponse,
|
||||||
|
PullResponse,
|
||||||
|
PushResponse,
|
||||||
|
StatusResponse,
|
||||||
|
)
|
||||||
|
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
||||||
|
from src.utils.git_control import (
|
||||||
|
checkout_branch,
|
||||||
|
commit_changes,
|
||||||
|
create_branch,
|
||||||
|
delete_branch,
|
||||||
|
fetch,
|
||||||
|
get_status,
|
||||||
|
merge,
|
||||||
|
pull,
|
||||||
|
push,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_status_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
) -> StatusResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
result = get_status(repo.path)
|
||||||
|
return StatusResponse(
|
||||||
|
branch=result.branch,
|
||||||
|
modified=result.modified,
|
||||||
|
added=result.added,
|
||||||
|
deleted=result.deleted,
|
||||||
|
untracked=result.untracked,
|
||||||
|
renamed=result.renamed,
|
||||||
|
ahead=result.ahead,
|
||||||
|
behind=result.behind,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def create_branch_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: BranchCreateRequest,
|
||||||
|
) -> dict:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
create_branch(repo.path, data.name, data.base_branch)
|
||||||
|
return {"message": f"Branch '{data.name}' created", "branch": data.name}
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_branch_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
branch_name: str,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
delete_branch(repo.path, branch_name, force)
|
||||||
|
return {"message": f"Branch '{branch_name}' deleted"}
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def checkout_branch_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: CheckoutRequest,
|
||||||
|
) -> dict:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
checkout_branch(repo.path, data.branch)
|
||||||
|
return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch}
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def commit_changes_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: CommitRequest,
|
||||||
|
user: User,
|
||||||
|
) -> dict:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
author_name = user.name or "Unknown"
|
||||||
|
author_email = user.email or "unknown@example.com"
|
||||||
|
try:
|
||||||
|
commit_hash = commit_changes(
|
||||||
|
repo_path=repo.path,
|
||||||
|
message=data.message,
|
||||||
|
author_name=author_name,
|
||||||
|
author_email=author_email,
|
||||||
|
files=data.files,
|
||||||
|
)
|
||||||
|
return {"commit_hash": commit_hash, "message": data.message}
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
) -> FetchResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
fetch(repo.path)
|
||||||
|
return FetchResponse(message="Fetched from remote")
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def pull_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
branch: str | None = None,
|
||||||
|
) -> PullResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
pull(repo.path, branch)
|
||||||
|
return PullResponse(message="Pulled from remote")
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def push_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
branch: str | None = None,
|
||||||
|
) -> PushResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
push(repo.path, branch)
|
||||||
|
return PushResponse(message="Pushed to remote")
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def merge_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: MergeRequest,
|
||||||
|
) -> MergeResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
commit_hash = merge(
|
||||||
|
repo_path=repo.path,
|
||||||
|
source_branch=data.source_branch,
|
||||||
|
target_branch=data.target_branch,
|
||||||
|
message=data.message,
|
||||||
|
)
|
||||||
|
return MergeResponse(
|
||||||
|
commit_hash=commit_hash,
|
||||||
|
message=data.message or f"Merge {data.source_branch}",
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Git file operations with repo validation."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.user import User
|
||||||
|
from src.schemas.git_repository import (
|
||||||
|
FileContentResponse,
|
||||||
|
FileListResponse,
|
||||||
|
FileUpdateRequest,
|
||||||
|
FileUpdateResponse,
|
||||||
|
)
|
||||||
|
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
||||||
|
from src.utils.git_files import (
|
||||||
|
commit_file,
|
||||||
|
get_file_content,
|
||||||
|
list_branches,
|
||||||
|
list_tree,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_files(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
branch: str = "main",
|
||||||
|
path: str = "",
|
||||||
|
) -> FileListResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
entries = list_tree(repo.path, branch=branch, path=path)
|
||||||
|
return FileListResponse(
|
||||||
|
path=path,
|
||||||
|
branch=branch,
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"name": e.name,
|
||||||
|
"type": e.type,
|
||||||
|
"path": e.path,
|
||||||
|
"size": e.size,
|
||||||
|
"mode": e.mode,
|
||||||
|
"last_commit": e.last_commit,
|
||||||
|
}
|
||||||
|
for e in entries
|
||||||
|
],
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list files for repo %s (path=%s, branch=%s): %s",
|
||||||
|
repo_id,
|
||||||
|
path,
|
||||||
|
branch,
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_file(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
branch: str,
|
||||||
|
path: str,
|
||||||
|
) -> FileContentResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
file_content = get_file_content(repo.path, branch=branch, path=path)
|
||||||
|
return FileContentResponse(
|
||||||
|
path=file_content.path,
|
||||||
|
branch=file_content.branch,
|
||||||
|
content=file_content.content,
|
||||||
|
size=file_content.size,
|
||||||
|
encoding=file_content.encoding,
|
||||||
|
language=file_content.language,
|
||||||
|
is_binary=file_content.is_binary,
|
||||||
|
last_commit=file_content.last_commit,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def update_file(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: FileUpdateRequest,
|
||||||
|
user: User,
|
||||||
|
) -> FileUpdateResponse:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
author_name = user.name or "Unknown"
|
||||||
|
author_email = user.email or "unknown@example.com"
|
||||||
|
try:
|
||||||
|
commit_hash = commit_file(
|
||||||
|
repo_path=repo.path,
|
||||||
|
branch=data.branch,
|
||||||
|
path=data.path,
|
||||||
|
content=data.content,
|
||||||
|
commit_message=data.commit_message,
|
||||||
|
author_name=author_name,
|
||||||
|
author_email=author_email,
|
||||||
|
)
|
||||||
|
return FileUpdateResponse(
|
||||||
|
commit_hash=commit_hash,
|
||||||
|
message=data.commit_message,
|
||||||
|
branch=data.branch,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def list_branches_with_validation(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
) -> dict:
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
ensure_repo_on_disk(repo)
|
||||||
|
try:
|
||||||
|
branches, default_branch = list_branches(repo.path)
|
||||||
|
return {
|
||||||
|
"branches": [
|
||||||
|
{
|
||||||
|
"name": b.name,
|
||||||
|
"is_default": b.is_default,
|
||||||
|
"last_commit": b.last_commit,
|
||||||
|
}
|
||||||
|
for b in branches
|
||||||
|
],
|
||||||
|
"default_branch": default_branch,
|
||||||
|
}
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list branches for repo %s: %s",
|
||||||
|
repo_id,
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Repository lifecycle and path helpers."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.config import Settings
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.user import User
|
||||||
|
from src.schemas.git_repository import GitRepositoryCreate
|
||||||
|
from src.utils.git_url_parser import parse_git_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
||||||
|
"""Generate the filesystem path for a repository."""
|
||||||
|
base = Settings().repo_base_path or "/data/repos"
|
||||||
|
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_provider_clone_url(owner: str, repo: str) -> str:
|
||||||
|
"""Build the SSH clone URL for the fixed git provider."""
|
||||||
|
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_remote_repository(remote_url: str) -> None:
|
||||||
|
"""Verify a remote repository is reachable before cloning."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", remote_url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="repository not found or inaccessible",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "clone", remote_url, repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to clone repository: {result.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_working_repository(repo_path: str) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "init", "-b", "main", repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
fallback = subprocess.run(
|
||||||
|
["git", "init", repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if fallback.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to initialize repository: {fallback.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
ref_result = subprocess.run(
|
||||||
|
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if ref_result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to set initial branch: {ref_result.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_repo_and_validate(
|
||||||
|
session: AsyncSession,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Fetch a repository and validate ownership + disk presence."""
|
||||||
|
repo = await session.get(GitRepository, repo_id)
|
||||||
|
if repo is None or repo.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_repo_on_disk(repo: GitRepository) -> None:
|
||||||
|
"""Raise 404 if the repository is not present on disk."""
|
||||||
|
if not os.path.exists(repo.path):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_repository(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
data: GitRepositoryCreate,
|
||||||
|
user: User,
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Create a new git repository (clone or init)."""
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.execute(
|
||||||
|
select(GitRepository).where(
|
||||||
|
GitRepository.project_id == project_id,
|
||||||
|
GitRepository.name == data.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||||
|
|
||||||
|
# Validate and potentially correct the URL
|
||||||
|
remote_url = data.remote_url
|
||||||
|
if remote_url and not data.force_original_url:
|
||||||
|
parse_result = parse_git_url(remote_url)
|
||||||
|
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||||
|
"suggested_url": parse_result["base_url"],
|
||||||
|
"original_url": remote_url,
|
||||||
|
"error_code": "URL_NEEDS_PARSING",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if parse_result["base_url"]:
|
||||||
|
remote_url = parse_result["base_url"]
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
_preflight_remote_repository(remote_url)
|
||||||
|
|
||||||
|
repo_path = _get_repo_path(user.id, project_id, data.name)
|
||||||
|
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
_clone_working_repository(remote_url, repo_path)
|
||||||
|
else:
|
||||||
|
_init_working_repository(repo_path)
|
||||||
|
|
||||||
|
repo = GitRepository(
|
||||||
|
name=data.name,
|
||||||
|
path=repo_path,
|
||||||
|
project_id=project_id,
|
||||||
|
owner_id=user.id,
|
||||||
|
is_mirror=False,
|
||||||
|
remote_url=remote_url,
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_repository(
|
||||||
|
session: AsyncSession,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
"""Delete a repository from DB and disk."""
|
||||||
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
|
|
||||||
|
if os.path.exists(repo.path):
|
||||||
|
shutil.rmtree(repo.path)
|
||||||
|
|
||||||
|
await session.delete(repo)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_repositories(
|
||||||
|
session: AsyncSession,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
) -> list[GitRepository]:
|
||||||
|
"""List all repositories in a project."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
"""High-level tool instance lifecycle orchestration.
|
||||||
|
|
||||||
|
Coordinates Docker compose, container, tunnel, and config staging services
|
||||||
|
to create, start, stop, restart, and delete tool instances.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_folder import ConfigFolder
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_config import ToolConfig
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
from src.services.docker import compose as compose_svc
|
||||||
|
from src.services.docker import config_staging
|
||||||
|
from src.services.docker import container as container_svc
|
||||||
|
from src.services.docker import tunnel as tunnel_svc
|
||||||
|
from src.services.docker_build import build_image
|
||||||
|
from src.services.readiness_probe import execute_probe
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_new_instance(
|
||||||
|
session: AsyncSession,
|
||||||
|
project: Project,
|
||||||
|
repo: GitRepository,
|
||||||
|
tool_type: ToolType,
|
||||||
|
user: User,
|
||||||
|
display_name: str | None,
|
||||||
|
selected_profile: ConfigProfile | None,
|
||||||
|
) -> ToolInstance:
|
||||||
|
"""Create a new tool instance record and its compose file."""
|
||||||
|
instance_name = await compose_svc._generate_instance_name(
|
||||||
|
session, project.name, tool_type.name
|
||||||
|
)
|
||||||
|
instance_dir = compose_svc.ensure_instance_directory(instance_name)
|
||||||
|
tool_port = container_svc.find_free_port()
|
||||||
|
|
||||||
|
compose_path = await _build_or_render_compose(
|
||||||
|
tool_type, instance_name, instance_dir, repo, user, project.id, tool_port
|
||||||
|
)
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
name=instance_name,
|
||||||
|
display_name=display_name or f"{project.name} / {repo.name} / {tool_type.display_name}",
|
||||||
|
tool_type_id=tool_type.id,
|
||||||
|
repository_id=repo.id,
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user.id,
|
||||||
|
status="pending",
|
||||||
|
compose_path=compose_path,
|
||||||
|
port=tool_port,
|
||||||
|
selected_profile_id=selected_profile.id if selected_profile else None,
|
||||||
|
)
|
||||||
|
session.add(instance)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(instance)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
async def start_existing_instance(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
user: User,
|
||||||
|
project_id: Any,
|
||||||
|
) -> dict:
|
||||||
|
"""Start an existing instance: stage configs, compose up, probe, tunnel."""
|
||||||
|
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
instance.status = "building"
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
||||||
|
session, user.id, instance.tool_type_id, project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_profile = None
|
||||||
|
if instance.selected_profile_id:
|
||||||
|
selected_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||||
|
if selected_profile and selected_profile.user_id == user.id:
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
||||||
|
selected_profile,
|
||||||
|
instance_dir,
|
||||||
|
env_vars,
|
||||||
|
port_override,
|
||||||
|
start_command,
|
||||||
|
working_directory,
|
||||||
|
extra_volumes,
|
||||||
|
)
|
||||||
|
|
||||||
|
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
||||||
|
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
||||||
|
env_vars, config_files, extra_volumes
|
||||||
|
)
|
||||||
|
|
||||||
|
if port_override or start_command or working_directory or extra_volumes:
|
||||||
|
compose_svc._modify_compose_file(
|
||||||
|
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
||||||
|
)
|
||||||
|
|
||||||
|
returncode, _stdout, stderr = compose_svc.execute_compose_command(
|
||||||
|
instance.compose_path, "up", env_file=env_file_path
|
||||||
|
)
|
||||||
|
if returncode != 0:
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"failed to start instance: {stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
container_id = container_svc.get_container_id(instance.name)
|
||||||
|
if container_id:
|
||||||
|
instance.container_id = container_id
|
||||||
|
container_name = container_svc.get_container_name(instance.name)
|
||||||
|
if container_name:
|
||||||
|
instance.container_name = container_name
|
||||||
|
container_svc.connect_container_to_network(container_name, "backend")
|
||||||
|
|
||||||
|
instance.status = "starting"
|
||||||
|
instance.last_started_at = datetime.now()
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
success, probe_logs = await _run_readiness_probe(instance, tool_type)
|
||||||
|
if not success:
|
||||||
|
instance.status = "failed"
|
||||||
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"error": f"Readiness probe failed: {' '.join(probe_logs)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.status = "running"
|
||||||
|
await session.commit()
|
||||||
|
await _start_tunnel_if_web(instance, tool_type)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|
||||||
|
|
||||||
|
async def restart_existing_instance(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
user: User,
|
||||||
|
project_id: Any,
|
||||||
|
) -> dict:
|
||||||
|
"""Restart an instance: re-stage configs, compose restart, tunnel."""
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop old tunnel: %s", exc)
|
||||||
|
|
||||||
|
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
return {"status": instance.status}
|
||||||
|
|
||||||
|
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
||||||
|
session, user.id, instance.tool_type_id, project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
stored_profile = None
|
||||||
|
if instance.selected_profile_id:
|
||||||
|
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||||
|
if stored_profile and stored_profile.user_id == user.id:
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
||||||
|
stored_profile, instance_dir, env_vars, port_override, start_command, working_directory, extra_volumes
|
||||||
|
)
|
||||||
|
|
||||||
|
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
||||||
|
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
||||||
|
env_vars, config_files, extra_volumes
|
||||||
|
)
|
||||||
|
|
||||||
|
if port_override or start_command or working_directory or extra_volumes:
|
||||||
|
compose_svc._modify_compose_file(
|
||||||
|
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
||||||
|
)
|
||||||
|
|
||||||
|
returncode, _stdout, _stderr = compose_svc.execute_compose_command(
|
||||||
|
instance.compose_path, "restart", env_file=env_file_path
|
||||||
|
)
|
||||||
|
if returncode != 0:
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
return {"status": instance.status}
|
||||||
|
|
||||||
|
instance.status = "running"
|
||||||
|
instance.last_started_at = datetime.now()
|
||||||
|
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
await _start_tunnel_if_web(instance, tool_type)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
||||||
|
"""Stop an instance and its tunnel."""
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop tunnel: %s", exc)
|
||||||
|
|
||||||
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
|
compose_svc.execute_compose_command(instance.compose_path, "stop")
|
||||||
|
|
||||||
|
instance.status = "stopped"
|
||||||
|
instance.last_stopped_at = datetime.now()
|
||||||
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
instance.tunnel_id = None
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
||||||
|
"""Delete an instance, its containers, and its directory."""
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop tunnel: %s", exc)
|
||||||
|
|
||||||
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
|
compose_svc.execute_compose_command(instance.compose_path, "down")
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
if os.path.exists(instance_dir):
|
||||||
|
shutil.rmtree(instance_dir)
|
||||||
|
|
||||||
|
await session.delete(instance)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Internal helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _build_or_render_compose(
|
||||||
|
tool_type: ToolType,
|
||||||
|
instance_name: str,
|
||||||
|
instance_dir: str,
|
||||||
|
repo: GitRepository,
|
||||||
|
user: User,
|
||||||
|
project_id: Any,
|
||||||
|
tool_port: int,
|
||||||
|
) -> str:
|
||||||
|
"""Build Dockerfile or render compose template."""
|
||||||
|
if tool_type.definition_type == "dockerfile":
|
||||||
|
image_tag = f"headquarter/{instance_name}:latest"
|
||||||
|
if tool_type.dockerfile_template:
|
||||||
|
returncode, _stdout, stderr = build_image(
|
||||||
|
instance_dir=instance_dir,
|
||||||
|
dockerfile=tool_type.dockerfile_template,
|
||||||
|
tag=image_tag,
|
||||||
|
build_context=tool_type.build_context,
|
||||||
|
)
|
||||||
|
if returncode != 0:
|
||||||
|
logger.error("Build failed for %s: %s", instance_name, stderr)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to build Docker image: {stderr[:500]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
compose_content = (
|
||||||
|
f'version: "3.8"\nservices:\n app:\n'
|
||||||
|
f' image: {image_tag}\n'
|
||||||
|
f' container_name: {instance_name}\n'
|
||||||
|
f' ports:\n - "{tool_port}:{tool_type.default_port}"\n'
|
||||||
|
f' volumes:\n - {repo.path}:/workspace\n'
|
||||||
|
f' restart: unless-stopped\n'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
variables = {
|
||||||
|
"REPO_PATH": repo.path,
|
||||||
|
"INSTANCE_NAME": instance_name,
|
||||||
|
"INSTANCE_ID": instance_name,
|
||||||
|
"TOOL_NAME": instance_name,
|
||||||
|
"TOOL_PORT": tool_port,
|
||||||
|
"USER_ID": str(user.id),
|
||||||
|
"PROJECT_ID": str(project_id),
|
||||||
|
}
|
||||||
|
compose_content = compose_svc.render_compose_template(
|
||||||
|
tool_type.compose_template, variables
|
||||||
|
)
|
||||||
|
|
||||||
|
compose_svc.write_compose_file(instance_dir, compose_content)
|
||||||
|
return os.path.join(instance_dir, "docker-compose.yml")
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_tool_configs(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: Any,
|
||||||
|
tool_type_id: Any,
|
||||||
|
project_id: Any,
|
||||||
|
) -> tuple[dict, dict, Any, Any, Any, dict, list]:
|
||||||
|
"""Fetch tool configs and return parsed values."""
|
||||||
|
env_vars: dict[str, str] = {}
|
||||||
|
config_files: dict[str, str] = {}
|
||||||
|
port_override = None
|
||||||
|
start_command = None
|
||||||
|
working_directory = None
|
||||||
|
extra_env_vars: dict[str, str] = {}
|
||||||
|
extra_volumes: list[dict] = []
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(ToolConfig)
|
||||||
|
.where(ToolConfig.user_id == user_id, ToolConfig.tool_type_id == tool_type_id)
|
||||||
|
.where((ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)))
|
||||||
|
)
|
||||||
|
configs = (await session.execute(query)).scalars().all()
|
||||||
|
|
||||||
|
for cfg in configs:
|
||||||
|
if cfg.config_type == "env":
|
||||||
|
env_vars[cfg.key] = cfg.value
|
||||||
|
elif cfg.config_type == "file" and cfg.file_path:
|
||||||
|
config_files[cfg.file_path] = cfg.value
|
||||||
|
if cfg.port_override:
|
||||||
|
port_override = cfg.port_override
|
||||||
|
if cfg.start_command:
|
||||||
|
start_command = cfg.start_command
|
||||||
|
if cfg.working_directory:
|
||||||
|
working_directory = cfg.working_directory
|
||||||
|
if cfg.environment_variables:
|
||||||
|
extra_env_vars.update(cfg.environment_variables)
|
||||||
|
if cfg.volumes:
|
||||||
|
extra_volumes.extend(cfg.volumes)
|
||||||
|
|
||||||
|
env_vars.update(extra_env_vars)
|
||||||
|
return env_vars, config_files, port_override, start_command, working_directory, extra_env_vars, extra_volumes
|
||||||
|
|
||||||
|
|
||||||
|
async def _stage_configs_and_folders(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: Any,
|
||||||
|
project_id: Any,
|
||||||
|
instance_dir: str,
|
||||||
|
env_vars: dict[str, str],
|
||||||
|
config_files: dict[str, str],
|
||||||
|
extra_volumes: list[dict],
|
||||||
|
) -> tuple[str | None, list[dict]]:
|
||||||
|
"""Write env/config files and config folders."""
|
||||||
|
env_file_path: str | None = None
|
||||||
|
if env_vars:
|
||||||
|
env_file_path = compose_svc.write_env_file(instance_dir, env_vars)
|
||||||
|
if config_files:
|
||||||
|
config_staging.write_config_files(instance_dir, config_files)
|
||||||
|
|
||||||
|
folder_query = select(ConfigFolder).where(
|
||||||
|
ConfigFolder.user_id == user_id, ConfigFolder.is_active.is_(True)
|
||||||
|
)
|
||||||
|
folders = (await session.execute(folder_query)).scalars().all()
|
||||||
|
if folders:
|
||||||
|
folder_volumes = config_staging.write_config_folder_files(
|
||||||
|
instance_dir, folders, str(project_id)
|
||||||
|
)
|
||||||
|
extra_volumes.extend(folder_volumes)
|
||||||
|
|
||||||
|
return env_file_path, extra_volumes
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None:
|
||||||
|
"""Create Cloudflare tunnel for web-enabled tools."""
|
||||||
|
if "web" not in tool_type.interfaces or not tool_type.default_port:
|
||||||
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
tunnel_info = tunnel_svc.start_cloudflared_tunnel(
|
||||||
|
container_name=instance.container_name or instance.name,
|
||||||
|
port=tool_type.default_port,
|
||||||
|
)
|
||||||
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
|
instance.public_url = tunnel_info["url"]
|
||||||
|
instance.url = tunnel_info["url"]
|
||||||
|
logger.info("Created tunnel for instance %s: %s", instance.id, tunnel_info["url"])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
instance.status = "error"
|
||||||
|
instance.url = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_readiness_probe(
|
||||||
|
instance: ToolInstance, tool_type: ToolType
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Run readiness probe if configured."""
|
||||||
|
if not tool_type.readiness_probe or not instance.container_id:
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
probe = tool_type.readiness_probe
|
||||||
|
command = probe.get("command", "")
|
||||||
|
if not command:
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
return await execute_probe(
|
||||||
|
container_id=instance.container_id,
|
||||||
|
command=command,
|
||||||
|
timeout=probe.get("timeout", 30),
|
||||||
|
interval=probe.get("interval", 2),
|
||||||
|
)
|
||||||
@@ -60,9 +60,12 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
|
|
||||||
Returns structured data including commits, branches, and graph information.
|
Returns structured data including commits, branches, and graph information.
|
||||||
"""
|
"""
|
||||||
# Get list of branches
|
# Get list of branches (may fail for empty repos)
|
||||||
|
try:
|
||||||
branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"])
|
branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"])
|
||||||
branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()]
|
branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()]
|
||||||
|
except RuntimeError:
|
||||||
|
branches = []
|
||||||
|
|
||||||
# Build git log command - use NULL bytes as separators to avoid parsing issues
|
# Build git log command - use NULL bytes as separators to avoid parsing issues
|
||||||
log_args = [
|
log_args = [
|
||||||
@@ -76,7 +79,16 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
else:
|
else:
|
||||||
log_args.append("--all")
|
log_args.append("--all")
|
||||||
|
|
||||||
|
try:
|
||||||
log_output = _run_git_command(repo_path, log_args)
|
log_output = _run_git_command(repo_path, log_args)
|
||||||
|
except RuntimeError:
|
||||||
|
# Empty repo or no commits
|
||||||
|
return {
|
||||||
|
"commits": [],
|
||||||
|
"branches": branches,
|
||||||
|
"total_commits": 0,
|
||||||
|
"graph_data": {"nodes": [], "edges": []},
|
||||||
|
}
|
||||||
|
|
||||||
# Get branch info for each commit
|
# Get branch info for each commit
|
||||||
branch_map = _get_branch_map(repo_path)
|
branch_map = _get_branch_map(repo_path)
|
||||||
@@ -113,8 +125,11 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get total commit count
|
# Get total commit count
|
||||||
|
try:
|
||||||
count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"])
|
count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"])
|
||||||
total_commits = int(count_output.strip()) if count_output.strip() else 0
|
total_commits = int(count_output.strip()) if count_output.strip() else 0
|
||||||
|
except RuntimeError:
|
||||||
|
total_commits = 0
|
||||||
|
|
||||||
# Build graph data and generate graph symbols
|
# Build graph data and generate graph symbols
|
||||||
graph_data = _build_graph_data(commits)
|
graph_data = _build_graph_data(commits)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Verifies repository structure conventions.
|
||||||
|
* Run with: node scripts/check-structure.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SRC_DIR = path.join(__dirname, "..", "src");
|
||||||
|
|
||||||
|
let errors = 0;
|
||||||
|
let warnings = 0;
|
||||||
|
|
||||||
|
// Known acceptable deviations — documented in naming.md
|
||||||
|
const OVERSIZE_ALLOWLIST = [
|
||||||
|
// Form-heavy admin tabs: 15+ fields each, splitting would create micro-components
|
||||||
|
"components/features/tool-workshop/ToolTypesTab.tsx",
|
||||||
|
"components/features/tool-workshop/ToolConfigsTab.tsx",
|
||||||
|
// Complex terminal hook: WS lifecycle + ping-pong + echo + resize debouncing
|
||||||
|
"hooks/use-terminal-connection.ts",
|
||||||
|
// Terminal component: xterm lifecycle + resize observer + overlay UI
|
||||||
|
"components/features/terminal/TerminalComponent.tsx",
|
||||||
|
// Instance list with health polling + inline confirmations
|
||||||
|
"components/features/session/InstanceList.tsx",
|
||||||
|
// Dialog with form validation + SSH key handling
|
||||||
|
"components/features/project/RepositoryCreateDialog.tsx",
|
||||||
|
// Test files: complex test coverage
|
||||||
|
"hooks/use-terminal-connection.test.ts",
|
||||||
|
"pages/ToolWorkshopPage.test.tsx",
|
||||||
|
// Global utility CSS: will be further split in future iteration
|
||||||
|
"styles/utilities.css",
|
||||||
|
];
|
||||||
|
|
||||||
|
function checkFileSize(filePath, maxLines = 300) {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const lines = content.split("\n").length;
|
||||||
|
const relative = path.relative(SRC_DIR, filePath);
|
||||||
|
if (lines > maxLines) {
|
||||||
|
if (OVERSIZE_ALLOWLIST.includes(relative)) {
|
||||||
|
console.warn(`⚠️ OVERSIZED (${lines} lines, allowlisted): ${relative}`);
|
||||||
|
warnings++;
|
||||||
|
} else {
|
||||||
|
console.error(`❌ OVERSIZED (${lines} lines): ${relative}`);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir, callback) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
||||||
|
walk(fullPath, callback);
|
||||||
|
} else {
|
||||||
|
callback(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Checking file sizes...\n");
|
||||||
|
walk(SRC_DIR, (filePath) => {
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
if ([".ts", ".tsx", ".py", ".css"].includes(ext)) {
|
||||||
|
checkFileSize(filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n---");
|
||||||
|
if (errors === 0 && warnings === 0) {
|
||||||
|
console.log("✅ All checks passed!");
|
||||||
|
process.exit(0);
|
||||||
|
} else if (errors === 0) {
|
||||||
|
console.log(`✅ All checks passed with ${warnings} warning(s)`);
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ ${errors} error(s), ${warnings} warning(s)`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
deleteConfigFolder,
|
deleteConfigFolder,
|
||||||
listConfigFolders,
|
listConfigFolders,
|
||||||
updateConfigFolder,
|
updateConfigFolder,
|
||||||
} from "../api/config_folders";
|
} from "../api/config-folders";
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
const mockGet = vi.fn();
|
||||||
const mockPost = vi.fn();
|
const mockPost = vi.fn();
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
import type {
|
||||||
|
ConfigFolder,
|
||||||
|
CreateConfigFolderRequest,
|
||||||
|
UpdateConfigFolderRequest,
|
||||||
|
ProjectOverrideRequest,
|
||||||
|
} from "../types/config-folder";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ConfigFolder,
|
||||||
|
CreateConfigFolderRequest,
|
||||||
|
UpdateConfigFolderRequest,
|
||||||
|
ProjectOverrideRequest,
|
||||||
|
} from "../types/config-folder";
|
||||||
|
|
||||||
|
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
|
||||||
|
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConfigFolder = async (
|
||||||
|
data: CreateConfigFolderRequest,
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateConfigFolder = async (
|
||||||
|
id: string,
|
||||||
|
data: UpdateConfigFolderRequest,
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.put<ConfigFolder>(
|
||||||
|
`/config-folders/${id}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteConfigFolder = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/config-folders/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addProjectOverride = async (
|
||||||
|
id: string,
|
||||||
|
projectId: string,
|
||||||
|
data: ProjectOverrideRequest,
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.post<ConfigFolder>(
|
||||||
|
`/config-folders/${id}/overrides/${projectId}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProjectOverride = async (
|
||||||
|
id: string,
|
||||||
|
projectId: string,
|
||||||
|
data: ProjectOverrideRequest,
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.put<ConfigFolder>(
|
||||||
|
`/config-folders/${id}/overrides/${projectId}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteProjectOverride = async (
|
||||||
|
id: string,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
|
||||||
|
};
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { apiClient } from "./client";
|
|
||||||
|
|
||||||
export interface ConfigFolder {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
mount_path: string;
|
|
||||||
files: Record<string, string>;
|
|
||||||
project_overrides: Record<string, { mount_path?: string; files?: Record<string, string> }> | null;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateConfigFolderRequest {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
mount_path: string;
|
|
||||||
files?: Record<string, string>;
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateConfigFolderRequest {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
mount_path?: string;
|
|
||||||
files?: Record<string, string>;
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectOverrideRequest {
|
|
||||||
mount_path?: string;
|
|
||||||
files?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
|
|
||||||
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
|
|
||||||
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createConfigFolder = async (
|
|
||||||
data: CreateConfigFolderRequest
|
|
||||||
): Promise<ConfigFolder> => {
|
|
||||||
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateConfigFolder = async (
|
|
||||||
id: string,
|
|
||||||
data: UpdateConfigFolderRequest
|
|
||||||
): Promise<ConfigFolder> => {
|
|
||||||
const response = await apiClient.put<ConfigFolder>(`/config-folders/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteConfigFolder = async (id: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/config-folders/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const addProjectOverride = async (
|
|
||||||
id: string,
|
|
||||||
projectId: string,
|
|
||||||
data: ProjectOverrideRequest
|
|
||||||
): Promise<ConfigFolder> => {
|
|
||||||
const response = await apiClient.post<ConfigFolder>(
|
|
||||||
`/config-folders/${id}/overrides/${projectId}`,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateProjectOverride = async (
|
|
||||||
id: string,
|
|
||||||
projectId: string,
|
|
||||||
data: ProjectOverrideRequest
|
|
||||||
): Promise<ConfigFolder> => {
|
|
||||||
const response = await apiClient.put<ConfigFolder>(
|
|
||||||
`/config-folders/${id}/overrides/${projectId}`,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteProjectOverride = async (
|
|
||||||
id: string,
|
|
||||||
projectId: string
|
|
||||||
): Promise<void> => {
|
|
||||||
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
import type {
|
||||||
|
CommitDetail,
|
||||||
|
CommitHistoryResponse,
|
||||||
|
CommitResponse,
|
||||||
|
GitRepository,
|
||||||
|
GitRepositoryCreate,
|
||||||
|
GitStatus,
|
||||||
|
MergeResponse,
|
||||||
|
URLParseResult,
|
||||||
|
} from "../types/git-repository";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CommitDetail,
|
||||||
|
CommitHistoryEntry,
|
||||||
|
CommitHistoryResponse,
|
||||||
|
CommitResponse,
|
||||||
|
GitRepository,
|
||||||
|
GitRepositoryCreate,
|
||||||
|
GitStatus,
|
||||||
|
MergeResponse,
|
||||||
|
URLParseResult,
|
||||||
|
} from "../types/git-repository";
|
||||||
|
|
||||||
|
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
||||||
|
const response = await apiClient.post("/projects/repositories/parse-url", {
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRepositories(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<GitRepository[]> {
|
||||||
|
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRepository(
|
||||||
|
projectId: string,
|
||||||
|
data: GitRepositoryCreate,
|
||||||
|
): Promise<GitRepository> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRepository(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRepositoryHistory(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
branch?: string,
|
||||||
|
limit?: number,
|
||||||
|
): Promise<CommitHistoryResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (branch) searchParams.set("branch", branch);
|
||||||
|
if (limit) searchParams.set("limit", String(limit));
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const params = queryString ? `?${queryString}` : "";
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/history${params}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCommitDetail(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
commitHash: string,
|
||||||
|
): Promise<CommitDetail> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRepositoryStatus(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
): Promise<GitStatus> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/status`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBranch(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
name: string,
|
||||||
|
baseBranch: string = "HEAD",
|
||||||
|
): Promise<{ message: string; branch: string }> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches`,
|
||||||
|
{ name, base_branch: baseBranch },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBranch(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
branchName: string,
|
||||||
|
force: boolean = false,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const response = await apiClient.delete(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkoutBranch(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
branch: string,
|
||||||
|
): Promise<{ message: string; branch: string }> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/checkout`,
|
||||||
|
{ branch },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitChanges(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
message: string,
|
||||||
|
files?: string[],
|
||||||
|
): Promise<CommitResponse> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/commit`,
|
||||||
|
{ message, files },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRepository(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/fetch`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pullRepository(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
branch?: string,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const params = branch ? `?branch=${branch}` : "";
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/pull${params}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushRepository(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
branch?: string,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const params = branch ? `?branch=${branch}` : "";
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/push${params}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeBranches(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
sourceBranch: string,
|
||||||
|
targetBranch?: string,
|
||||||
|
message?: string,
|
||||||
|
): Promise<MergeResponse> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/merge`,
|
||||||
|
{ source_branch: sourceBranch, target_branch: targetBranch, message },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
import { apiClient } from "./client";
|
|
||||||
|
|
||||||
export interface GitRepository {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
project_id: string;
|
|
||||||
owner_id: string;
|
|
||||||
is_mirror: boolean;
|
|
||||||
remote_url: string | null;
|
|
||||||
last_push: string | null;
|
|
||||||
created_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GitRepositoryCreate {
|
|
||||||
name: string;
|
|
||||||
remote_url?: string;
|
|
||||||
force_original_url?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface URLParseResult {
|
|
||||||
original_url: string;
|
|
||||||
base_url: string | null;
|
|
||||||
is_valid_clone_url: boolean;
|
|
||||||
needs_parsing: boolean;
|
|
||||||
host: string | null;
|
|
||||||
message: string;
|
|
||||||
error_code: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
|
||||||
const response = await apiClient.post("/projects/repositories/parse-url", { url });
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
|
||||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRepository(
|
|
||||||
projectId: string,
|
|
||||||
data: GitRepositoryCreate
|
|
||||||
): Promise<GitRepository> {
|
|
||||||
const response = await apiClient.post(`/projects/${projectId}/repositories`, data);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
|
||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommitHistoryEntry {
|
|
||||||
hash: string;
|
|
||||||
short_hash: string;
|
|
||||||
message: string;
|
|
||||||
author_name: string;
|
|
||||||
author_email: string;
|
|
||||||
author_date: string;
|
|
||||||
refs: string[];
|
|
||||||
graph_symbol: string;
|
|
||||||
graph_depth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommitHistoryResponse {
|
|
||||||
commits: CommitHistoryEntry[];
|
|
||||||
branches: string[];
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRepositoryHistory(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
branch?: string,
|
|
||||||
limit?: number
|
|
||||||
): Promise<CommitHistoryResponse> {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
if (branch) searchParams.set("branch", branch);
|
|
||||||
if (limit) searchParams.set("limit", String(limit));
|
|
||||||
const queryString = searchParams.toString();
|
|
||||||
const params = queryString ? `?${queryString}` : "";
|
|
||||||
const response = await apiClient.get(`/projects/${projectId}/repositories/${repoId}/history${params}`);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommitDetail {
|
|
||||||
hash: string;
|
|
||||||
short_hash: string;
|
|
||||||
message: string;
|
|
||||||
author_name: string;
|
|
||||||
author_email: string;
|
|
||||||
author_date: string;
|
|
||||||
committer_name: string;
|
|
||||||
committer_email: string;
|
|
||||||
committer_date: string;
|
|
||||||
stats: {
|
|
||||||
additions: number;
|
|
||||||
deletions: number;
|
|
||||||
files_changed: number;
|
|
||||||
};
|
|
||||||
diff: string;
|
|
||||||
parents: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCommitDetail(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
commitHash: string
|
|
||||||
): Promise<CommitDetail> {
|
|
||||||
const response = await apiClient.get(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Git Control API
|
|
||||||
|
|
||||||
export interface GitStatus {
|
|
||||||
branch: string;
|
|
||||||
modified: string[];
|
|
||||||
added: string[];
|
|
||||||
deleted: string[];
|
|
||||||
untracked: string[];
|
|
||||||
renamed: string[];
|
|
||||||
ahead: number;
|
|
||||||
behind: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRepositoryStatus(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string
|
|
||||||
): Promise<GitStatus> {
|
|
||||||
const response = await apiClient.get(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/status`
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createBranch(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
name: string,
|
|
||||||
baseBranch: string = "HEAD"
|
|
||||||
): Promise<{ message: string; branch: string }> {
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/branches`,
|
|
||||||
{ name, base_branch: baseBranch }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteBranch(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
branchName: string,
|
|
||||||
force: boolean = false
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
const response = await apiClient.delete(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkoutBranch(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
branch: string
|
|
||||||
): Promise<{ message: string; branch: string }> {
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/checkout`,
|
|
||||||
{ branch }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommitResponse {
|
|
||||||
commit_hash: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function commitChanges(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
message: string,
|
|
||||||
files?: string[]
|
|
||||||
): Promise<CommitResponse> {
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/commit`,
|
|
||||||
{ message, files }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchRepository(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/fetch`
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pullRepository(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
branch?: string
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
const params = branch ? `?branch=${branch}` : "";
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/pull${params}`
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pushRepository(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
branch?: string
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
const params = branch ? `?branch=${branch}` : "";
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/push${params}`
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MergeResponse {
|
|
||||||
commit_hash: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mergeBranches(
|
|
||||||
projectId: string,
|
|
||||||
repoId: string,
|
|
||||||
sourceBranch: string,
|
|
||||||
targetBranch?: string,
|
|
||||||
message?: string
|
|
||||||
): Promise<MergeResponse> {
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/projects/${projectId}/repositories/${repoId}/merge`,
|
|
||||||
{ source_branch: sourceBranch, target_branch: targetBranch, message }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,16 @@
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
import type { Session } from "../types/session";
|
||||||
|
import type { ToolInstance } from "../types/tool-instance";
|
||||||
|
|
||||||
export interface ToolInstance {
|
export type { Session } from "../types/session";
|
||||||
id: string;
|
export type { ToolInstance } from "../types/tool-instance";
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
tool_type_id: string;
|
|
||||||
tool_type_name: string;
|
|
||||||
tool_type_interfaces: string[];
|
|
||||||
status: string;
|
|
||||||
url: string | null;
|
|
||||||
port: number | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Session {
|
|
||||||
id: string;
|
|
||||||
display_name: string;
|
|
||||||
tool_type_name: string;
|
|
||||||
tool_icon: string;
|
|
||||||
tool_type_interfaces: string[];
|
|
||||||
repository_name: string;
|
|
||||||
repository_id: string;
|
|
||||||
project_name: string;
|
|
||||||
project_id: string;
|
|
||||||
status: string;
|
|
||||||
url: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listInstances(
|
export async function listInstances(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string
|
repoId: string,
|
||||||
): Promise<ToolInstance[]> {
|
): Promise<ToolInstance[]> {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
);
|
);
|
||||||
return response.data.instances;
|
return response.data.instances;
|
||||||
}
|
}
|
||||||
@@ -41,14 +19,14 @@ export async function createInstance(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
toolTypeId: string,
|
toolTypeId: string,
|
||||||
displayName?: string
|
displayName?: string,
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
{
|
{
|
||||||
tool_type_id: toolTypeId,
|
tool_type_id: toolTypeId,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -56,10 +34,10 @@ export async function createInstance(
|
|||||||
export async function startInstance(
|
export async function startInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -67,10 +45,10 @@ export async function startInstance(
|
|||||||
export async function stopInstance(
|
export async function stopInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<{ status: string }> {
|
): Promise<{ status: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -78,10 +56,10 @@ export async function stopInstance(
|
|||||||
export async function restartInstance(
|
export async function restartInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -89,10 +67,10 @@ export async function restartInstance(
|
|||||||
export async function deleteInstance(
|
export async function deleteInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await apiClient.delete(
|
await apiClient.delete(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,10 +82,10 @@ export async function getUserSessions(): Promise<Session[]> {
|
|||||||
export async function checkInstanceHealth(
|
export async function checkInstanceHealth(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
|
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -115,10 +93,10 @@ export async function checkInstanceHealth(
|
|||||||
export async function recreateInstanceTunnel(
|
export async function recreateInstanceTunnel(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config";
|
||||||
|
|
||||||
|
export type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config";
|
||||||
|
|
||||||
|
export const listToolConfigs = async (
|
||||||
|
tool_type_id?: string,
|
||||||
|
project_id?: string,
|
||||||
|
): Promise<ToolConfig[]> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (tool_type_id) params.append("tool_type_id", tool_type_id);
|
||||||
|
if (project_id) params.append("project_id", project_id);
|
||||||
|
|
||||||
|
const response = await apiClient.get<{ configs: ToolConfig[] }>(
|
||||||
|
`/tool-configs?${params.toString()}`,
|
||||||
|
);
|
||||||
|
return response.data.configs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createToolConfig = async (
|
||||||
|
data: CreateToolConfigRequest,
|
||||||
|
): Promise<ToolConfig> => {
|
||||||
|
const response = await apiClient.post<{ configs: ToolConfig[] }>(
|
||||||
|
"/tool-configs",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data.configs[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateToolConfig = async (
|
||||||
|
id: string,
|
||||||
|
data: CreateToolConfigRequest,
|
||||||
|
): Promise<ToolConfig> => {
|
||||||
|
const response = await apiClient.put<{ configs: ToolConfig[] }>(
|
||||||
|
`/tool-configs/${id}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data.configs[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteToolConfig = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/tool-configs/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getToolConfigDefaults = async (
|
||||||
|
toolTypeId: string,
|
||||||
|
): Promise<ToolConfig> => {
|
||||||
|
const response = await apiClient.get<ToolConfig>(
|
||||||
|
`/tool-configs/defaults/${toolTypeId}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
listToolTypes,
|
listToolTypes,
|
||||||
updateToolType,
|
updateToolType,
|
||||||
validateToolType,
|
validateToolType,
|
||||||
} from "../api/tool_types";
|
} from "../api/tool-types";
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
const mockGet = vi.fn();
|
||||||
const mockPost = vi.fn();
|
const mockPost = vi.fn();
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
import type {
|
||||||
|
ToolType,
|
||||||
|
CreateToolTypeRequest,
|
||||||
|
UpdateToolTypeRequest,
|
||||||
|
} from "../types/tool-type";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ReadinessProbe,
|
||||||
|
ToolType,
|
||||||
|
CreateToolTypeRequest,
|
||||||
|
UpdateToolTypeRequest,
|
||||||
|
} from "../types/tool-type";
|
||||||
|
|
||||||
|
export const listToolTypes = async (): Promise<ToolType[]> => {
|
||||||
|
const response = await apiClient.get<ToolType[]>("/tool-types");
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getToolType = async (id: string): Promise<ToolType> => {
|
||||||
|
const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createToolType = async (
|
||||||
|
data: CreateToolTypeRequest,
|
||||||
|
): Promise<ToolType> => {
|
||||||
|
const response = await apiClient.post<ToolType>("/tool-types", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateToolType = async (
|
||||||
|
id: string,
|
||||||
|
data: UpdateToolTypeRequest,
|
||||||
|
): Promise<ToolType> => {
|
||||||
|
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteToolType = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/tool-types/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateToolType = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<{ valid: boolean; errors?: string[] }> => {
|
||||||
|
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(
|
||||||
|
`/tool-types/${id}/validate`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { apiClient } from "./client";
|
|
||||||
|
|
||||||
export interface ToolConfig {
|
|
||||||
id: string;
|
|
||||||
tool_type_id: string;
|
|
||||||
project_id: string | null;
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
config_type: string;
|
|
||||||
file_path: string | null;
|
|
||||||
port_override: number | null;
|
|
||||||
start_command: string | null;
|
|
||||||
working_directory: string | null;
|
|
||||||
environment_variables: Record<string, string> | null;
|
|
||||||
volumes: Array<{ source: string; target: string; type?: string }> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateToolConfigRequest {
|
|
||||||
tool_type_id: string;
|
|
||||||
project_id?: string;
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
config_type?: string;
|
|
||||||
file_path?: string;
|
|
||||||
port_override?: number;
|
|
||||||
start_command?: string;
|
|
||||||
working_directory?: string;
|
|
||||||
environment_variables?: Record<string, string>;
|
|
||||||
volumes?: Array<{ source: string; target: string; type?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listToolConfigs = async (
|
|
||||||
tool_type_id?: string,
|
|
||||||
project_id?: string
|
|
||||||
): Promise<ToolConfig[]> => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (tool_type_id) params.append("tool_type_id", tool_type_id);
|
|
||||||
if (project_id) params.append("project_id", project_id);
|
|
||||||
|
|
||||||
const response = await apiClient.get<{ configs: ToolConfig[] }>(
|
|
||||||
`/tool-configs?${params.toString()}`
|
|
||||||
);
|
|
||||||
return response.data.configs;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createToolConfig = async (
|
|
||||||
data: CreateToolConfigRequest
|
|
||||||
): Promise<ToolConfig> => {
|
|
||||||
const response = await apiClient.post<{ configs: ToolConfig[] }>("/tool-configs", data);
|
|
||||||
return response.data.configs[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateToolConfig = async (
|
|
||||||
id: string,
|
|
||||||
data: CreateToolConfigRequest
|
|
||||||
): Promise<ToolConfig> => {
|
|
||||||
const response = await apiClient.put<{ configs: ToolConfig[] }>(
|
|
||||||
`/tool-configs/${id}`,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return response.data.configs[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteToolConfig = async (id: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/tool-configs/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getToolConfigDefaults = async (
|
|
||||||
toolTypeId: string
|
|
||||||
): Promise<ToolConfig> => {
|
|
||||||
const response = await apiClient.get<ToolConfig>(
|
|
||||||
`/tool-configs/defaults/${toolTypeId}`
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { apiClient } from "./client";
|
|
||||||
|
|
||||||
export interface ReadinessProbe {
|
|
||||||
command: string;
|
|
||||||
timeout: number;
|
|
||||||
interval: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolType {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
description: string | null;
|
|
||||||
category: string;
|
|
||||||
interfaces: string[];
|
|
||||||
default_port: number | null;
|
|
||||||
definition_type: 'compose' | 'dockerfile';
|
|
||||||
compose_template: string | null;
|
|
||||||
dockerfile_template: string | null;
|
|
||||||
build_context: Record<string, string> | null;
|
|
||||||
readiness_probe: ReadinessProbe | null;
|
|
||||||
required_variables: string[];
|
|
||||||
is_builtin: boolean;
|
|
||||||
created_by_id: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateToolTypeRequest {
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
description?: string;
|
|
||||||
category?: string;
|
|
||||||
interfaces?: string[];
|
|
||||||
default_port: number;
|
|
||||||
definition_type?: 'compose' | 'dockerfile';
|
|
||||||
compose_template?: string;
|
|
||||||
dockerfile_template?: string;
|
|
||||||
build_context?: Record<string, string>;
|
|
||||||
readiness_probe?: ReadinessProbe;
|
|
||||||
required_variables: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateToolTypeRequest {
|
|
||||||
display_name?: string;
|
|
||||||
description?: string;
|
|
||||||
category?: string;
|
|
||||||
interfaces?: string[];
|
|
||||||
default_port?: number;
|
|
||||||
definition_type?: 'compose' | 'dockerfile';
|
|
||||||
compose_template?: string;
|
|
||||||
dockerfile_template?: string;
|
|
||||||
build_context?: Record<string, string>;
|
|
||||||
readiness_probe?: ReadinessProbe;
|
|
||||||
required_variables?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listToolTypes = async (): Promise<ToolType[]> => {
|
|
||||||
const response = await apiClient.get<ToolType[]>("/tool-types");
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getToolType = async (id: string): Promise<ToolType> => {
|
|
||||||
const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createToolType = async (data: CreateToolTypeRequest): Promise<ToolType> => {
|
|
||||||
const response = await apiClient.post<ToolType>("/tool-types", data);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateToolType = async (id: string, data: UpdateToolTypeRequest): Promise<ToolType> => {
|
|
||||||
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteToolType = async (id: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/tool-types/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const validateToolType = async (id: string): Promise<{ valid: boolean; errors?: string[] }> => {
|
|
||||||
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(`/tool-types/${id}/validate`);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
|
|||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ProtectedRoute } from "./protected-route";
|
import { ProtectedRoute } from "./ProtectedRoute";
|
||||||
|
|
||||||
const mockUseAuth = vi.fn();
|
const mockUseAuth = vi.fn();
|
||||||
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
|
||||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
|
||||||
|
|
||||||
import { getUserSessions } from "../api/sessions";
|
|
||||||
import type { Session } from "../api/sessions";
|
|
||||||
import { useTheme } from "../hooks/use-theme";
|
|
||||||
import { useAuth } from "../state/auth";
|
|
||||||
import { useSessions } from "../state/sessions";
|
|
||||||
import { Icon } from "./icon";
|
|
||||||
import type { IconName } from "../utils/icons";
|
|
||||||
|
|
||||||
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
|
||||||
{ to: "/", label: "Home", icon: "dashboard" },
|
|
||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
|
||||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
|
||||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const ACTIVE_STATUSES = ["running", "building", "pending"];
|
|
||||||
|
|
||||||
const SessionItem = ({ session }: { session: Session }) => {
|
|
||||||
const isRunning = session.status === "running";
|
|
||||||
const displayName = session.display_name || session.tool_type_name || "Unnamed Session";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={session.url ?? `/projects/${session.project_id}`}
|
|
||||||
target={session.url ? "_blank" : undefined}
|
|
||||||
rel={session.url ? "noopener noreferrer" : undefined}
|
|
||||||
className="nav-item session-item"
|
|
||||||
title={`${displayName} (${session.status})`}
|
|
||||||
>
|
|
||||||
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
|
||||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
|
||||||
<span className="session-name">{displayName}</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppShell = () => {
|
|
||||||
useTheme();
|
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const { sessions, setAllSessions } = useSessions();
|
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await getUserSessions();
|
|
||||||
setAllSessions(data);
|
|
||||||
} catch {
|
|
||||||
// Silently fail - sessions are optional
|
|
||||||
}
|
|
||||||
}, [setAllSessions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadSessions();
|
|
||||||
// Poll every 10 seconds
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
void loadSessions();
|
|
||||||
}, 10000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [loadSessions]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="shell">
|
|
||||||
<header className="shell-header">
|
|
||||||
<Link className="brand" to="/">
|
|
||||||
Headquarter
|
|
||||||
</Link>
|
|
||||||
<div className="header-actions">
|
|
||||||
<Link className="user-chip" to="/profile">
|
|
||||||
{user?.name ?? "User"}
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => {
|
|
||||||
void logout();
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="logout" size="sm" />
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="shell-body">
|
|
||||||
<aside className="shell-nav" aria-label="Primary navigation">
|
|
||||||
{NAV_ITEMS.map((item) => {
|
|
||||||
const isHome = item.to === "/";
|
|
||||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
|
||||||
end={item.to === "/"}
|
|
||||||
>
|
|
||||||
<Icon name={item.icon} size="sm" />
|
|
||||||
{item.label}
|
|
||||||
{isHome && activeCount > 0 && (
|
|
||||||
<span className="nav-badge">{activeCount}</span>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{sessions.filter((s) => ACTIVE_STATUSES.includes(s.status)).length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="nav-divider" />
|
|
||||||
<div className="nav-section-title">Live sessions</div>
|
|
||||||
{sessions
|
|
||||||
.filter((s) => ACTIVE_STATUSES.includes(s.status))
|
|
||||||
.map((session) => (
|
|
||||||
<SessionItem key={session.id} session={session} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="shell-content">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { commitChanges } from "../api/git_repositories";
|
|
||||||
|
|
||||||
interface CommitPanelProps {
|
|
||||||
projectId: string;
|
|
||||||
repoId: string;
|
|
||||||
modified: string[];
|
|
||||||
added: string[];
|
|
||||||
deleted: string[];
|
|
||||||
untracked: string[];
|
|
||||||
onCommit: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CommitPanel = ({
|
|
||||||
projectId,
|
|
||||||
repoId,
|
|
||||||
modified,
|
|
||||||
added,
|
|
||||||
deleted,
|
|
||||||
untracked,
|
|
||||||
onCommit,
|
|
||||||
}: CommitPanelProps) => {
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const allFiles = [...modified, ...added, ...deleted, ...untracked];
|
|
||||||
const hasChanges = allFiles.length > 0;
|
|
||||||
|
|
||||||
const handleCommit = async () => {
|
|
||||||
if (!message.trim()) {
|
|
||||||
setError("Please enter a commit message");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await commitChanges(projectId, repoId, message);
|
|
||||||
setMessage("");
|
|
||||||
onCommit();
|
|
||||||
} catch {
|
|
||||||
setError("Commit failed. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!hasChanges) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="commit-panel">
|
|
||||||
<h4>Changes</h4>
|
|
||||||
|
|
||||||
<div className="file-list">
|
|
||||||
{modified.map((file) => (
|
|
||||||
<div key={file} className="file-item modified">
|
|
||||||
<span className="file-status">M</span>
|
|
||||||
<span className="file-name">{file}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{added.map((file) => (
|
|
||||||
<div key={file} className="file-item added">
|
|
||||||
<span className="file-status">A</span>
|
|
||||||
<span className="file-name">{file}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{deleted.map((file) => (
|
|
||||||
<div key={file} className="file-item deleted">
|
|
||||||
<span className="file-status">D</span>
|
|
||||||
<span className="file-name">{file}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{untracked.map((file) => (
|
|
||||||
<div key={file} className="file-item untracked">
|
|
||||||
<span className="file-status">?</span>
|
|
||||||
<span className="file-name">{file}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="commit-form">
|
|
||||||
<textarea
|
|
||||||
placeholder="Commit message"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
className="commit-message-input"
|
|
||||||
/>
|
|
||||||
{error && <div className="commit-error">{error}</div>}
|
|
||||||
<button
|
|
||||||
onClick={handleCommit}
|
|
||||||
disabled={loading || !message.trim()}
|
|
||||||
className="commit-button"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{loading ? "Committing..." : "Commit"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Session } from "../../../types/session";
|
||||||
|
import { Icon } from "../../ui/Icon";
|
||||||
|
|
||||||
|
interface ActiveSessionsListProps {
|
||||||
|
sessions: Session[];
|
||||||
|
actionBusy: string | null;
|
||||||
|
onOpen: (session: Session) => void;
|
||||||
|
onStop: (session: Session) => void;
|
||||||
|
onDelete: (session: Session) => void;
|
||||||
|
onRecreateTunnel: (session: Session) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActiveSessionsList = ({
|
||||||
|
sessions,
|
||||||
|
actionBusy,
|
||||||
|
onOpen,
|
||||||
|
onStop,
|
||||||
|
onDelete,
|
||||||
|
onRecreateTunnel,
|
||||||
|
}: ActiveSessionsListProps) => {
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return <p className="muted">No active sessions right now.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home-session-grid">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<article className="card session-card" key={session.id}>
|
||||||
|
<div className="stack-sm">
|
||||||
|
<div className="row row-tight">
|
||||||
|
<h3>
|
||||||
|
{session.display_name ||
|
||||||
|
session.tool_type_name ||
|
||||||
|
"Unnamed Session"}
|
||||||
|
</h3>
|
||||||
|
<span className={`status-badge ${session.status}`}>
|
||||||
|
{session.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
{session.project_name} · {session.repository_name}
|
||||||
|
</p>
|
||||||
|
<p className="muted">{session.tool_type_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="session-actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(session)}
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onRecreateTunnel(session)}
|
||||||
|
disabled={actionBusy === session.id}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Tunnel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onStop(session)}
|
||||||
|
disabled={actionBusy === session.id}
|
||||||
|
>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onDelete(session)}
|
||||||
|
disabled={actionBusy === session.id}
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { DashboardSummary as DashboardSummaryType } from "../../../api/dashboard";
|
||||||
|
|
||||||
|
interface DashboardSummaryProps {
|
||||||
|
summary: DashboardSummaryType;
|
||||||
|
activeSessionsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryCards = [
|
||||||
|
{ label: "Open sessions", key: "openSessions" },
|
||||||
|
{ label: "Projects", key: "projects" },
|
||||||
|
{ label: "Repositories", key: "repositories" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DashboardSummary = ({
|
||||||
|
summary,
|
||||||
|
activeSessionsCount,
|
||||||
|
}: DashboardSummaryProps) => {
|
||||||
|
return (
|
||||||
|
<div className="home-summary-grid">
|
||||||
|
{summaryCards.map((card) => (
|
||||||
|
<article className="card home-summary-card" key={card.label}>
|
||||||
|
<p className="card-label">{card.label}</p>
|
||||||
|
<p className="card-value">
|
||||||
|
{card.key === "openSessions"
|
||||||
|
? activeSessionsCount
|
||||||
|
: card.key === "projects"
|
||||||
|
? summary.projects
|
||||||
|
: summary.repositories}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Project } from "../../../types/project";
|
||||||
|
|
||||||
|
interface ProjectsSectionProps {
|
||||||
|
projects: Project[];
|
||||||
|
onOpenProject: (projectId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectsSection = ({
|
||||||
|
projects,
|
||||||
|
onOpenProject,
|
||||||
|
}: ProjectsSectionProps) => {
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return <p className="muted">No projects yet.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home-project-grid">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<article
|
||||||
|
className="card project-card home-project-card"
|
||||||
|
key={project.id}
|
||||||
|
>
|
||||||
|
<div className="stack-sm">
|
||||||
|
<h3>{project.name}</h3>
|
||||||
|
{project.description && (
|
||||||
|
<p className="muted">{project.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenProject(project.id)}
|
||||||
|
>
|
||||||
|
Open Workspace
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { Project } from "../../../types/project";
|
||||||
|
import type { GitRepository } from "../../../types/git-repository";
|
||||||
|
import type { ToolType } from "../../../types/tool-type";
|
||||||
|
import { Icon } from "../../ui/Icon";
|
||||||
|
|
||||||
|
interface QuickCreateFormProps {
|
||||||
|
projects: Project[];
|
||||||
|
repositories: GitRepository[];
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
saveState: "idle" | "saving" | "error";
|
||||||
|
onSubmit: (data: {
|
||||||
|
projectId: string;
|
||||||
|
repoId: string;
|
||||||
|
toolTypeId: string;
|
||||||
|
displayName: string;
|
||||||
|
}) => void;
|
||||||
|
onProjectChange: (projectId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickCreateForm = ({
|
||||||
|
projects,
|
||||||
|
repositories,
|
||||||
|
toolTypes,
|
||||||
|
saveState,
|
||||||
|
onSubmit,
|
||||||
|
onProjectChange,
|
||||||
|
}: QuickCreateFormProps) => {
|
||||||
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState("");
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
|
||||||
|
const handleProjectChange = (projectId: string) => {
|
||||||
|
setSelectedProject(projectId);
|
||||||
|
setSelectedRepo("");
|
||||||
|
onProjectChange(projectId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||||
|
onSubmit({
|
||||||
|
projectId: selectedProject,
|
||||||
|
repoId: selectedRepo,
|
||||||
|
toolTypeId: selectedToolType,
|
||||||
|
displayName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="stack create-session-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
Project
|
||||||
|
<select
|
||||||
|
value={selectedProject}
|
||||||
|
onChange={(event) => handleProjectChange(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select
|
||||||
|
value={selectedRepo}
|
||||||
|
onChange={(event) => setSelectedRepo(event.target.value)}
|
||||||
|
disabled={!selectedProject}
|
||||||
|
>
|
||||||
|
<option value="">Select repository...</option>
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<option key={repo.id} value={repo.id}>
|
||||||
|
{repo.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Tool type
|
||||||
|
<select
|
||||||
|
value={selectedToolType}
|
||||||
|
onChange={(event) => setSelectedToolType(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select tool...</option>
|
||||||
|
{toolTypes.map((tool) => (
|
||||||
|
<option key={tool.id} value={tool.id}>
|
||||||
|
{tool.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="form-field">
|
||||||
|
Display name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
|
placeholder="My Development Environment"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={saveState === "saving"}
|
||||||
|
>
|
||||||
|
{saveState === "saving" ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" /> Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="add" size="sm" /> Create Session
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{saveState === "error" && (
|
||||||
|
<span className="error-text">Failed to create session</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Session } from "../../../types/session";
|
||||||
|
|
||||||
|
interface RecentSessionsSectionProps {
|
||||||
|
sessions: Session[];
|
||||||
|
onOpen: (session: Session) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RecentSessionsSection = ({
|
||||||
|
sessions,
|
||||||
|
onOpen,
|
||||||
|
}: RecentSessionsSectionProps) => {
|
||||||
|
if (sessions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card stack home-section">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Recent sessions</p>
|
||||||
|
<h2>{sessions.length}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="recent-sessions-list">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<article className="recent-session-item" key={session.id}>
|
||||||
|
<div className="recent-session-info">
|
||||||
|
<span className="recent-session-name">
|
||||||
|
{session.display_name ||
|
||||||
|
session.tool_type_name ||
|
||||||
|
"Unnamed Session"}
|
||||||
|
</span>
|
||||||
|
<span className="muted">
|
||||||
|
{session.project_name} · {session.tool_type_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(session)}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { DashboardSummary } from "./DashboardSummary";
|
||||||
|
export { ActiveSessionsList } from "./ActiveSessionsList";
|
||||||
|
export { ProjectsSection } from "./ProjectsSection";
|
||||||
|
export { QuickCreateForm } from "./QuickCreateForm";
|
||||||
|
export { RecentSessionsSection } from "./RecentSessionsSection";
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
.commitDialog {
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogHeader h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogClose {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogClose:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogBody {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInfo {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffPreview {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffPreview h4 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffContent {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
font-family: 'Fira Code', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffLine {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffLineNumber {
|
||||||
|
color: var(--muted);
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffMarker {
|
||||||
|
width: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffAdded {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffAdded .diffMarker {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffRemoved {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffRemoved .diffMarker {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffSame {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffLineContent {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningMessage {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #d97706;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
+16
-15
@@ -1,6 +1,7 @@
|
|||||||
|
import styles from "./CommitDialog.module.css";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../ui/Icon";
|
||||||
|
|
||||||
interface CommitDialogProps {
|
interface CommitDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -71,40 +72,40 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
|
|||||||
const hasChanges = diff.some((d) => d.type !== "same");
|
const hasChanges = diff.some((d) => d.type !== "same");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dialog-overlay">
|
<div className={styles.dialogOverlay}>
|
||||||
<div className="commit-dialog">
|
<div className={styles.commitDialog}>
|
||||||
<div className="dialog-header">
|
<div className={styles.dialogHeader}>
|
||||||
<h3>Commit Changes</h3>
|
<h3>Commit Changes</h3>
|
||||||
<button className="dialog-close" onClick={onCancel} type="button">
|
<button className={styles.dialogClose} onClick={onCancel} type="button">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dialog-body">
|
<div className={styles.dialogBody}>
|
||||||
<p className="file-info">
|
<p className={styles.fileInfo}>
|
||||||
Editing: <strong>{filePath}</strong>
|
Editing: <strong>{filePath}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!hasChanges && (
|
{!hasChanges && (
|
||||||
<div className="warning-message">No changes to commit</div>
|
<div className={styles.warningMessage}>No changes to commit</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<div className="diff-preview">
|
<div className={styles.diffPreview}>
|
||||||
<h4>Changes</h4>
|
<h4>Changes</h4>
|
||||||
<div className="diff-content">
|
<div className={styles.diffContent}>
|
||||||
{diff.map((line, i) => (
|
{diff.map((line, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`diff-line diff-${line.type}`}
|
className={`${styles.diffLine} ${line.type === "added" ? styles.diffAdded : line.type === "removed" ? styles.diffRemoved : styles.diffSame}`}
|
||||||
>
|
>
|
||||||
<span className="diff-line-number">{line.lineNum}</span>
|
<span className={styles.diffLineNumber}>{line.lineNum}</span>
|
||||||
<span className="diff-marker">
|
<span className={styles.diffMarker}>
|
||||||
{line.type === "added" && "+"}
|
{line.type === "added" && "+"}
|
||||||
{line.type === "removed" && "-"}
|
{line.type === "removed" && "-"}
|
||||||
{line.type === "same" && " "}
|
{line.type === "same" && " "}
|
||||||
</span>
|
</span>
|
||||||
<span className="diff-line-content">{line.line}</span>
|
<span className={styles.diffLineContent}>{line.line}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +126,7 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
|
|||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dialog-footer">
|
<div className={styles.dialogFooter}>
|
||||||
<button
|
<button
|
||||||
className="btn-secondary"
|
className="btn-secondary"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
.commitPanel {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commitPanel h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileList {
|
||||||
|
max-height: 150px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatus {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
width: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem.modified .fileStatus {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.fileItem.added .fileStatus {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
.fileItem.deleted .fileStatus {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.fileItem.untracked .fileStatus {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commitForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commitMessageInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commitButton {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commitButton:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commitError {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { commitChanges } from "../../../api/git-repositories";
|
||||||
|
import styles from "./CommitPanel.module.css";
|
||||||
|
|
||||||
|
interface CommitPanelProps {
|
||||||
|
projectId: string;
|
||||||
|
repoId: string;
|
||||||
|
modified: string[];
|
||||||
|
added: string[];
|
||||||
|
deleted: string[];
|
||||||
|
untracked: string[];
|
||||||
|
onCommit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommitPanel = ({
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
modified,
|
||||||
|
added,
|
||||||
|
deleted,
|
||||||
|
untracked,
|
||||||
|
onCommit,
|
||||||
|
}: CommitPanelProps) => {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const allFiles = [...modified, ...added, ...deleted, ...untracked];
|
||||||
|
const hasChanges = allFiles.length > 0;
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
if (!message.trim()) {
|
||||||
|
setError("Please enter a commit message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await commitChanges(projectId, repoId, message);
|
||||||
|
setMessage("");
|
||||||
|
onCommit();
|
||||||
|
} catch {
|
||||||
|
setError("Commit failed. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!hasChanges) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.commitPanel}>
|
||||||
|
<h4>Changes</h4>
|
||||||
|
|
||||||
|
<div className={styles.fileList}>
|
||||||
|
{modified.map((file) => (
|
||||||
|
<div key={file} className={`${styles.fileItem} modified`}>
|
||||||
|
<span className={styles.fileStatus}>M</span>
|
||||||
|
<span>{file}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{added.map((file) => (
|
||||||
|
<div key={file} className={`${styles.fileItem} added`}>
|
||||||
|
<span className={styles.fileStatus}>A</span>
|
||||||
|
<span>{file}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{deleted.map((file) => (
|
||||||
|
<div key={file} className={`${styles.fileItem} deleted`}>
|
||||||
|
<span className={styles.fileStatus}>D</span>
|
||||||
|
<span>{file}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{untracked.map((file) => (
|
||||||
|
<div key={file} className={`${styles.fileItem} untracked`}>
|
||||||
|
<span className={styles.fileStatus}>?</span>
|
||||||
|
<span>{file}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.commitForm}>
|
||||||
|
<textarea
|
||||||
|
placeholder="Commit message"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className={styles.commitMessageInput}
|
||||||
|
/>
|
||||||
|
{error && <div className={styles.commitError}>{error}</div>}
|
||||||
|
<button
|
||||||
|
onClick={handleCommit}
|
||||||
|
disabled={loading || !message.trim()}
|
||||||
|
className={styles.commitButton}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{loading ? "Committing..." : "Commit"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
.fileTree {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeEntry {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeEntry:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeDirectory {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeUp {
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatusIndicator {
|
||||||
|
float: right;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatusIndicator.modified {
|
||||||
|
color: #f59e0b;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatusIndicator.added {
|
||||||
|
color: #10b981;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatusIndicator.deleted {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileStatusIndicator.untracked {
|
||||||
|
color: #6b7280;
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { FileBrowser } from "./FileBrowser";
|
||||||
|
|
||||||
|
// Mock apiClient
|
||||||
|
vi.mock("../../../api/client", () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { apiClient } from "../../../api/client";
|
||||||
|
|
||||||
|
describe("FileBrowser", () => {
|
||||||
|
it("renders loading state initially", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/loading files/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders file entries after loading", async () => {
|
||||||
|
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
entries: [
|
||||||
|
{ name: "src", type: "directory", path: "src" },
|
||||||
|
{ name: "README.md", type: "file", path: "README.md" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("src")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("README.md")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders error state on failure", async () => {
|
||||||
|
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
|
||||||
|
mockedGet.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/failed to load files/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { Icon } from "../../ui/Icon";
|
||||||
|
import { apiClient } from "../../../api/client";
|
||||||
|
import type { GitStatus } from "../../../types/git-repository";
|
||||||
|
|
||||||
|
interface FileTreeEntry {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
path: string;
|
||||||
|
size?: number;
|
||||||
|
mode?: string;
|
||||||
|
last_commit?: {
|
||||||
|
hash: string;
|
||||||
|
message: string;
|
||||||
|
author: string;
|
||||||
|
date: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileBrowserProps {
|
||||||
|
projectId: string;
|
||||||
|
repoId: string;
|
||||||
|
gitStatus: GitStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
gitStatus,
|
||||||
|
}) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const branch = searchParams.get("branch") || "main";
|
||||||
|
const path = searchParams.get("path") || "";
|
||||||
|
|
||||||
|
const loadFiles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/files`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
branch,
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setEntries(response.data.entries || []);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load files");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId, repoId, branch, path]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFiles();
|
||||||
|
}, [loadFiles]);
|
||||||
|
|
||||||
|
// Listen for refresh events
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRefresh = () => void loadFiles();
|
||||||
|
window.addEventListener("refresh-file-tree", handleRefresh);
|
||||||
|
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
|
||||||
|
}, [loadFiles]);
|
||||||
|
|
||||||
|
const handleEntryClick = (entry: FileTreeEntry) => {
|
||||||
|
if (entry.type === "directory") {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set("path", entry.path);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
} else {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set("file", entry.path);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateUp = () => {
|
||||||
|
if (!path) return;
|
||||||
|
const parentPath = path.split("/").slice(0, -1).join("/");
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
if (parentPath) {
|
||||||
|
newParams.set("path", parentPath);
|
||||||
|
} else {
|
||||||
|
newParams.delete("path");
|
||||||
|
}
|
||||||
|
setSearchParams(newParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileStatus = (filePath: string): string | null => {
|
||||||
|
if (!gitStatus) return null;
|
||||||
|
if (gitStatus.modified.includes(filePath)) return "modified";
|
||||||
|
if (gitStatus.added.includes(filePath)) return "added";
|
||||||
|
if (gitStatus.deleted.includes(filePath)) return "deleted";
|
||||||
|
if (gitStatus.untracked.includes(filePath)) return "untracked";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p className="muted">Loading files...</p>;
|
||||||
|
if (error) return <p className="error-text">{error}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-tree">
|
||||||
|
{path && (
|
||||||
|
<button
|
||||||
|
className="tree-entry tree-up"
|
||||||
|
onClick={navigateUp}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="folder" size="sm" /> ..
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<p className="muted">No files in this repository yet.</p>
|
||||||
|
)}
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const fileStatus =
|
||||||
|
entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.path}
|
||||||
|
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
|
||||||
|
onClick={() => handleEntryClick(entry)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={entry.type === "directory" ? "folder" : "file"}
|
||||||
|
size="sm"
|
||||||
|
/>{" "}
|
||||||
|
{entry.name}
|
||||||
|
{fileStatus && (
|
||||||
|
<span className={`file-status-indicator ${fileStatus}`}>
|
||||||
|
{fileStatus === "modified" && "M"}
|
||||||
|
{fileStatus === "added" && "A"}
|
||||||
|
{fileStatus === "deleted" && "D"}
|
||||||
|
{fileStatus === "untracked" && "?"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.fileEditor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileEditorToolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileEditorContent {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
+76
-12
@@ -1,21 +1,31 @@
|
|||||||
|
import styles from "./FileEditor.module.css";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { apiClient } from "../api/client";
|
import { apiClient } from "../../../api/client";
|
||||||
import { useAuth } from "../state/auth";
|
import { useAuth } from "../../../state/auth";
|
||||||
import { CodeEditor } from "../components/code-editor";
|
import { CodeEditor } from "../../ui/CodeEditor";
|
||||||
import { CommitDialog } from "../components/commit-dialog";
|
import { CommitDialog } from "./CommitDialog";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../../ui/Icon";
|
||||||
import { SyntaxHighlighter } from "../components/syntax-highlighter";
|
import { SyntaxHighlighter } from "./SyntaxHighlighter";
|
||||||
import { detectLanguage } from "../utils/language";
|
import { detectLanguage } from "../../../utils/language";
|
||||||
|
|
||||||
|
interface GitFileStatus {
|
||||||
|
modified: string[];
|
||||||
|
added: string[];
|
||||||
|
deleted: string[];
|
||||||
|
untracked: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface FileEditorProps {
|
interface FileEditorProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
|
gitStatus?: GitFileStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileEditor: React.FC<FileEditorProps> = ({
|
export const FileEditor: React.FC<FileEditorProps> = ({
|
||||||
projectId,
|
projectId,
|
||||||
repoId,
|
repoId,
|
||||||
|
gitStatus,
|
||||||
}) => {
|
}) => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -30,9 +40,49 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
const [isBinary, setIsBinary] = useState(false);
|
const [isBinary, setIsBinary] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleDiscard = async () => {
|
||||||
|
if (!filePath) return;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/files/content`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
branch,
|
||||||
|
path: filePath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.is_binary) {
|
||||||
|
setIsBinary(true);
|
||||||
|
setContent("Binary file - cannot display");
|
||||||
|
setOriginalContent("");
|
||||||
|
} else {
|
||||||
|
setIsBinary(false);
|
||||||
|
setContent(data.content);
|
||||||
|
setOriginalContent(data.content);
|
||||||
|
}
|
||||||
|
setMode("view");
|
||||||
|
} catch {
|
||||||
|
setError("Failed to discard changes");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const branch = searchParams.get("branch") || "main";
|
const branch = searchParams.get("branch") || "main";
|
||||||
const filePath = searchParams.get("file");
|
const filePath = searchParams.get("file");
|
||||||
|
|
||||||
|
const fileStatus = gitStatus
|
||||||
|
? gitStatus.modified.includes(filePath || "")
|
||||||
|
? "modified"
|
||||||
|
: gitStatus.added.includes(filePath || "")
|
||||||
|
? "added"
|
||||||
|
: gitStatus.deleted.includes(filePath || "")
|
||||||
|
? "deleted"
|
||||||
|
: gitStatus.untracked.includes(filePath || "")
|
||||||
|
? "untracked"
|
||||||
|
: undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const loadFile = useCallback(async () => {
|
const loadFile = useCallback(async () => {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
setContent("");
|
setContent("");
|
||||||
@@ -154,10 +204,10 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
if (error) return <p className="error-text">{error}</p>;
|
if (error) return <p className="error-text">{error}</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="file-editor">
|
<div className={styles.fileEditor}>
|
||||||
<div className="file-editor-toolbar">
|
<div className={styles.fileEditorToolbar}>
|
||||||
<div className="file-breadcrumbs">
|
<div className="file-breadcrumbs">
|
||||||
{filePath.split("/").map((part, i, arr) => (
|
{filePath?.split("/").map((part, i, arr) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
{part}
|
{part}
|
||||||
{i < arr.length - 1 && (
|
{i < arr.length - 1 && (
|
||||||
@@ -166,7 +216,12 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="file-actions">
|
<div className={styles.fileActions}>
|
||||||
|
{fileStatus && (
|
||||||
|
<span className={`git-status-badge ${fileStatus}`} title={fileStatus}>
|
||||||
|
{fileStatus === "modified" ? "M" : fileStatus === "added" ? "A" : fileStatus === "deleted" ? "D" : "?"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{mode === "view" && !isBinary && (
|
{mode === "view" && !isBinary && (
|
||||||
<button
|
<button
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
@@ -197,6 +252,15 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={handleDiscard}
|
||||||
|
type="button"
|
||||||
|
title="Revert to last committed version"
|
||||||
|
>
|
||||||
|
<Icon name="undo" size="sm" />
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-secondary"
|
className="btn-secondary"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
@@ -210,7 +274,7 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="file-editor-content">
|
<div className={styles.fileEditorContent}>
|
||||||
{mode === "view" && (
|
{mode === "view" && (
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
code={content}
|
code={content}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
.fileViewer {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileViewerHeader {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileBreadcrumbs {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbSep {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileContent {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileContent pre {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileViewerEmpty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
.gitToolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarGroup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButton:hover:not(:disabled) {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButton:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButtonPrimary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branchSelect {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarError {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarInput {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newBranchForm {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusSummary {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadgeModified {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadgeAdded {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadgeDeleted {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadgeUntracked {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
+27
-26
@@ -8,9 +8,10 @@ import {
|
|||||||
pullRepository,
|
pullRepository,
|
||||||
pushRepository,
|
pushRepository,
|
||||||
type GitStatus,
|
type GitStatus,
|
||||||
} from "../api/git_repositories";
|
} from "../../../api/git-repositories";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../ui/Icon";
|
||||||
import { MergeDialog } from "./merge-dialog";
|
import { MergeDialog } from "./MergeDialog";
|
||||||
|
import styles from "./GitToolbar.module.css";
|
||||||
|
|
||||||
interface GitToolbarProps {
|
interface GitToolbarProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -134,16 +135,16 @@ export const GitToolbar = ({
|
|||||||
const canSync = hasRemote;
|
const canSync = hasRemote;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-toolbar">
|
<div className={styles.gitToolbar}>
|
||||||
{error && <div className="toolbar-error">{error}</div>}
|
{error && <div className={styles.toolbarError}>{error}</div>}
|
||||||
|
|
||||||
<div className="toolbar-row">
|
<div className={styles.toolbarRow}>
|
||||||
<div className="toolbar-group">
|
<div className={styles.toolbarGroup}>
|
||||||
<select
|
<select
|
||||||
value={currentBranch}
|
value={currentBranch}
|
||||||
onChange={(e) => handleCheckout(e.target.value)}
|
onChange={(e) => handleCheckout(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="branch-select"
|
className={styles.branchSelect}
|
||||||
>
|
>
|
||||||
{branches.map((b) => (
|
{branches.map((b) => (
|
||||||
<option key={b} value={b}>
|
<option key={b} value={b}>
|
||||||
@@ -158,7 +159,7 @@ export const GitToolbar = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={() => setShowNewBranch(!showNewBranch)}
|
onClick={() => setShowNewBranch(!showNewBranch)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -167,9 +168,9 @@ export const GitToolbar = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-group">
|
<div className={styles.toolbarGroup}>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={handleFetch}
|
onClick={handleFetch}
|
||||||
disabled={loading || !canSync}
|
disabled={loading || !canSync}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -177,25 +178,25 @@ export const GitToolbar = ({
|
|||||||
<Icon name="fetch" size="sm" /> Fetch
|
<Icon name="fetch" size="sm" /> Fetch
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={handlePull}
|
onClick={handlePull}
|
||||||
disabled={loading || !canSync}
|
disabled={loading || !canSync}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="pull" size="sm" /> Pull
|
<Icon name="pull" size="sm" /> Pull
|
||||||
{status?.behind ? <span className="badge">{status.behind}</span> : null}
|
{status?.behind ? <span className={styles.badge}>{status.behind}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={handlePush}
|
onClick={handlePush}
|
||||||
disabled={loading || !canSync || !status?.ahead}
|
disabled={loading || !canSync || !status?.ahead}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="push" size="sm" /> Push
|
<Icon name="push" size="sm" /> Push
|
||||||
{status?.ahead ? <span className="badge">{status.ahead}</span> : null}
|
{status?.ahead ? <span className={styles.badge}>{status.ahead}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={() => setShowMergeDialog(true)}
|
onClick={() => setShowMergeDialog(true)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -206,18 +207,18 @@ export const GitToolbar = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNewBranch && (
|
{showNewBranch && (
|
||||||
<div className="toolbar-row new-branch-form">
|
<div className={`${styles.toolbarRow} ${styles.newBranchForm}`}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Branch name"
|
placeholder="Branch name"
|
||||||
value={newBranchName}
|
value={newBranchName}
|
||||||
onChange={(e) => setNewBranchName(e.target.value)}
|
onChange={(e) => setNewBranchName(e.target.value)}
|
||||||
className="toolbar-input"
|
className={styles.toolbarInput}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={newBranchBase}
|
value={newBranchBase}
|
||||||
onChange={(e) => setNewBranchBase(e.target.value)}
|
onChange={(e) => setNewBranchBase(e.target.value)}
|
||||||
className="toolbar-input"
|
className={styles.toolbarInput}
|
||||||
>
|
>
|
||||||
<option value="">Base: HEAD</option>
|
<option value="">Base: HEAD</option>
|
||||||
{branches.map((b) => (
|
{branches.map((b) => (
|
||||||
@@ -225,7 +226,7 @@ export const GitToolbar = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button primary"
|
className={styles.toolbarButtonPrimary}
|
||||||
onClick={handleCreateBranch}
|
onClick={handleCreateBranch}
|
||||||
disabled={loading || !newBranchName.trim()}
|
disabled={loading || !newBranchName.trim()}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -233,7 +234,7 @@ export const GitToolbar = ({
|
|||||||
<Icon name="add" size="sm" /> Create
|
<Icon name="add" size="sm" /> Create
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className={styles.toolbarButton}
|
||||||
onClick={() => setShowNewBranch(false)}
|
onClick={() => setShowNewBranch(false)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -243,11 +244,11 @@ export const GitToolbar = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasChanges && status && (
|
{hasChanges && status && (
|
||||||
<div className="toolbar-row status-summary">
|
<div className={`${styles.toolbarRow} ${styles.statusSummary}`}>
|
||||||
{status.modified.length > 0 && <span className="status-badge modified"><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
|
{status.modified.length > 0 && <span className={styles.statusBadgeModified}><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
|
||||||
{status.added.length > 0 && <span className="status-badge added"><Icon name="add" size="sm" /> {status.added.length} added</span>}
|
{status.added.length > 0 && <span className={styles.statusBadgeAdded}><Icon name="add" size="sm" /> {status.added.length} added</span>}
|
||||||
{status.deleted.length > 0 && <span className="status-badge deleted"><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
|
{status.deleted.length > 0 && <span className={styles.statusBadgeDeleted}><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
|
||||||
{status.untracked.length > 0 && <span className="status-badge untracked"><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
|
{status.untracked.length > 0 && <span className={styles.statusBadgeUntracked}><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
.mergeForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeForm .formField {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeForm label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeForm select,
|
||||||
|
.mergeForm input,
|
||||||
|
.mergeForm textarea {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeForm textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputDisabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.successText {
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
+9
-8
@@ -1,7 +1,8 @@
|
|||||||
|
import styles from "./MergeDialog.module.css";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { mergeBranches } from "../api/git_repositories";
|
import { mergeBranches } from "../../../api/git-repositories";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../ui/Icon";
|
||||||
|
|
||||||
interface MergeDialogProps {
|
interface MergeDialogProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -65,8 +66,8 @@ export const MergeDialog = ({
|
|||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2>Merge Branch</h2>
|
<h2>Merge Branch</h2>
|
||||||
|
|
||||||
<div className="merge-form">
|
<div className={styles.mergeForm}>
|
||||||
<div className="form-field">
|
<div className={styles.formField}>
|
||||||
<label>Source Branch (merge from)</label>
|
<label>Source Branch (merge from)</label>
|
||||||
<select
|
<select
|
||||||
value={sourceBranch}
|
value={sourceBranch}
|
||||||
@@ -82,17 +83,17 @@ export const MergeDialog = ({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-field">
|
<div className={styles.formField}>
|
||||||
<label>Target Branch (merge into)</label>
|
<label>Target Branch (merge into)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={currentBranch}
|
value={currentBranch}
|
||||||
disabled
|
disabled
|
||||||
className="input-disabled"
|
className={styles.inputDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-field">
|
<div className={styles.formField}>
|
||||||
<label>Commit Message (optional)</label>
|
<label>Commit Message (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={commitMessage}
|
value={commitMessage}
|
||||||
@@ -105,7 +106,7 @@ export const MergeDialog = ({
|
|||||||
|
|
||||||
{error && <div className="error-text">{error}</div>}
|
{error && <div className="error-text">{error}</div>}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="success-text">Merge successful!</div>
|
<div className={styles.successText}>Merge successful!</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "../../ui/Icon";
|
||||||
import { highlightCode, loadLanguage } from "../utils/language";
|
import { highlightCode, loadLanguage } from "../../../utils/language";
|
||||||
|
|
||||||
interface SyntaxHighlighterProps {
|
interface SyntaxHighlighterProps {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { GitRepository } from "../../../types/git-repository";
|
||||||
|
import type { GitStatus } from "../../../api/git-repositories";
|
||||||
|
import type { ToolType } from "../../../types/tool-type";
|
||||||
|
import { FileBrowser } from "./FileBrowser";
|
||||||
|
import { CommitPanel } from "../git/CommitPanel";
|
||||||
|
import { InstanceList } from "../session/InstanceList";
|
||||||
|
|
||||||
|
interface WorkspaceSidebarProps {
|
||||||
|
projectId: string;
|
||||||
|
repoId: string;
|
||||||
|
repositories: GitRepository[];
|
||||||
|
gitStatus: GitStatus | null;
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
onRepoChange: (repoId: string) => void;
|
||||||
|
onCommit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceSidebar = ({
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
repositories,
|
||||||
|
gitStatus,
|
||||||
|
toolTypes,
|
||||||
|
onRepoChange,
|
||||||
|
onCommit,
|
||||||
|
}: WorkspaceSidebarProps) => {
|
||||||
|
return (
|
||||||
|
<aside className="workspace-sidebar">
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select value={repoId} onChange={(e) => onRepoChange(e.target.value)}>
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<option key={repo.id} value={repo.id}>
|
||||||
|
{repo.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<FileBrowser
|
||||||
|
projectId={projectId}
|
||||||
|
repoId={repoId}
|
||||||
|
gitStatus={gitStatus}
|
||||||
|
/>
|
||||||
|
{gitStatus && (
|
||||||
|
<CommitPanel
|
||||||
|
projectId={projectId}
|
||||||
|
repoId={repoId}
|
||||||
|
modified={gitStatus.modified}
|
||||||
|
added={gitStatus.added}
|
||||||
|
deleted={gitStatus.deleted}
|
||||||
|
untracked={gitStatus.untracked}
|
||||||
|
onCommit={onCommit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<InstanceList
|
||||||
|
projectId={projectId}
|
||||||
|
repoId={repoId}
|
||||||
|
toolTypes={toolTypes}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { FileBrowser } from "./FileBrowser";
|
||||||
|
export { WorkspaceSidebar } from "./WorkspaceSidebar";
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
|
import { RepositoriesSettingsTab } from "./RepositoriesSettingsTab";
|
||||||
import * as gitRepositoriesApi from "../api/git_repositories";
|
import * as gitRepositoriesApi from "../../../api/git-repositories";
|
||||||
|
|
||||||
const mockRepositories = [
|
const mockRepositories = [
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import type { GitRepository } from "../../../types/git-repository";
|
||||||
|
import { deleteRepository, listRepositories } from "../../../api/git-repositories";
|
||||||
|
import { RepositoryCreateDialog } from "./RepositoryCreateDialog";
|
||||||
|
import { Icon } from "../../ui/Icon";
|
||||||
|
|
||||||
|
export const RepositoriesSettingsTab: React.FC = () => {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const loadRepositories = useCallback(async () => {
|
||||||
|
if (!projectId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await listRepositories(projectId);
|
||||||
|
setRepositories(data);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load repositories");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRepositories();
|
||||||
|
}, [loadRepositories]);
|
||||||
|
|
||||||
|
const handleDelete = async (repoId: string) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
if (!window.confirm("Are you sure you want to delete this repository?"))
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
await deleteRepository(projectId, repoId);
|
||||||
|
setRepositories((current) => current.filter((r) => r.id !== repoId));
|
||||||
|
} catch {
|
||||||
|
setError("Failed to delete repository");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="repositories-settings-tab">
|
||||||
|
<div className="page-header">
|
||||||
|
<h2>Repositories</h2>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add Repository
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
<div className="repositories-list">
|
||||||
|
{repositories.length === 0 ? (
|
||||||
|
<p>No repositories yet.</p>
|
||||||
|
) : (
|
||||||
|
repositories.map((repo) => (
|
||||||
|
<div key={repo.id} className="repository-card">
|
||||||
|
<div className="repository-info">
|
||||||
|
<h3>{repo.name}</h3>
|
||||||
|
<p>{repo.remote_url}</p>
|
||||||
|
<span className="repo-type">
|
||||||
|
{repo.is_mirror ? "Mirror" : "Clone"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(repo.id)}
|
||||||
|
className="btn-danger"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<RepositoryCreateDialog
|
||||||
|
projectId={projectId!}
|
||||||
|
open={showCreate}
|
||||||
|
title="Add Repository"
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onCreated={loadRepositories}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GitRepositoryCreate,
|
||||||
|
URLParseResult,
|
||||||
|
} from "../../../types/git-repository";
|
||||||
|
import { createRepository, parseGitUrl } from "../../../api/git-repositories";
|
||||||
|
import { Icon } from "../../ui/Icon";
|
||||||
|
|
||||||
|
type CreateMode = "clone" | "blank";
|
||||||
|
type UrlValidationStatus =
|
||||||
|
| "idle"
|
||||||
|
| "validating"
|
||||||
|
| "valid"
|
||||||
|
| "needs-parsing"
|
||||||
|
| "invalid";
|
||||||
|
|
||||||
|
interface RepositoryCreateDialogProps {
|
||||||
|
projectId: string;
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RepositoryCreateDialog = ({
|
||||||
|
projectId,
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: RepositoryCreateDialogProps) => {
|
||||||
|
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||||
|
const [formName, setFormName] = useState("");
|
||||||
|
const [owner, setOwner] = useState("");
|
||||||
|
const [repoName, setRepoName] = useState("");
|
||||||
|
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||||
|
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [urlValidation, setUrlValidation] = useState<{
|
||||||
|
status: UrlValidationStatus;
|
||||||
|
result: URLParseResult | null;
|
||||||
|
}>({ status: "idle", result: null });
|
||||||
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open && debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
debounceTimer.current = null;
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (!useAdvancedUrl) {
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!advancedUrl.trim()) {
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrlValidation({ status: "validating", result: null });
|
||||||
|
|
||||||
|
debounceTimer.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await parseGitUrl(advancedUrl.trim());
|
||||||
|
if (result.is_valid_clone_url) {
|
||||||
|
setUrlValidation({ status: "valid", result });
|
||||||
|
} else if (result.needs_parsing) {
|
||||||
|
setUrlValidation({ status: "needs-parsing", result });
|
||||||
|
} else {
|
||||||
|
setUrlValidation({ status: "invalid", result });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUrlValidation({ status: "invalid", result: null });
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [advancedUrl, open, useAdvancedUrl]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setCreateMode("clone");
|
||||||
|
setFormName("");
|
||||||
|
setOwner("");
|
||||||
|
setRepoName("");
|
||||||
|
setAdvancedUrl("");
|
||||||
|
setUseAdvancedUrl(false);
|
||||||
|
setFormError(null);
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
if (!formName.trim()) {
|
||||||
|
setFormError("Repository name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input: GitRepositoryCreate = {
|
||||||
|
name: formName.trim(),
|
||||||
|
remote_url: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createMode === "clone") {
|
||||||
|
if (useAdvancedUrl) {
|
||||||
|
if (!advancedUrl.trim()) {
|
||||||
|
setFormError("Remote URL is required for advanced cloning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.remote_url = advancedUrl.trim();
|
||||||
|
} else {
|
||||||
|
if (!owner.trim() || !repoName.trim()) {
|
||||||
|
setFormError("Owner and repository name are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await createRepository(projectId, input);
|
||||||
|
handleClose();
|
||||||
|
await onCreated();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const response = error as { response?: { data?: { detail?: string } } };
|
||||||
|
const detail = response.response?.data?.detail;
|
||||||
|
setFormError(
|
||||||
|
typeof detail === "string" ? detail : "Failed to create repository",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseSuggestedUrl = () => {
|
||||||
|
if (urlValidation.result?.base_url) {
|
||||||
|
setAdvancedUrl(urlValidation.result.base_url);
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUrlInputClass = () => {
|
||||||
|
switch (urlValidation.status) {
|
||||||
|
case "valid":
|
||||||
|
return "valid-url";
|
||||||
|
case "needs-parsing":
|
||||||
|
return "needs-parsing-url";
|
||||||
|
case "invalid":
|
||||||
|
return "invalid-url";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="dialog">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Clone an existing repository from git.commumedia.org, or create a
|
||||||
|
blank bare repo here.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="stack">
|
||||||
|
<div className="form-field">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="repository-mode"
|
||||||
|
checked={createMode === "clone"}
|
||||||
|
onChange={() => setCreateMode("clone")}
|
||||||
|
/>
|
||||||
|
Clone existing repository
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="repository-mode"
|
||||||
|
checked={createMode === "blank"}
|
||||||
|
onChange={() => setCreateMode("blank")}
|
||||||
|
/>
|
||||||
|
Create blank repository
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="form-field">
|
||||||
|
Repository name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formName}
|
||||||
|
onChange={(event) => setFormName(event.target.value)}
|
||||||
|
placeholder="repository-name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{createMode === "clone" && !useAdvancedUrl && (
|
||||||
|
<>
|
||||||
|
<label className="form-field">
|
||||||
|
Owner
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={owner}
|
||||||
|
onChange={(event) => setOwner(event.target.value)}
|
||||||
|
placeholder="owner"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={repoName}
|
||||||
|
onChange={(event) => setRepoName(event.target.value)}
|
||||||
|
placeholder="repo-name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="muted">
|
||||||
|
SSH target: git@git.commumedia.org:{owner || "owner"}/
|
||||||
|
{repoName || "repo"}.git
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => setUseAdvancedUrl(true)}
|
||||||
|
>
|
||||||
|
Use full URL instead
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{createMode === "clone" && useAdvancedUrl && (
|
||||||
|
<label className="form-field">
|
||||||
|
Remote URL
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={advancedUrl}
|
||||||
|
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||||
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
className={getUrlInputClass()}
|
||||||
|
/>
|
||||||
|
{urlValidation.status === "validating" && (
|
||||||
|
<span className="validation-status validating">
|
||||||
|
Validating...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "valid" && (
|
||||||
|
<span className="validation-status valid">
|
||||||
|
<Icon name="success" size="sm" /> Valid git URL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "needs-parsing" &&
|
||||||
|
urlValidation.result && (
|
||||||
|
<div className="url-suggestion">
|
||||||
|
<span className="validation-status warning">
|
||||||
|
<Icon name="warning" size="sm" /> This looks like a
|
||||||
|
browser URL
|
||||||
|
</span>
|
||||||
|
<div className="suggestion-actions">
|
||||||
|
<span className="suggested-url">
|
||||||
|
Suggested: {urlValidation.result.base_url}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={handleUseSuggestedUrl}
|
||||||
|
>
|
||||||
|
Use Suggested
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "invalid" && (
|
||||||
|
<span className="validation-status invalid">
|
||||||
|
<Icon name="error" size="sm" /> Invalid URL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => setUseAdvancedUrl(false)}
|
||||||
|
>
|
||||||
|
Use owner/repo instead
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{formError && (
|
||||||
|
<div className="error-message">
|
||||||
|
<p className="error-text">{formError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={handleClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="cancel" size="sm" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
{createMode === "clone"
|
||||||
|
? "Clone Repository"
|
||||||
|
: "Create Blank Repository"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { CreateSessionForm } from "./CreateSessionForm";
|
||||||
|
import type { Project } from "@/types/project";
|
||||||
|
import type { ToolType } from "@/types/tool-type";
|
||||||
|
|
||||||
|
vi.mock("@/api/git_repositories", () => ({
|
||||||
|
listRepositories: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/api/sessions", () => ({
|
||||||
|
createInstance: vi.fn(),
|
||||||
|
startInstance: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/api/settings", () => ({
|
||||||
|
updateUserConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { listRepositories } from "@/api/git-repositories";
|
||||||
|
import { createInstance } from "@/api/sessions";
|
||||||
|
|
||||||
|
const mockProjects = [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
name: "Project One",
|
||||||
|
description: null,
|
||||||
|
owner_id: "u1",
|
||||||
|
default_ssh_key_id: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p2",
|
||||||
|
name: "Project Two",
|
||||||
|
description: null,
|
||||||
|
owner_id: "u1",
|
||||||
|
default_ssh_key_id: null,
|
||||||
|
},
|
||||||
|
] as Project[];
|
||||||
|
|
||||||
|
const mockToolTypes = [
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
name: "vscode",
|
||||||
|
display_name: "VS Code",
|
||||||
|
description: null,
|
||||||
|
category: "editor",
|
||||||
|
interfaces: ["web"],
|
||||||
|
default_port: 8443,
|
||||||
|
definition_type: "compose",
|
||||||
|
compose_template: "",
|
||||||
|
dockerfile_template: null,
|
||||||
|
readiness_probe: null,
|
||||||
|
required_variables: [],
|
||||||
|
is_builtin: true,
|
||||||
|
build_context: null,
|
||||||
|
created_by_id: "u1",
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "t2",
|
||||||
|
name: "terminal",
|
||||||
|
display_name: "Terminal",
|
||||||
|
description: null,
|
||||||
|
category: "shell",
|
||||||
|
interfaces: ["terminal"],
|
||||||
|
default_port: 22,
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
compose_template: null,
|
||||||
|
dockerfile_template: "",
|
||||||
|
readiness_probe: null,
|
||||||
|
required_variables: [],
|
||||||
|
is_builtin: true,
|
||||||
|
build_context: null,
|
||||||
|
created_by_id: "u1",
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
},
|
||||||
|
] as ToolType[];
|
||||||
|
|
||||||
|
describe("CreateSessionForm", () => {
|
||||||
|
it("renders form with create button", () => {
|
||||||
|
render(
|
||||||
|
<CreateSessionForm
|
||||||
|
projects={mockProjects}
|
||||||
|
toolTypes={mockToolTypes}
|
||||||
|
onCreated={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Create New Session")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Create Session")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows validation error when fields are missing", async () => {
|
||||||
|
render(
|
||||||
|
<CreateSessionForm
|
||||||
|
projects={mockProjects}
|
||||||
|
toolTypes={mockToolTypes}
|
||||||
|
onCreated={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<CreateSessionForm
|
||||||
|
projects={mockProjects}
|
||||||
|
toolTypes={mockToolTypes}
|
||||||
|
onCreated={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitBtn = container.querySelector(
|
||||||
|
'button[type="submit"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/project, repository, and tool type are required/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createInstance).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads repositories when project selected", async () => {
|
||||||
|
const mockedList = listRepositories as ReturnType<typeof vi.fn>;
|
||||||
|
mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<CreateSessionForm
|
||||||
|
projects={mockProjects}
|
||||||
|
toolTypes={mockToolTypes}
|
||||||
|
onCreated={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectSelect = container.querySelector(
|
||||||
|
"select",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
fireEvent.change(projectSelect, { target: { value: "p1" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(listRepositories).toHaveBeenCalledWith("p1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { listRepositories } from "@/api/git-repositories";
|
||||||
|
import { createInstance, startInstance } from "@/api/sessions";
|
||||||
|
import { updateUserConfig } from "@/api/settings";
|
||||||
|
import { Icon } from "@/components/ui/Icon";
|
||||||
|
import type { Project } from "@/types/project";
|
||||||
|
import type { GitRepository } from "@/types/git-repository";
|
||||||
|
import type { ToolType } from "@/types/tool-type";
|
||||||
|
|
||||||
|
interface CreateSessionFormProps {
|
||||||
|
projects: Project[];
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateStatus = "idle" | "creating" | "error";
|
||||||
|
|
||||||
|
export const CreateSessionForm: React.FC<CreateSessionFormProps> = ({
|
||||||
|
projects,
|
||||||
|
toolTypes,
|
||||||
|
onCreated,
|
||||||
|
}) => {
|
||||||
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState("");
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProject) {
|
||||||
|
setRepositories([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadRepos = async () => {
|
||||||
|
try {
|
||||||
|
const data = await listRepositories(selectedProject);
|
||||||
|
setRepositories(data);
|
||||||
|
} catch {
|
||||||
|
setRepositories([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadRepos();
|
||||||
|
}, [selectedProject]);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCreateError(null);
|
||||||
|
|
||||||
|
if (!selectedProject || !selectedRepo || !selectedToolType) {
|
||||||
|
setCreateError("Project, repository, and tool type are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateStatus("creating");
|
||||||
|
try {
|
||||||
|
const instance = await createInstance(
|
||||||
|
selectedProject,
|
||||||
|
selectedRepo,
|
||||||
|
selectedToolType,
|
||||||
|
displayName || undefined,
|
||||||
|
);
|
||||||
|
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||||
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
|
setCreateStatus("idle");
|
||||||
|
setSelectedProject("");
|
||||||
|
setSelectedRepo("");
|
||||||
|
setSelectedToolType("");
|
||||||
|
setDisplayName("");
|
||||||
|
onCreated();
|
||||||
|
} catch {
|
||||||
|
setCreateStatus("error");
|
||||||
|
setCreateError("Failed to create session");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="create-session-section">
|
||||||
|
<h2>Create New Session</h2>
|
||||||
|
<form onSubmit={handleCreate} className="card stack create-session-form">
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
Project
|
||||||
|
<select
|
||||||
|
value={selectedProject}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedProject(e.target.value);
|
||||||
|
setSelectedRepo("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select
|
||||||
|
value={selectedRepo}
|
||||||
|
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||||
|
disabled={!selectedProject}
|
||||||
|
>
|
||||||
|
<option value="">Select repository...</option>
|
||||||
|
{repositories.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Tool Type
|
||||||
|
<select
|
||||||
|
value={selectedToolType}
|
||||||
|
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select tool...</option>
|
||||||
|
{toolTypes.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Display Name (optional)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="My Development Environment"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{createError && <p className="error-text">{createError}</p>}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={createStatus === "creating"}
|
||||||
|
>
|
||||||
|
{createStatus === "creating" ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Create Session
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
.instanceList {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceListHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceListHeader h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceCard {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceActions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineConfirm {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Icon } from "../../ui/Icon";
|
||||||
|
import type { ToolInstance } from "../../../types/tool-instance";
|
||||||
|
import type { ToolType } from "../../../types/tool-type";
|
||||||
|
import {
|
||||||
|
checkInstanceHealth,
|
||||||
|
createInstance,
|
||||||
|
deleteInstance,
|
||||||
|
listInstances,
|
||||||
|
recreateInstanceTunnel,
|
||||||
|
restartInstance,
|
||||||
|
startInstance,
|
||||||
|
stopInstance,
|
||||||
|
} from "../../../api/sessions";
|
||||||
|
import styles from "./InstanceList.module.css";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
|
interface InstanceListProps {
|
||||||
|
projectId: string;
|
||||||
|
repoId: string;
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InstanceList = ({
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
toolTypes,
|
||||||
|
}: InstanceListProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Stop confirmation
|
||||||
|
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Health check state
|
||||||
|
const [healthStatus, setHealthStatus] = useState<
|
||||||
|
Record<string, { healthy: boolean; lastCheck: number }>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const loadInstances = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await listInstances(projectId, repoId);
|
||||||
|
setInstances(data);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load instances");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId, repoId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadInstances();
|
||||||
|
}, [loadInstances]);
|
||||||
|
|
||||||
|
// Health check polling
|
||||||
|
useEffect(() => {
|
||||||
|
const runningInstances = instances.filter(
|
||||||
|
(i) => i.status === "running" && i.url?.startsWith("http"),
|
||||||
|
);
|
||||||
|
if (runningInstances.length === 0) return;
|
||||||
|
|
||||||
|
const checkHealth = async () => {
|
||||||
|
for (const instance of runningInstances) {
|
||||||
|
try {
|
||||||
|
const health = await checkInstanceHealth(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instance.id,
|
||||||
|
);
|
||||||
|
setHealthStatus((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() },
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setHealthStatus((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[instance.id]: { healthy: false, lastCheck: Date.now() },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately
|
||||||
|
void checkHealth();
|
||||||
|
|
||||||
|
// Then every 30 seconds
|
||||||
|
const interval = setInterval(() => void checkHealth(), 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [instances, projectId, repoId]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!selectedToolType) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await createInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
selectedToolType,
|
||||||
|
displayName || undefined,
|
||||||
|
);
|
||||||
|
setShowCreate(false);
|
||||||
|
setSelectedToolType("");
|
||||||
|
setDisplayName("");
|
||||||
|
await loadInstances();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to create instance");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = async (instanceId: string) => {
|
||||||
|
try {
|
||||||
|
await startInstance(projectId, repoId, instanceId);
|
||||||
|
await loadInstances();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to start instance");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async (instanceId: string) => {
|
||||||
|
try {
|
||||||
|
await stopInstance(projectId, repoId, instanceId);
|
||||||
|
setStopConfirmId(null);
|
||||||
|
await loadInstances();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to stop instance");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestart = async (instanceId: string) => {
|
||||||
|
try {
|
||||||
|
await restartInstance(projectId, repoId, instanceId);
|
||||||
|
await loadInstances();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to restart instance");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (instanceId: string) => {
|
||||||
|
if (!confirm("Are you sure you want to delete this instance?")) return;
|
||||||
|
try {
|
||||||
|
await deleteInstance(projectId, repoId, instanceId);
|
||||||
|
// Update state immediately instead of reloading
|
||||||
|
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
|
||||||
|
} catch {
|
||||||
|
setError("Failed to delete instance");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecreateTunnel = async (instanceId: string) => {
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(projectId, repoId, instanceId);
|
||||||
|
await loadInstances();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to recreate tunnel");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "running":
|
||||||
|
return "var(--success)";
|
||||||
|
case "error":
|
||||||
|
return "var(--danger)";
|
||||||
|
case "pending":
|
||||||
|
case "building":
|
||||||
|
return "var(--warning)";
|
||||||
|
default:
|
||||||
|
return "var(--muted)";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTunnelUnhealthy = (instance: ToolInstance) => {
|
||||||
|
if (instance.status !== "running") return false;
|
||||||
|
if (!instance.url?.startsWith("http")) return false;
|
||||||
|
const health = healthStatus[instance.id];
|
||||||
|
if (!health) return false;
|
||||||
|
return !health.healthy;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.instanceList}>
|
||||||
|
<div className={styles.instanceListHeader}>
|
||||||
|
<h3>Tool Instances</h3>
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Launch Tool
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="muted">Loading instances...</p>
|
||||||
|
) : instances.length === 0 ? (
|
||||||
|
<p className="muted">No instances yet. Launch a tool to get started.</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.instanceGrid}>
|
||||||
|
{instances.map((instance) => (
|
||||||
|
<div key={instance.id} className={styles.instanceCard}>
|
||||||
|
<div className={styles.instanceInfo}>
|
||||||
|
<div className={styles.instanceName}>
|
||||||
|
{instance.display_name ||
|
||||||
|
instance.tool_type_name ||
|
||||||
|
"Unnamed Instance"}
|
||||||
|
</div>
|
||||||
|
<div className={styles.instanceMeta}>
|
||||||
|
<span
|
||||||
|
className={styles.statusDot}
|
||||||
|
style={{ backgroundColor: getStatusColor(instance.status) }}
|
||||||
|
/>
|
||||||
|
{instance.status}
|
||||||
|
{isTunnelUnhealthy(instance) && (
|
||||||
|
<span
|
||||||
|
className={styles.errorBadge}
|
||||||
|
title="Tunnel unreachable"
|
||||||
|
>
|
||||||
|
<Icon name="warning" size="sm" />
|
||||||
|
tunnel error
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.instanceActions}>
|
||||||
|
{instance.status === "running" &&
|
||||||
|
instance.url &&
|
||||||
|
instance.tool_type_interfaces.includes("web") && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={
|
||||||
|
instance.url.startsWith("http")
|
||||||
|
? instance.url
|
||||||
|
: `${API_BASE_URL}${instance.url}`
|
||||||
|
}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="secondary-button small"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
{isTunnelUnhealthy(instance) && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small warning"
|
||||||
|
onClick={() => void handleRecreateTunnel(instance.id)}
|
||||||
|
type="button"
|
||||||
|
title="Recreate tunnel"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Fix Tunnel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{instance.status === "running" &&
|
||||||
|
instance.tool_type_interfaces.includes("terminal") && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/instances/${instance.id}/terminal`)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="terminal" size="sm" />
|
||||||
|
Terminal
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{instance.status !== "running" && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => void handleStart(instance.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{instance.status === "running" && (
|
||||||
|
<>
|
||||||
|
{stopConfirmId === instance.id ? (
|
||||||
|
<div className={styles.inlineConfirm}>
|
||||||
|
<span>Stop?</span>
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={() => void handleStop(instance.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => setStopConfirmId(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => setStopConfirmId(instance.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => void handleRestart(instance.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={() => void handleDelete(instance.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="dialog">
|
||||||
|
<h2>Launch Tool</h2>
|
||||||
|
<div className="stack">
|
||||||
|
<label className="form-field">
|
||||||
|
Tool Type
|
||||||
|
<select
|
||||||
|
value={selectedToolType}
|
||||||
|
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select a tool...</option>
|
||||||
|
{toolTypes.map((tool) => (
|
||||||
|
<option key={tool.id} value={tool.id}>
|
||||||
|
{tool.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Display Name (optional)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="My Development Environment"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => setShowCreate(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
onClick={() => void handleCreate()}
|
||||||
|
disabled={!selectedToolType}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Launch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { SessionCard } from "./SessionCard";
|
||||||
|
import type { Session } from "@/types/session";
|
||||||
|
|
||||||
|
const mockSession: Session = {
|
||||||
|
id: "s1",
|
||||||
|
display_name: "Dev Environment",
|
||||||
|
tool_type_name: "VS Code",
|
||||||
|
tool_icon: "code",
|
||||||
|
tool_type_interfaces: ["web", "terminal"],
|
||||||
|
repository_name: "my-repo",
|
||||||
|
repository_id: "r1",
|
||||||
|
project_name: "My Project",
|
||||||
|
project_id: "p1",
|
||||||
|
status: "running",
|
||||||
|
url: "https://example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SessionCard", () => {
|
||||||
|
it("renders active variant with display name and status", () => {
|
||||||
|
render(
|
||||||
|
<SessionCard
|
||||||
|
session={mockSession}
|
||||||
|
variant="active"
|
||||||
|
onOpen={vi.fn()}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onRecreateTunnel={vi.fn()}
|
||||||
|
onCancelStop={vi.fn()}
|
||||||
|
onCancelDelete={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("running")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getAllByText("Dev Environment").length,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders recent variant with display name", () => {
|
||||||
|
render(
|
||||||
|
<SessionCard
|
||||||
|
session={{ ...mockSession, status: "stopped" }}
|
||||||
|
variant="recent"
|
||||||
|
onOpen={vi.fn()}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onRecreateTunnel={vi.fn()}
|
||||||
|
onCancelStop={vi.fn()}
|
||||||
|
onCancelDelete={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getAllByText("Dev Environment").length,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows unnamed fallback when display_name is empty", () => {
|
||||||
|
render(
|
||||||
|
<SessionCard
|
||||||
|
session={{ ...mockSession, display_name: "" }}
|
||||||
|
variant="active"
|
||||||
|
onOpen={vi.fn()}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onRecreateTunnel={vi.fn()}
|
||||||
|
onCancelStop={vi.fn()}
|
||||||
|
onCancelDelete={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("VS Code")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon } from "@/components/ui/Icon";
|
||||||
|
import type { Session } from "@/types/session";
|
||||||
|
|
||||||
|
interface SessionCardProps {
|
||||||
|
session: Session;
|
||||||
|
variant: "active" | "recent";
|
||||||
|
tunnelHealth?: {
|
||||||
|
healthy: boolean;
|
||||||
|
status_code: number | null;
|
||||||
|
error?: string;
|
||||||
|
} | null;
|
||||||
|
isRecreating?: boolean;
|
||||||
|
isStopConfirming?: boolean;
|
||||||
|
isDeleteConfirming?: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onRecreateTunnel: () => void;
|
||||||
|
onCancelStop: () => void;
|
||||||
|
onCancelDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionCard: React.FC<SessionCardProps> = ({
|
||||||
|
session,
|
||||||
|
variant,
|
||||||
|
tunnelHealth,
|
||||||
|
isRecreating,
|
||||||
|
isStopConfirming,
|
||||||
|
isDeleteConfirming,
|
||||||
|
onOpen,
|
||||||
|
onStop,
|
||||||
|
onDelete,
|
||||||
|
onRecreateTunnel,
|
||||||
|
onCancelStop,
|
||||||
|
onCancelDelete,
|
||||||
|
}) => {
|
||||||
|
const displayName =
|
||||||
|
session.display_name || session.tool_type_name || "Unnamed Session";
|
||||||
|
|
||||||
|
if (variant === "recent") {
|
||||||
|
return (
|
||||||
|
<div className="recent-session-item" key={session.id}>
|
||||||
|
<div className="recent-session-info">
|
||||||
|
<span className="recent-session-name">{displayName}</span>
|
||||||
|
<span className="muted">
|
||||||
|
{session.tool_type_name} · {session.project_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="recent-session-actions">
|
||||||
|
{session.url ? (
|
||||||
|
<a
|
||||||
|
href={session.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ghost-button small"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={onOpen}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isDeleteConfirming ? (
|
||||||
|
<div className="delete-confirm-inline">
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={onDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={onCancelDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={onDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active variant
|
||||||
|
return (
|
||||||
|
<div className="card session-card" key={session.id}>
|
||||||
|
<div className="session-info">
|
||||||
|
<h4>{displayName}</h4>
|
||||||
|
<p className="muted">
|
||||||
|
{session.tool_type_name} · {session.project_name}
|
||||||
|
</p>
|
||||||
|
{session.url && (
|
||||||
|
<p className="session-url">
|
||||||
|
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{session.url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span className={`status-badge ${session.status}`}>
|
||||||
|
{session.status}
|
||||||
|
</span>
|
||||||
|
{tunnelHealth && !tunnelHealth.healthy && (
|
||||||
|
<span className="status-badge error">tunnel error</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="session-actions">
|
||||||
|
{session.url ? (
|
||||||
|
<a
|
||||||
|
href={session.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="secondary-button small"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={onOpen}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{tunnelHealth && !tunnelHealth.healthy && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={onRecreateTunnel}
|
||||||
|
type="button"
|
||||||
|
disabled={isRecreating}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
{isRecreating ? "Recreating..." : "Recreate Tunnel"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isStopConfirming ? (
|
||||||
|
<div className="stop-confirm-inline">
|
||||||
|
<span className="confirm-text">Stop?</span>
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={onStop}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={onCancelStop}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={onStop}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isDeleteConfirming ? (
|
||||||
|
<div className="delete-confirm-inline">
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={onDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={onCancelDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={onDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
stopInstance,
|
||||||
|
deleteInstance,
|
||||||
|
checkInstanceHealth,
|
||||||
|
recreateInstanceTunnel,
|
||||||
|
} from "@/api/sessions";
|
||||||
|
import type { Session } from "@/types/session";
|
||||||
|
import { SessionCard } from "./SessionCard";
|
||||||
|
|
||||||
|
interface SessionListProps {
|
||||||
|
sessions: Session[];
|
||||||
|
variant: "active" | "recent";
|
||||||
|
onSessionChange?: () => void;
|
||||||
|
onOpen?: (session: Session) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionList: React.FC<SessionListProps> = ({
|
||||||
|
sessions,
|
||||||
|
variant,
|
||||||
|
onSessionChange,
|
||||||
|
onOpen,
|
||||||
|
}) => {
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||||
|
const [tunnelHealth, setTunnelHealth] = useState<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{ healthy: boolean; status_code: number | null; error?: string }
|
||||||
|
>
|
||||||
|
>({});
|
||||||
|
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Poll tunnel health every 30 seconds for running instances (active only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (variant !== "active") return;
|
||||||
|
|
||||||
|
const checkHealth = async () => {
|
||||||
|
const runningSessions = sessions.filter(
|
||||||
|
(s) => s.status === "running" && s.url,
|
||||||
|
);
|
||||||
|
for (const session of runningSessions) {
|
||||||
|
try {
|
||||||
|
const health = await checkInstanceHealth(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id,
|
||||||
|
);
|
||||||
|
setTunnelHealth((prev) => ({ ...prev, [session.id]: health }));
|
||||||
|
} catch {
|
||||||
|
setTunnelHealth((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[session.id]: {
|
||||||
|
healthy: false,
|
||||||
|
status_code: null,
|
||||||
|
error: "check failed",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkHealth();
|
||||||
|
const interval = setInterval(() => void checkHealth(), 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [sessions, variant]);
|
||||||
|
|
||||||
|
const handleStop = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
try {
|
||||||
|
await stopInstance(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id,
|
||||||
|
);
|
||||||
|
setStopConfirmId(null);
|
||||||
|
onSessionChange?.();
|
||||||
|
} catch {
|
||||||
|
setStopConfirmId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSessionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
try {
|
||||||
|
await deleteInstance(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id,
|
||||||
|
);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
onSessionChange?.();
|
||||||
|
} catch {
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSessionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRecreateTunnel = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
setRecreatingId(session.id);
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id,
|
||||||
|
);
|
||||||
|
onSessionChange?.();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setRecreatingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSessionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(
|
||||||
|
(session: Session) => {
|
||||||
|
if (session.url) {
|
||||||
|
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||||
|
} else {
|
||||||
|
onOpen?.(session);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="muted">
|
||||||
|
{variant === "active" ? "No active sessions" : "No recent sessions"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{variant === "active" ? (
|
||||||
|
<div className="sessions-grid">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
variant="active"
|
||||||
|
tunnelHealth={tunnelHealth[session.id] ?? null}
|
||||||
|
isRecreating={recreatingId === session.id}
|
||||||
|
isStopConfirming={stopConfirmId === session.id}
|
||||||
|
isDeleteConfirming={deleteConfirmId === session.id}
|
||||||
|
onOpen={() => handleOpen(session)}
|
||||||
|
onStop={() =>
|
||||||
|
stopConfirmId === session.id
|
||||||
|
? handleStop(session)
|
||||||
|
: setStopConfirmId(session.id)
|
||||||
|
}
|
||||||
|
onDelete={() =>
|
||||||
|
deleteConfirmId === session.id
|
||||||
|
? handleDelete(session)
|
||||||
|
: setDeleteConfirmId(session.id)
|
||||||
|
}
|
||||||
|
onRecreateTunnel={() => handleRecreateTunnel(session)}
|
||||||
|
onCancelStop={() => setStopConfirmId(null)}
|
||||||
|
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="recent-sessions-list">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
variant="recent"
|
||||||
|
isDeleteConfirming={deleteConfirmId === session.id}
|
||||||
|
onOpen={() => handleOpen(session)}
|
||||||
|
onStop={() => {}}
|
||||||
|
onDelete={() =>
|
||||||
|
deleteConfirmId === session.id
|
||||||
|
? handleDelete(session)
|
||||||
|
: setDeleteConfirmId(session.id)
|
||||||
|
}
|
||||||
|
onRecreateTunnel={() => {}}
|
||||||
|
onCancelStop={() => {}}
|
||||||
|
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { CreateSessionForm } from "./CreateSessionForm";
|
||||||
|
export { SessionList } from "./SessionList";
|
||||||
|
export { SessionCard } from "./SessionCard";
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
.settingsLayout {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsSidebar {
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNavLink {
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNavLink:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNavLinkActive {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsContent {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsPanel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsBreadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsBreadcrumb a {
|
||||||
|
color: var(--brand);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsBreadcrumb a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settingsLayout {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsSidebar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNav {
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsNavLink {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import styles from "./SettingsTabLayout.module.css";
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsTabLayoutProps {
|
||||||
|
tabs: Tab[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
basePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsTabLayout: React.FC<SettingsTabLayoutProps> = ({
|
||||||
|
tabs,
|
||||||
|
children,
|
||||||
|
basePath,
|
||||||
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.settingsLayout}>
|
||||||
|
<aside className={styles.settingsSidebar}>
|
||||||
|
<nav className={styles.settingsNav}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Link
|
||||||
|
key={tab.id}
|
||||||
|
to={`${basePath}/${tab.path}`}
|
||||||
|
className={`${styles.settingsNavLink} ${
|
||||||
|
location.pathname.includes(tab.path)
|
||||||
|
? styles.settingsNavLinkActive
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main className={styles.settingsContent}>{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
.terminalWrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-bottom: 1px solid #3e3e3e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalStatus {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalStatus .statusDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalStatus .statusText {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #d4d4d4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalClose {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalClose:hover {
|
||||||
|
background: #3e3e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalContainer {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalContainer :global(.xterm-viewport) {
|
||||||
|
background: #1e1e1e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalOverlayContent {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border: 1px solid #3e3e3e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalOverlayContent h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: #f14c4c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalOverlayContent p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalOverlayActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminalReconnectBanner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: #3e3e3e;
|
||||||
|
color: #f5f543;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.terminalOverlayContent {
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-15
@@ -5,11 +5,12 @@ import { SerializeAddon } from "xterm-addon-serialize";
|
|||||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||||
import "xterm/css/xterm.css";
|
import "xterm/css/xterm.css";
|
||||||
|
|
||||||
import { useTerminalConnection } from "../hooks/use-terminal-connection";
|
import { useTerminalConnection } from "../../../hooks/use-terminal-connection";
|
||||||
import type {
|
import type {
|
||||||
ServerControlMessage,
|
ServerControlMessage,
|
||||||
TerminalConnectionState,
|
TerminalConnectionState,
|
||||||
} from "../types/terminal";
|
} from "../../../types/terminal";
|
||||||
|
import styles from "./TerminalComponent.module.css";
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
@@ -231,11 +232,11 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
}, [state.status, sendResize]);
|
}, [state.status, sendResize]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="terminal-wrapper">
|
<div className={styles.terminalWrapper}>
|
||||||
<div className="terminal-header">
|
<div className={styles.terminalHeader}>
|
||||||
<div className="terminal-status">
|
<div className={styles.terminalStatus}>
|
||||||
<span
|
<span
|
||||||
className="status-dot"
|
className={styles.statusDot}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: STATUS_DOT_COLORS[state.status],
|
backgroundColor: STATUS_DOT_COLORS[state.status],
|
||||||
}}
|
}}
|
||||||
@@ -246,9 +247,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
: getStatusText(state)
|
: getStatusText(state)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="status-text">{getStatusText(state)}</span>
|
<span className={styles.statusText}>{getStatusText(state)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="terminal-actions">
|
<div className={styles.terminalActions}>
|
||||||
{state.status === "disconnected" && (
|
{state.status === "disconnected" && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
@@ -259,7 +260,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button className="terminal-close" onClick={onClose} type="button">
|
<button className={styles.terminalClose} onClick={onClose} type="button">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -267,11 +268,11 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sessionEnded && (
|
{sessionEnded && (
|
||||||
<div className="terminal-overlay">
|
<div className={styles.terminalOverlay}>
|
||||||
<div className="terminal-overlay-content">
|
<div className={styles.terminalOverlayContent}>
|
||||||
<h3>Session Ended</h3>
|
<h3>Session Ended</h3>
|
||||||
<p>{sessionEnded.message}</p>
|
<p>{sessionEnded.message}</p>
|
||||||
<div className="terminal-overlay-actions">
|
<div className={styles.terminalOverlayActions}>
|
||||||
<button
|
<button
|
||||||
className="primary-button small"
|
className="primary-button small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -297,13 +298,13 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{state.status === "reconnecting" && (
|
{state.status === "reconnecting" && (
|
||||||
<div className="terminal-reconnect-banner">
|
<div className={styles.terminalReconnectBanner}>
|
||||||
<span className="spinner" />
|
<span className={styles.spinner} />
|
||||||
{state.error}
|
{state.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={terminalRef} className="terminal-container" />
|
<div ref={terminalRef} className={styles.terminalContainer} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ToolConfig } from "../../../types/tool-config";
|
||||||
|
|
||||||
|
interface ToolConfigFormProps {
|
||||||
|
editingConfig: ToolConfig | null;
|
||||||
|
onSubmit: (data: {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
config_type: string;
|
||||||
|
file_path: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolConfigForm = ({
|
||||||
|
editingConfig,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: ToolConfigFormProps) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
key: editingConfig?.key ?? "",
|
||||||
|
value: editingConfig?.value ?? "",
|
||||||
|
config_type: editingConfig?.config_type ?? "env",
|
||||||
|
file_path: editingConfig?.file_path ?? "",
|
||||||
|
});
|
||||||
|
const [saveStatus, setSaveStatus] = useState<
|
||||||
|
"idle" | "saving" | "saved" | "error"
|
||||||
|
>("idle");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaveStatus("saving");
|
||||||
|
try {
|
||||||
|
await onSubmit(formData);
|
||||||
|
setSaveStatus("saved");
|
||||||
|
} catch {
|
||||||
|
setSaveStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card stack">
|
||||||
|
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="stack">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-key">Key</label>
|
||||||
|
<input
|
||||||
|
id="config-key"
|
||||||
|
type="text"
|
||||||
|
value={formData.key}
|
||||||
|
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||||
|
placeholder="e.g., OPENAI_API_KEY"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-type">Type</label>
|
||||||
|
<select
|
||||||
|
id="config-type"
|
||||||
|
value={formData.config_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, config_type: e.target.value })
|
||||||
|
}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
<option value="env">Environment Variable</option>
|
||||||
|
<option value="file">Configuration File</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{formData.config_type === "file" && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-file-path">File Path</label>
|
||||||
|
<input
|
||||||
|
id="config-file-path"
|
||||||
|
type="text"
|
||||||
|
value={formData.file_path}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, file_path: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g., /app/config.json"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-value">Value</label>
|
||||||
|
<textarea
|
||||||
|
id="config-value"
|
||||||
|
value={formData.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, value: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
formData.config_type === "env"
|
||||||
|
? "Enter value..."
|
||||||
|
: "Enter file contents..."
|
||||||
|
}
|
||||||
|
className="form-input"
|
||||||
|
rows={formData.config_type === "file" ? 8 : 2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="row"
|
||||||
|
style={{ gap: "0.5rem", justifyContent: "flex-end" }}
|
||||||
|
>
|
||||||
|
<button type="button" className="secondary-button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="primary-button">
|
||||||
|
{editingConfig ? "Update" : "Add"} Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{saveStatus === "saved" && (
|
||||||
|
<p className="text-success" style={{ textAlign: "right" }}>
|
||||||
|
Saved successfully!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveStatus === "error" && (
|
||||||
|
<p className="text-error" style={{ textAlign: "right" }}>
|
||||||
|
Failed to save. Please try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Icon } from "../../ui/Icon";
|
||||||
|
import type { ToolConfig } from "../../../types/tool-config";
|
||||||
|
|
||||||
|
interface ToolConfigListProps {
|
||||||
|
configs: ToolConfig[];
|
||||||
|
onEdit: (config: ToolConfig) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolConfigList = ({
|
||||||
|
configs,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: ToolConfigListProps) => {
|
||||||
|
if (configs.length === 0) {
|
||||||
|
return <p className="muted">No configurations for this tool yet.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||||
|
{configs.map((config) => (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="row"
|
||||||
|
style={{ gap: "0.5rem", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<code style={{ fontWeight: 600 }}>{config.key}</code>
|
||||||
|
<span
|
||||||
|
className="badge"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
background:
|
||||||
|
config.config_type === "env"
|
||||||
|
? "var(--color-info)"
|
||||||
|
: "var(--color-warning)",
|
||||||
|
color: "white",
|
||||||
|
padding: "0.125rem 0.5rem",
|
||||||
|
borderRadius: "9999px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.config_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="muted"
|
||||||
|
style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}
|
||||||
|
>
|
||||||
|
{config.config_type === "file" && config.file_path
|
||||||
|
? `File: ${config.file_path}`
|
||||||
|
: "Environment variable"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => onEdit(config)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => void onDelete(config.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ToolConfigForm } from "./ToolConfigForm";
|
||||||
|
export { ToolConfigList } from "./ToolConfigList";
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Icon } from "../../ui/Icon";
|
||||||
|
import type {
|
||||||
|
ToolType,
|
||||||
|
CreateToolTypeRequest,
|
||||||
|
UpdateToolTypeRequest,
|
||||||
|
} from "../../../types/tool-type";
|
||||||
|
|
||||||
|
interface ToolTypeFormProps {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
toolType?: ToolType | null;
|
||||||
|
onSubmit: (
|
||||||
|
input: CreateToolTypeRequest | UpdateToolTypeRequest,
|
||||||
|
) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolTypeForm = ({
|
||||||
|
mode,
|
||||||
|
toolType,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: ToolTypeFormProps) => {
|
||||||
|
const [formName, setFormName] = useState(toolType?.name ?? "");
|
||||||
|
const [formDisplayName, setFormDisplayName] = useState(
|
||||||
|
toolType?.display_name ?? "",
|
||||||
|
);
|
||||||
|
const [formDescription, setFormDescription] = useState(
|
||||||
|
toolType?.description ?? "",
|
||||||
|
);
|
||||||
|
const [formCategory, setFormCategory] = useState(toolType?.category ?? "");
|
||||||
|
const [formInterfaces, setFormInterfaces] = useState<string[]>(
|
||||||
|
toolType?.interfaces ?? [],
|
||||||
|
);
|
||||||
|
const [formPort, setFormPort] = useState(
|
||||||
|
toolType?.default_port?.toString() ?? "",
|
||||||
|
);
|
||||||
|
const [formTemplate, setFormTemplate] = useState(
|
||||||
|
toolType?.compose_template ?? "",
|
||||||
|
);
|
||||||
|
const [formVariables, setFormVariables] = useState(
|
||||||
|
toolType?.required_variables?.join(", ") ?? "",
|
||||||
|
);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
|
||||||
|
setFormError("Name, display name, and compose template are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formPort.trim() || isNaN(Number(formPort))) {
|
||||||
|
setFormError("Default port is required and must be a number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = formVariables
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v.length > 0);
|
||||||
|
const base = {
|
||||||
|
display_name: formDisplayName.trim(),
|
||||||
|
description: formDescription.trim() || undefined,
|
||||||
|
category: formCategory.trim() || undefined,
|
||||||
|
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||||
|
default_port: Number(formPort),
|
||||||
|
compose_template: formTemplate.trim(),
|
||||||
|
required_variables: variables,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === "create") {
|
||||||
|
await onSubmit({
|
||||||
|
name: formName.trim(),
|
||||||
|
...base,
|
||||||
|
} as CreateToolTypeRequest);
|
||||||
|
} else {
|
||||||
|
await onSubmit(base as UpdateToolTypeRequest);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||||
|
setFormError(
|
||||||
|
axiosError?.response?.data?.detail || "Failed to save tool type",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleInterface = (iface: string) => {
|
||||||
|
setFormInterfaces((prev) =>
|
||||||
|
prev.includes(iface) ? prev.filter((i) => i !== iface) : [...prev, iface],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dialog-overlay">
|
||||||
|
<div className="dialog">
|
||||||
|
<h2>{mode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Name (unique identifier)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
disabled={mode === "edit"}
|
||||||
|
placeholder="e.g., code-server"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formDisplayName}
|
||||||
|
onChange={(e) => setFormDisplayName(e.target.value)}
|
||||||
|
placeholder="e.g., VS Code Server"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formDescription}
|
||||||
|
onChange={(e) => setFormDescription(e.target.value)}
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Category</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formCategory}
|
||||||
|
onChange={(e) => setFormCategory(e.target.value)}
|
||||||
|
placeholder="e.g., editor, notebook, ai-assistant"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Interfaces</label>
|
||||||
|
<div className="checkbox-group">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formInterfaces.includes("web")}
|
||||||
|
onChange={() => toggleInterface("web")}
|
||||||
|
/>{" "}
|
||||||
|
Web
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formInterfaces.includes("terminal")}
|
||||||
|
onChange={() => toggleInterface("terminal")}
|
||||||
|
/>{" "}
|
||||||
|
Terminal
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Default Port *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formPort}
|
||||||
|
onChange={(e) => setFormPort(e.target.value)}
|
||||||
|
placeholder="e.g., 8443"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Compose Template (YAML)</label>
|
||||||
|
<textarea
|
||||||
|
value={formTemplate}
|
||||||
|
onChange={(e) => setFormTemplate(e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
placeholder="version: '3.8' services: app: image: ..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Required Variables (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formVariables}
|
||||||
|
onChange={(e) => setFormVariables(e.target.value)}
|
||||||
|
placeholder="REPO_PATH, TOOL_NAME"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formError && <p className="text-error">{formError}</p>}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button type="submit">
|
||||||
|
<Icon name={mode === "create" ? "add" : "save"} size="sm" />
|
||||||
|
{mode === "create" ? "Create" : "Update"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="button-secondary"
|
||||||
|
>
|
||||||
|
<Icon name="cancel" size="sm" /> Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Icon } from "../../ui/Icon";
|
||||||
|
import type { ToolType } from "../../../types/tool-type";
|
||||||
|
|
||||||
|
interface ToolTypeListProps {
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
onEdit: (toolType: ToolType) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
deleteConfirmId: string | null;
|
||||||
|
setDeleteConfirmId: (id: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolTypeList = ({
|
||||||
|
toolTypes,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
deleteConfirmId,
|
||||||
|
setDeleteConfirmId,
|
||||||
|
}: ToolTypeListProps) => {
|
||||||
|
if (toolTypes.length === 0) {
|
||||||
|
return <p>No tool types found.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-grid">
|
||||||
|
{toolTypes.map((toolType) => (
|
||||||
|
<div key={toolType.id} className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3>{toolType.display_name}</h3>
|
||||||
|
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary">
|
||||||
|
{toolType.description || "No description"}
|
||||||
|
</p>
|
||||||
|
<div className="tool-type-meta">
|
||||||
|
<span>Port: {toolType.default_port || "N/A"}</span>
|
||||||
|
{toolType.interfaces?.length > 0 && (
|
||||||
|
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
||||||
|
)}
|
||||||
|
{toolType.category && <span>Category: {toolType.category}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="card-actions">
|
||||||
|
{!toolType.is_builtin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(toolType)}
|
||||||
|
className="button-secondary"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirmId(toolType.id)}
|
||||||
|
className="button-danger"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteConfirmId === toolType.id && (
|
||||||
|
<div className="dialog-overlay">
|
||||||
|
<div className="dialog">
|
||||||
|
<p>Delete tool type "{toolType.display_name}"?</p>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(toolType.id)}
|
||||||
|
className="button-danger"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setDeleteConfirmId(null)}>
|
||||||
|
<Icon name="cancel" size="sm" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user