Compare commits
3 Commits
a905cf729e
...
ab79080f0b
| Author | SHA1 | Date | |
|---|---|---|---|
| ab79080f0b | |||
| 4c216dd1ca | |||
| a37a3122f9 |
@@ -8,6 +8,7 @@ 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.api.shared_validators import validate_files as _validate_files, validate_mount_path as _validate_mount_path
|
||||||
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
|
||||||
|
|
||||||
@@ -15,9 +16,6 @@ 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):
|
class ConfigFolderCreate(BaseModel):
|
||||||
name: str = Field(description="Folder name (unique per user)")
|
name: str = Field(description="Folder name (unique per user)")
|
||||||
@@ -28,24 +26,12 @@ class ConfigFolderCreate(BaseModel):
|
|||||||
@field_validator("mount_path")
|
@field_validator("mount_path")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_mount_path(cls, v: str) -> str:
|
def validate_mount_path(cls, v: str) -> str:
|
||||||
if not v.startswith("/"):
|
return _validate_mount_path(v)
|
||||||
raise ValueError("Mount path must be absolute (start with /)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("files")
|
@field_validator("files")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_files(cls, v: dict) -> dict:
|
def validate_files(cls, v: dict) -> dict:
|
||||||
total_size = 0
|
return _validate_files(v)
|
||||||
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):
|
class ConfigFolderUpdate(BaseModel):
|
||||||
@@ -58,29 +44,12 @@ class ConfigFolderUpdate(BaseModel):
|
|||||||
@field_validator("mount_path")
|
@field_validator("mount_path")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
return _validate_mount_path(v)
|
||||||
return v
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Mount path must be absolute (start with /)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("files")
|
@field_validator("files")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_files(cls, v: dict | None) -> dict | None:
|
def validate_files(cls, v: dict | None) -> dict | None:
|
||||||
if v is None:
|
return _validate_files(v)
|
||||||
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):
|
class ProjectOverrideCreate(BaseModel):
|
||||||
@@ -90,11 +59,7 @@ class ProjectOverrideCreate(BaseModel):
|
|||||||
@field_validator("mount_path")
|
@field_validator("mount_path")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
return _validate_mount_path(v)
|
||||||
return v
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Mount path must be absolute (start with /)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderResponse(BaseModel):
|
class ConfigFolderResponse(BaseModel):
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
|
|
||||||
|
from src.api.shared_validators import validate_env_vars as _validate_env_vars
|
||||||
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_profile import ConfigProfile, ConfigProfileInclude
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
@@ -120,9 +121,10 @@ class ConfigProfileCreate(BaseModel):
|
|||||||
@field_validator("env_vars")
|
@field_validator("env_vars")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_env_vars(cls, v: dict) -> dict:
|
def validate_env_vars(cls, v: dict) -> dict:
|
||||||
if not isinstance(v, dict):
|
result = _validate_env_vars(v)
|
||||||
|
if result is None:
|
||||||
raise ValueError("env_vars must be a JSON object")
|
raise ValueError("env_vars must be a JSON object")
|
||||||
return v
|
return result
|
||||||
|
|
||||||
@field_validator("runtime_hints")
|
@field_validator("runtime_hints")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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_owned_project, _get_user, get_current_user_id, get_db_session
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
@@ -42,40 +42,6 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
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_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
|
|
||||||
|
|
||||||
|
|
||||||
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
||||||
"""Generate the filesystem path for a repository.
|
"""Generate the filesystem path for a repository.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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_owned_project, _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
@@ -16,14 +16,6 @@ from src.models.user import User
|
|||||||
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):
|
class ProjectCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -132,32 +124,6 @@ async def get_project(
|
|||||||
return await _get_owned_project(project_id, user_id, session)
|
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/{project_id}",
|
"/{project_id}",
|
||||||
response_model=ProjectResponse,
|
response_model=ProjectResponse,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Shared Pydantic validators for API schemas."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MAX_FOLDER_SIZE_MB = 10
|
||||||
|
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mount_path(v: str | None) -> str | None:
|
||||||
|
"""Validate that a mount path is absolute (starts with /).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Mount path string or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated path, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path is not absolute.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount path must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_files(v: dict | None, max_size_bytes: int = MAX_FOLDER_SIZE_BYTES) -> dict | None:
|
||||||
|
"""Validate file dict for path traversal and size limits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Dict of {path: content} or None.
|
||||||
|
max_size_bytes: Maximum total size in bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated dict, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path traversal detected or size limit exceeded.
|
||||||
|
"""
|
||||||
|
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_size_bytes:
|
||||||
|
raise ValueError(f"Total folder size exceeds {max_size_bytes // (1024 * 1024)}MB limit")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_env_vars(v: dict | None) -> dict | None:
|
||||||
|
"""Validate that environment variables is a JSON object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Dict of env vars or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated dict, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If not a dict.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("environment_variables must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_volumes(v: list | None) -> list | None:
|
||||||
|
"""Validate volume mounts list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: List of volume dicts or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated list, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If not a list or missing required fields.
|
||||||
|
"""
|
||||||
|
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
|
||||||
@@ -10,7 +10,7 @@ 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_user, get_current_user_id, 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
|
||||||
@@ -18,14 +18,6 @@ from src.models.user import User
|
|||||||
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."""
|
||||||
import base64
|
import base64
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ 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.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
|
||||||
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
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||||
|
|
||||||
@@ -42,27 +40,12 @@ class ToolConfigCreate(BaseModel):
|
|||||||
@field_validator("environment_variables")
|
@field_validator("environment_variables")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||||
if v is None:
|
return _validate_env_vars(v)
|
||||||
return v
|
|
||||||
if not isinstance(v, dict):
|
|
||||||
raise ValueError("environment_variables must be a JSON object")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("volumes")
|
@field_validator("volumes")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_volumes(cls, v: list | None) -> list | None:
|
def validate_volumes(cls, v: list | None) -> list | None:
|
||||||
if v is None:
|
return _validate_volumes(v)
|
||||||
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):
|
class ToolConfigUpdate(BaseModel):
|
||||||
@@ -88,27 +71,12 @@ class ToolConfigUpdate(BaseModel):
|
|||||||
@field_validator("environment_variables")
|
@field_validator("environment_variables")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||||
if v is None:
|
return _validate_env_vars(v)
|
||||||
return v
|
|
||||||
if not isinstance(v, dict):
|
|
||||||
raise ValueError("environment_variables must be a JSON object")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("volumes")
|
@field_validator("volumes")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_volumes(cls, v: list | None) -> list | None:
|
def validate_volumes(cls, v: list | None) -> list | None:
|
||||||
if v is None:
|
return _validate_volumes(v)
|
||||||
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):
|
class ToolConfigResponse(BaseModel):
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id
|
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||||
from src.auth.dependencies import get_db_session
|
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
@@ -193,40 +192,6 @@ def _modify_compose_file(
|
|||||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
"""Fetch a user by ID or raise 404 if not found."""
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="user not found"
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned_project(
|
|
||||||
project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession
|
|
||||||
) -> Project:
|
|
||||||
"""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 or project.owner_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="project not found"
|
|
||||||
)
|
|
||||||
return project
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{project_id}/repositories/{repo_id}/instances",
|
"/{project_id}/repositories/{repo_id}/instances",
|
||||||
summary="Create tool instance",
|
summary="Create tool instance",
|
||||||
|
|||||||
+28
-126
@@ -1,4 +1,3 @@
|
|||||||
import re
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -8,26 +7,19 @@ 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.api.tool_types_validation import (
|
||||||
def _sanitize_template_vars(template: str) -> str:
|
check_port_exposed,
|
||||||
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
|
sanitize_template_vars,
|
||||||
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
|
validate_compose_yaml,
|
||||||
|
validate_required_variables,
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
)
|
||||||
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -72,24 +64,7 @@ class ToolTypeCreate(BaseModel):
|
|||||||
if v is None:
|
if v is None:
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
|
||||||
# Replace template variables with dummy values before YAML validation
|
validate_compose_yaml(v)
|
||||||
# to avoid YAML parsing errors with {{VAR}} syntax
|
|
||||||
sanitized = _sanitize_template_vars(v)
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(sanitized)
|
|
||||||
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
|
return v
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
@field_validator("dockerfile_template")
|
||||||
@@ -156,29 +131,11 @@ class ToolTypeCreate(BaseModel):
|
|||||||
# Validate that default_port is exposed in compose template (only if requires_port)
|
# Validate that default_port is exposed in compose template (only if requires_port)
|
||||||
if self.requires_port and self.definition_type == "compose" and self.compose_template:
|
if self.requires_port and self.definition_type == "compose" and self.compose_template:
|
||||||
try:
|
try:
|
||||||
sanitized = _sanitize_template_vars(self.compose_template)
|
parsed = validate_compose_yaml(self.compose_template)
|
||||||
parsed = yaml.safe_load(sanitized)
|
except ValueError:
|
||||||
except yaml.YAMLError:
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
port_str = str(self.default_port)
|
if not check_port_exposed(parsed, self.default_port):
|
||||||
port_exposed = False
|
|
||||||
|
|
||||||
if isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
for service_name, service_config in parsed["services"].items():
|
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if isinstance(port_mapping, str):
|
|
||||||
if port_str in port_mapping:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif isinstance(port_mapping, int) and port_mapping == self.default_port:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not port_exposed:
|
|
||||||
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
|
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
|
||||||
|
|
||||||
return self
|
return self
|
||||||
@@ -228,23 +185,7 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
if definition_type and definition_type != "compose":
|
if definition_type and definition_type != "compose":
|
||||||
return v
|
return v
|
||||||
|
|
||||||
# Replace template variables with dummy values before YAML validation
|
validate_compose_yaml(v)
|
||||||
sanitized = _sanitize_template_vars(v)
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(sanitized)
|
|
||||||
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
|
return v
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
@field_validator("dockerfile_template")
|
||||||
@@ -442,54 +383,29 @@ async def update_tool_type(
|
|||||||
template = update_data.get("compose_template", tool_type.compose_template)
|
template = update_data.get("compose_template", tool_type.compose_template)
|
||||||
if template:
|
if template:
|
||||||
try:
|
try:
|
||||||
sanitized = _sanitize_template_vars(template)
|
parsed = validate_compose_yaml(template)
|
||||||
parsed = yaml.safe_load(sanitized)
|
if not check_port_exposed(parsed, new_port):
|
||||||
except yaml.YAMLError:
|
|
||||||
parsed = None
|
|
||||||
|
|
||||||
if parsed and isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
port_str = str(new_port)
|
|
||||||
port_exposed = False
|
|
||||||
for service_config in parsed["services"].values():
|
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif isinstance(port_mapping, int) and port_mapping == new_port:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not port_exposed:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Port {new_port} is not exposed in the compose template"
|
detail=f"Port {new_port} is not exposed in the compose template"
|
||||||
)
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
# Validate required variables for compose definitions
|
# Validate required variables for compose definitions
|
||||||
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||||
if definition_type == "compose":
|
if definition_type == "compose":
|
||||||
if "required_variables" in update_data and "compose_template" in update_data:
|
if "required_variables" in update_data and "compose_template" in update_data:
|
||||||
template = update_data["compose_template"]
|
validate_required_variables(
|
||||||
for var in update_data["required_variables"]:
|
update_data["compose_template"], update_data["required_variables"]
|
||||||
placeholder = f"{{{{{var}}}}}"
|
|
||||||
if placeholder not in template:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
|
||||||
)
|
)
|
||||||
elif "required_variables" in update_data:
|
elif "required_variables" in update_data:
|
||||||
template = tool_type.compose_template
|
template = tool_type.compose_template
|
||||||
if template:
|
if template:
|
||||||
for var in update_data["required_variables"]:
|
validate_required_variables(template, update_data["required_variables"])
|
||||||
placeholder = f"{{{{{var}}}}}"
|
|
||||||
if placeholder not in template:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
|
||||||
)
|
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(tool_type, field, value)
|
setattr(tool_type, field, value)
|
||||||
@@ -534,16 +450,9 @@ async def validate_tool_type_template(
|
|||||||
errors.append("Compose template is required")
|
errors.append("Compose template is required")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
sanitized = _sanitize_template_vars(data.compose_template)
|
validate_compose_yaml(data.compose_template)
|
||||||
parsed = yaml.safe_load(sanitized)
|
except ValueError as e:
|
||||||
if not isinstance(parsed, dict):
|
errors.append(str(e))
|
||||||
errors.append("Compose template must be a YAML mapping")
|
|
||||||
elif "services" not in parsed:
|
|
||||||
errors.append("Compose template must contain 'services' key")
|
|
||||||
elif not parsed["services"]:
|
|
||||||
errors.append("Compose template must define at least one service")
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
errors.append(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
elif data.definition_type == "dockerfile":
|
elif data.definition_type == "dockerfile":
|
||||||
if not data.dockerfile_template:
|
if not data.dockerfile_template:
|
||||||
@@ -592,16 +501,9 @@ async def validate_tool_type(
|
|||||||
errors.append("Compose template is empty")
|
errors.append("Compose template is empty")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
sanitized = _sanitize_template_vars(tool_type.compose_template)
|
validate_compose_yaml(tool_type.compose_template)
|
||||||
parsed = yaml.safe_load(sanitized)
|
except ValueError as e:
|
||||||
if not isinstance(parsed, dict):
|
errors.append(str(e))
|
||||||
errors.append("Compose template must be a YAML mapping")
|
|
||||||
elif "services" not in parsed:
|
|
||||||
errors.append("Compose template must contain 'services' key")
|
|
||||||
elif not parsed["services"]:
|
|
||||||
errors.append("Compose template must define at least one service")
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
errors.append(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
elif tool_type.definition_type == "dockerfile":
|
elif tool_type.definition_type == "dockerfile":
|
||||||
if not tool_type.dockerfile_template:
|
if not tool_type.dockerfile_template:
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Shared validation utilities for tool types."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_template_vars(template: str) -> str:
|
||||||
|
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
|
||||||
|
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_compose_yaml(template: str) -> dict:
|
||||||
|
"""Validate and parse a compose template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Raw compose template string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed YAML dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If YAML is invalid or missing required keys.
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_template_vars(template)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
|
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 parsed
|
||||||
|
|
||||||
|
|
||||||
|
def check_port_exposed(parsed: dict, port: int) -> bool:
|
||||||
|
"""Check if a port is exposed in a parsed compose template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parsed: Parsed compose YAML dict.
|
||||||
|
port: Port number to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if port is exposed in any service.
|
||||||
|
"""
|
||||||
|
port_str = str(port)
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict) or "services" not in parsed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for service_config in parsed["services"].values():
|
||||||
|
if isinstance(service_config, dict) and "ports" in service_config:
|
||||||
|
for port_mapping in service_config["ports"]:
|
||||||
|
if isinstance(port_mapping, str) and port_str in port_mapping:
|
||||||
|
return True
|
||||||
|
elif isinstance(port_mapping, int) and port_mapping == port:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_required_variables(template: str, variables: list[str]) -> None:
|
||||||
|
"""Validate that all required variables exist in the template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Compose template string.
|
||||||
|
variables: List of required variable names.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If any variable is not found in the template.
|
||||||
|
"""
|
||||||
|
for var in variables:
|
||||||
|
placeholder = f"{{{{{var}}}}}"
|
||||||
|
if placeholder not in template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Required variable '{var}' not found in compose template",
|
||||||
|
)
|
||||||
@@ -8,21 +8,13 @@ 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_user, get_current_user_id, 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
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
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_user, get_current_user_id, get_db_session
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
@@ -16,14 +16,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):
|
class UserProfileResponse(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -47,3 +47,39 @@ 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_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_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: 404 if project not found, 403 if user is not the owner.
|
||||||
|
"""
|
||||||
|
from src.models.project import Project
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
interface LoadingStateProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingState = ({ message = "Loading..." }: LoadingStateProps) => (
|
||||||
|
<p className="muted">{message}</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
message?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorState = ({ message = "Failed to load", onRetry }: ErrorStateProps) => (
|
||||||
|
<div className="card stack">
|
||||||
|
<p>{message}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button className="secondary-button" onClick={onRetry} type="button">
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmptyState = ({ message }: EmptyStateProps) => (
|
||||||
|
<p className="muted">{message}</p>
|
||||||
|
);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type AsyncStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
interface UseAsyncDataResult<T> {
|
||||||
|
data: T | null;
|
||||||
|
status: AsyncStatus;
|
||||||
|
error: string | null;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsyncData<T>(
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
deps: React.DependencyList = []
|
||||||
|
): UseAsyncDataResult<T> {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [status, setStatus] = useState<AsyncStatus>("idle");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await fetcher();
|
||||||
|
setData(result);
|
||||||
|
setStatus("ready");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load data");
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { data, status, error, reload };
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
stopInstance,
|
||||||
|
deleteInstance,
|
||||||
|
startInstance,
|
||||||
|
recreateInstanceTunnel,
|
||||||
|
} from "../api/sessions";
|
||||||
|
import type { Session } from "../api/sessions";
|
||||||
|
|
||||||
|
interface UseInstanceActionsOptions {
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseInstanceActionsReturn {
|
||||||
|
loadingSessionId: string | null;
|
||||||
|
dirtyDeleteSession: Session | null;
|
||||||
|
dirtyDeleteFiles: string[];
|
||||||
|
handleOpen: (session: Session) => void;
|
||||||
|
handleStart: (session: Session) => Promise<void>;
|
||||||
|
handleStop: (session: Session) => Promise<void>;
|
||||||
|
handleDelete: (session: Session) => Promise<void>;
|
||||||
|
handleForceDelete: (session: Session) => Promise<void>;
|
||||||
|
handleRecreateTunnel: (session: Session) => Promise<void>;
|
||||||
|
clearDirtyDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstanceActions(
|
||||||
|
options: UseInstanceActionsOptions
|
||||||
|
): UseInstanceActionsReturn {
|
||||||
|
const { onRefresh } = options;
|
||||||
|
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||||
|
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||||
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleOpen = useCallback((session: Session) => {
|
||||||
|
if (session.url) {
|
||||||
|
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.tool_type_interfaces?.includes("terminal")) {
|
||||||
|
window.location.href = `/instances/${session.id}/terminal`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = `/projects/${session.project_id}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStart = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
if (loadingSessionId === session.id) return;
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
try {
|
||||||
|
await startInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
await onRefresh();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadingSessionId, onRefresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStop = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
if (loadingSessionId === session.id) return;
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
try {
|
||||||
|
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
await onRefresh();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadingSessionId, onRefresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
if (loadingSessionId === session.id) return;
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
try {
|
||||||
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
setDirtyDeleteSession(null);
|
||||||
|
setDirtyDeleteFiles([]);
|
||||||
|
await onRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { status?: number; data?: { detail?: { changed_files?: string[] } } };
|
||||||
|
};
|
||||||
|
if (axiosError.response?.status === 409) {
|
||||||
|
const detail = axiosError.response.data?.detail;
|
||||||
|
if (detail?.changed_files) {
|
||||||
|
setDirtyDeleteSession(session);
|
||||||
|
setDirtyDeleteFiles(detail.changed_files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadingSessionId, onRefresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleForceDelete = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
if (loadingSessionId === session.id) return;
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
try {
|
||||||
|
await deleteInstance(session.project_id, session.repository_id, session.id, true);
|
||||||
|
setDirtyDeleteSession(null);
|
||||||
|
setDirtyDeleteFiles([]);
|
||||||
|
await onRefresh();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadingSessionId, onRefresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRecreateTunnel = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
if (loadingSessionId === session.id) return;
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||||
|
await onRefresh();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadingSessionId, onRefresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearDirtyDelete = useCallback(() => {
|
||||||
|
setDirtyDeleteSession(null);
|
||||||
|
setDirtyDeleteFiles([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadingSessionId,
|
||||||
|
dirtyDeleteSession,
|
||||||
|
dirtyDeleteFiles,
|
||||||
|
handleOpen,
|
||||||
|
handleStart,
|
||||||
|
handleStop,
|
||||||
|
handleDelete,
|
||||||
|
handleForceDelete,
|
||||||
|
handleRecreateTunnel,
|
||||||
|
clearDirtyDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
import { extractErrorMessage } from "../utils/errors";
|
||||||
import { MobileListView } from "../components/mobile-list-view";
|
import { MobileListView } from "../components/mobile-list-view";
|
||||||
import { MobileDetailView } from "../components/mobile-detail-view";
|
import { MobileDetailView } from "../components/mobile-detail-view";
|
||||||
import { MobileEditView } from "../components/mobile-edit-view";
|
import { MobileEditView } from "../components/mobile-edit-view";
|
||||||
@@ -127,16 +129,6 @@ export const ConfigProfilesPage = () => {
|
|||||||
resetForm();
|
resetForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractErrorMessage = (err: unknown): string => {
|
|
||||||
const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } };
|
|
||||||
const detail = axiosError?.response?.data?.detail;
|
|
||||||
if (typeof detail === "string") return detail;
|
|
||||||
if (Array.isArray(detail)) {
|
|
||||||
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
|
|
||||||
}
|
|
||||||
return "Failed to save";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Include management functions
|
// Include management functions
|
||||||
const getIncludedProfile = (id: string): ConfigProfile | undefined => profiles.find((p) => p.id === id);
|
const getIncludedProfile = (id: string): ConfigProfile | undefined => profiles.find((p) => p.id === id);
|
||||||
|
|
||||||
@@ -417,7 +409,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<p>Loading Config Profiles...</p>
|
<LoadingState message="Loading Config Profiles..." />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -425,10 +417,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<p className="text-error">Failed to load Config Profiles.</p>
|
<ErrorState message="Failed to load Config Profiles." onRetry={loadData} />
|
||||||
<button onClick={loadData}>
|
|
||||||
<Icon name="refresh" size="sm" /> Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||||
import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
import { getUserSessions, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { updateUserConfig } from "../api/settings";
|
import { updateUserConfig } from "../api/settings";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { CreateSessionForm } from "../components/create-session-form";
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
import { SessionList } from "../components/session-list";
|
import { SessionList } from "../components/session-list";
|
||||||
|
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||||
|
|
||||||
type HomeStatus = "loading" | "ready" | "error";
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -31,7 +33,6 @@ export const HomePage = () => {
|
|||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState("");
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
|
||||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||||
|
|
||||||
@@ -58,6 +59,15 @@ export const HomePage = () => {
|
|||||||
void loadHome();
|
void loadHome();
|
||||||
}, [loadHome]);
|
}, [loadHome]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadingSessionId: actionBusy,
|
||||||
|
handleOpen,
|
||||||
|
handleStart,
|
||||||
|
handleStop,
|
||||||
|
handleDelete,
|
||||||
|
handleRecreateTunnel,
|
||||||
|
} = useInstanceActions({ onRefresh: loadHome });
|
||||||
|
|
||||||
// Poll tunnel health every 30 seconds for running instances
|
// Poll tunnel health every 30 seconds for running instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkHealth = async () => {
|
const checkHealth = async () => {
|
||||||
@@ -130,64 +140,6 @@ export const HomePage = () => {
|
|||||||
await loadHome();
|
await loadHome();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpen = (session: SessionView) => {
|
|
||||||
if (session.url) {
|
|
||||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session.tool_type_interfaces.includes("terminal")) {
|
|
||||||
navigate(`/instances/${session.id}/terminal`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(`/projects/${session.project_id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStop = async (session: SessionView) => {
|
|
||||||
if (actionBusy === session.id) return;
|
|
||||||
setActionBusy(session.id);
|
|
||||||
try {
|
|
||||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
await loadHome();
|
|
||||||
} finally {
|
|
||||||
setActionBusy(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (session: SessionView) => {
|
|
||||||
if (actionBusy === session.id) return;
|
|
||||||
setActionBusy(session.id);
|
|
||||||
try {
|
|
||||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
|
||||||
} catch {
|
|
||||||
// error - session remains in state
|
|
||||||
} finally {
|
|
||||||
setActionBusy(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRecreateTunnel = async (session: SessionView) => {
|
|
||||||
if (actionBusy === session.id) return;
|
|
||||||
setActionBusy(session.id);
|
|
||||||
try {
|
|
||||||
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
|
||||||
await loadHome();
|
|
||||||
} finally {
|
|
||||||
setActionBusy(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStart = async (session: SessionView) => {
|
|
||||||
if (actionBusy === session.id) return;
|
|
||||||
setActionBusy(session.id);
|
|
||||||
try {
|
|
||||||
await startInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
await loadHome();
|
|
||||||
} finally {
|
|
||||||
setActionBusy(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack home-page">
|
<section className="stack home-page">
|
||||||
<header className="home-hero card">
|
<header className="home-hero card">
|
||||||
@@ -202,17 +154,9 @@ export const HomePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading overview...</p>}
|
{status === "loading" && <LoadingState message="Loading overview..." />}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
|
||||||
<div className="card stack">
|
|
||||||
<p>Unable to load your workspace overview.</p>
|
|
||||||
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "ready" && summary && (
|
{status === "ready" && summary && (
|
||||||
<>
|
<>
|
||||||
@@ -260,7 +204,7 @@ export const HomePage = () => {
|
|||||||
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||||
</div>
|
</div>
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<p className="muted">No projects yet.</p>
|
<EmptyState message="No projects yet." />
|
||||||
) : (
|
) : (
|
||||||
<div className="home-project-grid">
|
<div className="home-project-grid">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry } from "../api/git_repositories";
|
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
|
|
||||||
export const GitHistoryPage = () => {
|
export const GitHistoryPage = () => {
|
||||||
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
|
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [commits, setCommits] = useState<CommitHistoryEntry[]>([]);
|
|
||||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||||
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
|
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
|
||||||
const [branches, setBranches] = useState<string[]>([]);
|
|
||||||
const [selectedBranch, setSelectedBranch] = useState<string>("");
|
const [selectedBranch, setSelectedBranch] = useState<string>("");
|
||||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
|
||||||
const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
||||||
|
|
||||||
const loadHistory = useCallback(async () => {
|
const { data: historyData, status, reload } = useAsyncData<CommitHistoryResponse>(
|
||||||
if (!projectId || !repoId) return;
|
async () => {
|
||||||
setStatus("loading");
|
if (!projectId || !repoId) return { commits: [], branches: [], tags: [] };
|
||||||
try {
|
return await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
|
||||||
const data = await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
|
},
|
||||||
setCommits(data.commits);
|
[projectId, repoId, selectedBranch]
|
||||||
setBranches(data.branches);
|
);
|
||||||
if (data.branches.length > 0 && !selectedBranch) {
|
|
||||||
setSelectedBranch(data.branches[0]);
|
|
||||||
}
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}, [projectId, repoId, selectedBranch]);
|
|
||||||
|
|
||||||
|
// Auto-select first branch when data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadHistory();
|
if (historyData?.branches.length && !selectedBranch) {
|
||||||
}, [loadHistory]);
|
setSelectedBranch(historyData.branches[0]);
|
||||||
|
}
|
||||||
|
}, [historyData?.branches, selectedBranch]);
|
||||||
|
|
||||||
const handleCommitClick = async (hash: string) => {
|
const handleCommitClick = async (hash: string) => {
|
||||||
if (!projectId || !repoId) return;
|
if (!projectId || !repoId) return;
|
||||||
@@ -61,7 +55,7 @@ export const GitHistoryPage = () => {
|
|||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<p className="muted">Loading commit history...</p>
|
<LoadingState message="Loading commit history..." />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,15 +63,14 @@ export const GitHistoryPage = () => {
|
|||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<p>Failed to load commit history</p>
|
<ErrorState message="Failed to load commit history" onRetry={reload} />
|
||||||
<button className="secondary-button" onClick={() => void loadHistory()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commits = historyData?.commits ?? [];
|
||||||
|
const branches = historyData?.branches ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -6,48 +6,38 @@ import {
|
|||||||
listRepositories,
|
listRepositories,
|
||||||
} from "../api/git_repositories";
|
} from "../api/git_repositories";
|
||||||
import type { GitRepository } from "../api/git_repositories";
|
import type { GitRepository } from "../api/git_repositories";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
|
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
type RepoStatus = "loading" | "ready" | "error";
|
|
||||||
|
|
||||||
export const GitRepositoriesPage = () => {
|
export const GitRepositoriesPage = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [status, setStatus] = useState<RepoStatus>("loading");
|
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadRepositories = useCallback(async () => {
|
const { data: repositories, status, reload } = useAsyncData<GitRepository[]>(
|
||||||
if (!projectId) return;
|
async () => {
|
||||||
setStatus("loading");
|
if (!projectId) return [];
|
||||||
try {
|
return await listRepositories(projectId);
|
||||||
const data = await listRepositories(projectId);
|
},
|
||||||
setRepositories(data);
|
[projectId]
|
||||||
setStatus("ready");
|
);
|
||||||
} catch {
|
|
||||||
setRepositories([]);
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadRepositories();
|
|
||||||
}, [loadRepositories]);
|
|
||||||
|
|
||||||
const handleDelete = async (repoId: string) => {
|
const handleDelete = async (repoId: string) => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
try {
|
try {
|
||||||
await deleteRepository(projectId, repoId);
|
await deleteRepository(projectId, repoId);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
await loadRepositories();
|
reload();
|
||||||
} catch {
|
} catch {
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEmpty = status === "ready" && repositories.length === 0;
|
const safeRepositories = repositories ?? [];
|
||||||
|
const isEmpty = status === "ready" && safeRepositories.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
@@ -59,23 +49,15 @@ export const GitRepositoriesPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading repositories...</p>}
|
{status === "loading" && <LoadingState message="Loading repositories..." />}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && <ErrorState message="Failed to load repositories" onRetry={reload} />}
|
||||||
<div className="card stack">
|
|
||||||
<p>Failed to load repositories</p>
|
|
||||||
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
|
{isEmpty && <EmptyState message="No repositories yet. Create your first repository above." />}
|
||||||
|
|
||||||
{status === "ready" && repositories.length > 0 && (
|
{status === "ready" && safeRepositories.length > 0 && (
|
||||||
<div className="repository-list">
|
<div className="repository-list">
|
||||||
{repositories.map((repo) => (
|
{safeRepositories.map((repo) => (
|
||||||
<article className="card repository-card" key={repo.id}>
|
<article className="card repository-card" key={repo.id}>
|
||||||
<div className="repository-info">
|
<div className="repository-info">
|
||||||
<h3>{repo.name}</h3>
|
<h3>{repo.name}</h3>
|
||||||
@@ -131,7 +113,7 @@ export const GitRepositoriesPage = () => {
|
|||||||
open={showCreate}
|
open={showCreate}
|
||||||
title="Create Repository"
|
title="Create Repository"
|
||||||
onClose={() => setShowCreate(false)}
|
onClose={() => setShowCreate(false)}
|
||||||
onCreated={loadRepositories}
|
onCreated={reload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,39 +1,38 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { useAuth } from "../state/auth";
|
import { useAuth } from "../state/auth";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
import type { UserProfile } from "../api/profile";
|
import type { UserProfile } from "../api/profile";
|
||||||
|
|
||||||
type ProfileStatus = "loading" | "ready" | "error" | "saving";
|
type ProfileStatus = "loading" | "ready" | "error" | "saving";
|
||||||
|
|
||||||
export const ProfilePage = () => {
|
export const ProfilePage = () => {
|
||||||
const { refreshSession } = useAuth();
|
const { refreshSession } = useAuth();
|
||||||
const [status, setStatus] = useState<ProfileStatus>("loading");
|
const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []);
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const loadProfile = useCallback(async () => {
|
// Sync loaded profile into form fields
|
||||||
setStatus("loading");
|
useEffect(() => {
|
||||||
|
if (profile) {
|
||||||
|
setName(profile.name);
|
||||||
|
setEmail(profile.email);
|
||||||
|
setDisplayStatus("ready");
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
|
||||||
const data = await getProfile();
|
|
||||||
setProfile(data);
|
|
||||||
setName(data.name);
|
|
||||||
setEmail(data.email);
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setProfile(null);
|
|
||||||
setStatus("error");
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, [profile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadProfile();
|
if (loadStatus === "error") {
|
||||||
}, [loadProfile]);
|
setDisplayStatus("error");
|
||||||
|
}
|
||||||
|
}, [loadStatus]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -45,16 +44,15 @@ export const ProfilePage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("saving");
|
setDisplayStatus("saving");
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await updateProfile({ name: name.trim(), email: email.trim() });
|
await updateProfile({ name: name.trim(), email: email.trim() });
|
||||||
setProfile(updated);
|
|
||||||
await refreshSession();
|
await refreshSession();
|
||||||
setStatus("ready");
|
setDisplayStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to update profile");
|
setError("Failed to update profile");
|
||||||
setStatus("ready");
|
setDisplayStatus("ready");
|
||||||
}
|
}
|
||||||
}, [name, email, refreshSession]);
|
}, [name, email, refreshSession]);
|
||||||
|
|
||||||
@@ -73,19 +71,19 @@ export const ProfilePage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("saving");
|
setDisplayStatus("saving");
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await uploadAvatar(file);
|
await uploadAvatar(file);
|
||||||
setProfile(updated);
|
|
||||||
await refreshSession();
|
await refreshSession();
|
||||||
setStatus("ready");
|
reload();
|
||||||
|
setDisplayStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to upload avatar");
|
setError("Failed to upload avatar");
|
||||||
setStatus("ready");
|
setDisplayStatus("ready");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshSession]
|
[refreshSession, reload]
|
||||||
);
|
);
|
||||||
|
|
||||||
const avatarUrl = profile?.avatar_url ?? null;
|
const avatarUrl = profile?.avatar_url ?? null;
|
||||||
@@ -94,19 +92,11 @@ export const ProfilePage = () => {
|
|||||||
<section className="stack">
|
<section className="stack">
|
||||||
<h1>Profile</h1>
|
<h1>Profile</h1>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading profile...</p>}
|
{displayStatus === "loading" && <LoadingState message="Loading profile..." />}
|
||||||
|
|
||||||
{status === "error" && (
|
{displayStatus === "error" && <ErrorState message="Failed to load profile" onRetry={reload} />}
|
||||||
<div className="card stack">
|
|
||||||
<p>Failed to load profile</p>
|
|
||||||
<button className="secondary-button" onClick={() => void loadProfile()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(status === "ready" || status === "saving") && profile && (
|
{(displayStatus === "ready" || displayStatus === "saving") && profile && (
|
||||||
<div className="card stack">
|
<div className="card stack">
|
||||||
<div className="profile-avatar-section">
|
<div className="profile-avatar-section">
|
||||||
<div className="avatar-preview">
|
<div className="avatar-preview">
|
||||||
@@ -118,11 +108,11 @@ export const ProfilePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
disabled={status === "saving"}
|
disabled={displayStatus === "saving"}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{status === "saving" ? (
|
{displayStatus === "saving" ? (
|
||||||
<>
|
<>
|
||||||
<Icon name="loading" size="sm" />
|
<Icon name="loading" size="sm" />
|
||||||
Uploading...
|
Uploading...
|
||||||
@@ -146,7 +136,7 @@ export const ProfilePage = () => {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profile-name">Name</label>
|
<label htmlFor="profile-name">Name</label>
|
||||||
<input
|
<input
|
||||||
disabled={status === "saving"}
|
disabled={displayStatus === "saving"}
|
||||||
id="profile-name"
|
id="profile-name"
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -157,7 +147,7 @@ export const ProfilePage = () => {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profile-email">Email</label>
|
<label htmlFor="profile-email">Email</label>
|
||||||
<input
|
<input
|
||||||
disabled={status === "saving"}
|
disabled={displayStatus === "saving"}
|
||||||
id="profile-email"
|
id="profile-email"
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
type="email"
|
type="email"
|
||||||
@@ -170,11 +160,11 @@ export const ProfilePage = () => {
|
|||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={status === "saving"}
|
disabled={displayStatus === "saving"}
|
||||||
onClick={() => void handleSave()}
|
onClick={() => void handleSave()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{status === "saving" ? (
|
{displayStatus === "saving" ? (
|
||||||
<>
|
<>
|
||||||
<Icon name="loading" size="sm" />
|
<Icon name="loading" size="sm" />
|
||||||
Saving...
|
Saving...
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
@@ -10,15 +10,15 @@ import {
|
|||||||
type ProjectCreateInput,
|
type ProjectCreateInput,
|
||||||
type ProjectUpdateInput,
|
type ProjectUpdateInput,
|
||||||
} from "../api/projects";
|
} from "../api/projects";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
|
|
||||||
type ProjectsStatus = "loading" | "ready" | "error";
|
|
||||||
type DialogMode = "none" | "create" | "edit";
|
type DialogMode = "none" | "create" | "edit";
|
||||||
|
|
||||||
export const ProjectsPage = () => {
|
export const ProjectsPage = () => {
|
||||||
const [status, setStatus] = useState<ProjectsStatus>("loading");
|
const { data: projects, status, reload } = useAsyncData<Project[]>(listProjects, []);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||||
const [formName, setFormName] = useState("");
|
const [formName, setFormName] = useState("");
|
||||||
@@ -26,21 +26,7 @@ export const ProjectsPage = () => {
|
|||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadProjects = useCallback(async () => {
|
const safeProjects = projects ?? [];
|
||||||
setStatus("loading");
|
|
||||||
try {
|
|
||||||
const data = await listProjects();
|
|
||||||
setProjects(data);
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setProjects([]);
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadProjects();
|
|
||||||
}, [loadProjects]);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setFormName("");
|
setFormName("");
|
||||||
@@ -88,7 +74,7 @@ export const ProjectsPage = () => {
|
|||||||
await updateProject(editingProject.id, input);
|
await updateProject(editingProject.id, input);
|
||||||
}
|
}
|
||||||
closeDialog();
|
closeDialog();
|
||||||
await loadProjects();
|
reload();
|
||||||
} catch {
|
} catch {
|
||||||
setFormError("Failed to save project");
|
setFormError("Failed to save project");
|
||||||
}
|
}
|
||||||
@@ -98,13 +84,13 @@ export const ProjectsPage = () => {
|
|||||||
try {
|
try {
|
||||||
await deleteProject(projectId);
|
await deleteProject(projectId);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
await loadProjects();
|
reload();
|
||||||
} catch {
|
} catch {
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEmpty = status === "ready" && projects.length === 0;
|
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
@@ -116,23 +102,15 @@ export const ProjectsPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading projects...</p>}
|
{status === "loading" && <LoadingState message="Loading projects..." />}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && <ErrorState message="Failed to load projects" onRetry={reload} />}
|
||||||
<div className="card stack">
|
|
||||||
<p>Failed to load projects</p>
|
|
||||||
<button className="secondary-button" onClick={() => void loadProjects()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEmpty && <p className="muted">No projects yet. Create your first project above.</p>}
|
{isEmpty && <EmptyState message="No projects yet. Create your first project above." />}
|
||||||
|
|
||||||
{status === "ready" && projects.length > 0 && (
|
{status === "ready" && safeProjects.length > 0 && (
|
||||||
<div className="project-list">
|
<div className="project-list">
|
||||||
{projects.map((project) => (
|
{safeProjects.map((project) => (
|
||||||
<article className="card project-card" key={project.id}>
|
<article className="card project-card" key={project.id}>
|
||||||
<div className="project-info">
|
<div className="project-info">
|
||||||
<h3>{project.name}</h3>
|
<h3>{project.name}</h3>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
|
||||||
@@ -164,26 +165,16 @@ export const RepoWorkspace = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "loading" && (
|
{status === "loading" && (
|
||||||
<p className="muted">Loading repositories...</p>
|
<LoadingState message="Loading repositories..." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="card stack">
|
<ErrorState message="Failed to load repositories" onRetry={() => void loadRepositories()} />
|
||||||
<p>Failed to load repositories</p>
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={() => void loadRepositories()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "empty" && (
|
{status === "empty" && (
|
||||||
<div className="card stack">
|
<div className="card stack">
|
||||||
<p>No repositories in this project yet.</p>
|
<EmptyState message="No repositories in this project yet." />
|
||||||
<Link
|
<Link
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
to={`/projects/${projectId}/settings/repositories`}
|
to={`/projects/${projectId}/settings/repositories`}
|
||||||
@@ -486,7 +477,7 @@ const FileBrowser = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{entries.length === 0 && (
|
{entries.length === 0 && (
|
||||||
<p className="muted">No files in this repository yet.</p>
|
<EmptyState message="No files in this repository yet." />
|
||||||
)}
|
)}
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||||
|
|||||||
+18
-115
@@ -7,18 +7,15 @@ import { listRepositories, type GitRepository } from "../api/git_repositories";
|
|||||||
import {
|
import {
|
||||||
getUserSessions,
|
getUserSessions,
|
||||||
type Session,
|
type Session,
|
||||||
deleteInstance,
|
|
||||||
stopInstance,
|
|
||||||
startInstance,
|
|
||||||
checkInstanceHealth,
|
checkInstanceHealth,
|
||||||
recreateInstanceTunnel,
|
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||||
import { Icon } from "../components/icon";
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { CreateSessionForm } from "../components/create-session-form";
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
import { SessionList } from "../components/session-list";
|
import { SessionList } from "../components/session-list";
|
||||||
import { SessionCard } from "../components/session-card";
|
import { SessionCard } from "../components/session-card";
|
||||||
|
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||||
import type { InstanceHealth } from "../api/sessions";
|
import type { InstanceHealth } from "../api/sessions";
|
||||||
|
|
||||||
type SessionsStatus = "loading" | "ready" | "error";
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
@@ -34,11 +31,7 @@ export const SessionsPage = () => {
|
|||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||||
|
|
||||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
|
||||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -83,7 +76,18 @@ export const SessionsPage = () => {
|
|||||||
void loadToolTypes();
|
void loadToolTypes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadingSessionId,
|
||||||
|
dirtyDeleteSession,
|
||||||
|
dirtyDeleteFiles,
|
||||||
|
handleOpen,
|
||||||
|
handleStart,
|
||||||
|
handleStop,
|
||||||
|
handleDelete,
|
||||||
|
handleForceDelete,
|
||||||
|
handleRecreateTunnel,
|
||||||
|
clearDirtyDelete,
|
||||||
|
} = useInstanceActions({ onRefresh: loadSessions });
|
||||||
|
|
||||||
// Poll health every 30 seconds for active web-enabled instances
|
// Poll health every 30 seconds for active web-enabled instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -154,116 +158,15 @@ export const SessionsPage = () => {
|
|||||||
await loadSessions();
|
await loadSessions();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (session: Session) => {
|
|
||||||
if (loadingSessionId === session.id) return;
|
|
||||||
setLoadingSessionId(session.id);
|
|
||||||
try {
|
|
||||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (session: Session) => {
|
|
||||||
if (loadingSessionId === session.id) return;
|
|
||||||
setLoadingSessionId(session.id);
|
|
||||||
try {
|
|
||||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
setDirtyDeleteSession(null);
|
|
||||||
setDirtyDeleteFiles([]);
|
|
||||||
// Remove from local state immediately
|
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
|
||||||
} catch (error) {
|
|
||||||
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
|
||||||
if (axiosError.response?.status === 409) {
|
|
||||||
const detail = axiosError.response.data?.detail;
|
|
||||||
if (detail?.changed_files) {
|
|
||||||
setDirtyDeleteSession(session);
|
|
||||||
setDirtyDeleteFiles(detail.changed_files);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleForceDelete = async (session: Session) => {
|
|
||||||
if (loadingSessionId === session.id) return;
|
|
||||||
setLoadingSessionId(session.id);
|
|
||||||
try {
|
|
||||||
await deleteInstance(session.project_id, session.repository_id, session.id, true);
|
|
||||||
setDirtyDeleteSession(null);
|
|
||||||
setDirtyDeleteFiles([]);
|
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRecreateTunnel = async (session: Session) => {
|
|
||||||
if (loadingSessionId === session.id) return;
|
|
||||||
setLoadingSessionId(session.id);
|
|
||||||
try {
|
|
||||||
await recreateInstanceTunnel(
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id,
|
|
||||||
session.id
|
|
||||||
);
|
|
||||||
// Refresh sessions to get new URL
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStart = async (session: Session) => {
|
|
||||||
if (loadingSessionId === session.id) return;
|
|
||||||
setLoadingSessionId(session.id);
|
|
||||||
try {
|
|
||||||
await startInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpen = (session: Session) => {
|
|
||||||
if (session.url) {
|
|
||||||
window.open(session.url, '_blank', 'noopener,noreferrer');
|
|
||||||
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
|
||||||
navigate(`/instances/${session.id}/terminal`);
|
|
||||||
} else {
|
|
||||||
navigate(`/projects/${session.project_id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack sessions-page">
|
<section className="stack sessions-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Sessions</h1>
|
<h1>Sessions</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading sessions...</p>}
|
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && <ErrorState message="Failed to load sessions" onRetry={() => void loadSessions()} />}
|
||||||
<div className="card stack">
|
|
||||||
<p>Failed to load sessions</p>
|
|
||||||
<button className="secondary-button" onClick={() => void loadSessions()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "ready" && (
|
{status === "ready" && (
|
||||||
<>
|
<>
|
||||||
@@ -311,7 +214,7 @@ export const SessionsPage = () => {
|
|||||||
|
|
||||||
{/* Dirty Delete Confirmation Modal */}
|
{/* Dirty Delete Confirmation Modal */}
|
||||||
{dirtyDeleteSession && (
|
{dirtyDeleteSession && (
|
||||||
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
|
<div className="modal-overlay" onClick={clearDirtyDelete}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>Uncommitted Changes</h3>
|
<h3>Uncommitted Changes</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -330,7 +233,7 @@ export const SessionsPage = () => {
|
|||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() => setDirtyDeleteSession(null)}
|
onClick={clearDirtyDelete}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||||
|
|
||||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
type SettingsStatus = "loading" | "ready" | "error";
|
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ label: "General", path: "general" },
|
{ label: "General", path: "general" },
|
||||||
@@ -26,7 +26,7 @@ type SettingsOutletContext = {
|
|||||||
|
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
const { data: loadedConfig, status, reload } = useAsyncData<UserConfig>(getUserConfig, []);
|
||||||
const [config, setConfig] = useState<UserConfig>({
|
const [config, setConfig] = useState<UserConfig>({
|
||||||
theme: "system",
|
theme: "system",
|
||||||
default_editor: null,
|
default_editor: null,
|
||||||
@@ -36,19 +36,12 @@ export const SettingsPage = () => {
|
|||||||
});
|
});
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
|
||||||
const loadConfig = useCallback(async () => {
|
// Sync loaded config into local editable state
|
||||||
try {
|
|
||||||
const data = await getUserConfig();
|
|
||||||
setConfig(data);
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadConfig();
|
if (loadedConfig) {
|
||||||
}, [loadConfig]);
|
setConfig(loadedConfig);
|
||||||
|
}
|
||||||
|
}, [loadedConfig]);
|
||||||
|
|
||||||
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
|
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
|
||||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -79,17 +72,13 @@ export const SettingsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return <section className="stack"><p className="muted">Loading settings...</p></section>;
|
return <section className="stack"><LoadingState message="Loading settings..." /></section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<p>Failed to load settings</p>
|
<ErrorState message="Failed to load settings" onRetry={reload} />
|
||||||
<button className="secondary-button" onClick={() => void loadConfig()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
|
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
|
|
||||||
export const SSHKeysPage = () => {
|
export const SSHKeysPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
const { data: keys, status, error, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [newKeyName, setNewKeyName] = useState("");
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||||
@@ -17,23 +17,9 @@ export const SSHKeysPage = () => {
|
|||||||
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
||||||
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
||||||
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
||||||
|
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const safeKeys = keys ?? [];
|
||||||
loadKeys();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadKeys() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await listSSHKeys();
|
|
||||||
setKeys(data);
|
|
||||||
setError(null);
|
|
||||||
} catch {
|
|
||||||
setError("Failed to load SSH keys");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGenerate(e: React.FormEvent) {
|
async function handleGenerate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -45,7 +31,7 @@ export const SSHKeysPage = () => {
|
|||||||
setNewKeyName("");
|
setNewKeyName("");
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to generate SSH key");
|
setMutationError("Failed to generate SSH key");
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +44,7 @@ export const SSHKeysPage = () => {
|
|||||||
await deleteSSHKey(keyId);
|
await deleteSSHKey(keyId);
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to delete SSH key");
|
setMutationError("Failed to delete SSH key");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +60,9 @@ export const SSHKeysPage = () => {
|
|||||||
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
||||||
const result = await signPayload(keyId, { payload: payload.trim() });
|
const result = await signPayload(keyId, { payload: payload.trim() });
|
||||||
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
||||||
setError(null);
|
setMutationError(null);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to sign payload");
|
setMutationError("Failed to sign payload");
|
||||||
} finally {
|
} finally {
|
||||||
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
||||||
}
|
}
|
||||||
@@ -94,15 +80,15 @@ export const SSHKeysPage = () => {
|
|||||||
signature: signature.trim(),
|
signature: signature.trim(),
|
||||||
});
|
});
|
||||||
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
||||||
setError(null);
|
setMutationError(null);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to verify signature");
|
setMutationError("Failed to verify signature");
|
||||||
} finally {
|
} finally {
|
||||||
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
@@ -116,7 +102,7 @@ export const SSHKeysPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{mutationError && <div className="error">{mutationError}</div>}
|
||||||
|
|
||||||
<form onSubmit={handleGenerate} className="stack">
|
<form onSubmit={handleGenerate} className="stack">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -145,11 +131,13 @@ export const SSHKeysPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
|
||||||
|
|
||||||
<div className="keys-list">
|
<div className="keys-list">
|
||||||
{keys.length === 0 ? (
|
{safeKeys.length === 0 ? (
|
||||||
<p className="muted">No SSH keys yet. Generate one above.</p>
|
<EmptyState message="No SSH keys yet. Generate one above." />
|
||||||
) : (
|
) : (
|
||||||
keys.map((key) => (
|
safeKeys.map((key) => (
|
||||||
<div key={key.id} className="key-card">
|
<div key={key.id} className="key-card">
|
||||||
<div className="key-header">
|
<div className="key-header">
|
||||||
<h3>{key.name}</h3>
|
<h3>{key.name}</h3>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
import { extractErrorMessage } from "../utils/errors";
|
||||||
import { MobileListView } from "../components/mobile-list-view";
|
import { MobileListView } from "../components/mobile-list-view";
|
||||||
import { MobileDetailView } from "../components/mobile-detail-view";
|
import { MobileDetailView } from "../components/mobile-detail-view";
|
||||||
import { MobileEditView } from "../components/mobile-edit-view";
|
import { MobileEditView } from "../components/mobile-edit-view";
|
||||||
@@ -201,16 +203,6 @@ export const ToolWorkshopPage = () => {
|
|||||||
setShowFolderForm(false);
|
setShowFolderForm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractErrorMessage = (err: unknown): string => {
|
|
||||||
const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } };
|
|
||||||
const detail = axiosError?.response?.data?.detail;
|
|
||||||
if (typeof detail === 'string') return detail;
|
|
||||||
if (Array.isArray(detail)) {
|
|
||||||
return detail.map(d => typeof d === 'string' ? d : d.msg || JSON.stringify(d)).join(', ');
|
|
||||||
}
|
|
||||||
return "Failed to save";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToolTypeSubmit = async (e: React.FormEvent) => {
|
const handleToolTypeSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setToolTypeError(null);
|
setToolTypeError(null);
|
||||||
@@ -500,7 +492,7 @@ export const ToolWorkshopPage = () => {
|
|||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<p>Loading Tool Workshop...</p>
|
<LoadingState message="Loading Tool Workshop..." />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -508,10 +500,7 @@ export const ToolWorkshopPage = () => {
|
|||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<p className="text-error">Failed to load Tool Workshop.</p>
|
<ErrorState message="Failed to load Tool Workshop." onRetry={loadData} />
|
||||||
<button onClick={loadData}>
|
|
||||||
<Icon name="refresh" size="sm" /> Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1314,7 +1303,7 @@ export const ToolWorkshopPage = () => {
|
|||||||
|
|
||||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||||
{toolConfigs.length === 0 ? (
|
{toolConfigs.length === 0 ? (
|
||||||
<p className="muted">No configurations for this tool type yet.</p>
|
<EmptyState message="No configurations for this tool type yet." />
|
||||||
) : (
|
) : (
|
||||||
toolConfigs.map((config) => (
|
toolConfigs.map((config) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const extractErrorMessage = (err: unknown): string => {
|
||||||
|
const axiosError = err as { response?: { data?: { detail?: string | Array<{ msg?: string }> } } };
|
||||||
|
const detail = axiosError?.response?.data?.detail;
|
||||||
|
if (typeof detail === "string") return detail;
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
|
||||||
|
}
|
||||||
|
return "Failed to save";
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user