Compare commits
169 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22474cdba5 | |||
| 0c839e8c6f | |||
| c63cf7db50 | |||
| d9d2b91384 | |||
| d6ea5fb1fd | |||
| 1883825b18 | |||
| bc71fd6fac | |||
| 28aa9ccf5a | |||
| 44dd80cb58 | |||
| 23485833d8 | |||
| e23dcdf4e1 | |||
| f05ac55875 | |||
| bcefeb4163 | |||
| 33d08faf70 | |||
| 8a58c61278 | |||
| 8231e750d9 | |||
| 6ce645d210 | |||
| 89ca9f10c7 | |||
| baabd1fa62 | |||
| f14fc37e75 | |||
| e07938098a | |||
| 943b9db5c7 | |||
| a4604d6a9a | |||
| 18204628cc | |||
| 93b415c53e | |||
| e7adfb462b | |||
| ed1d6528c6 | |||
| c4be7163d6 | |||
| 13f55fff47 | |||
| 0ec20b9c23 | |||
| 4c11163bff | |||
| adda76a2ff | |||
| 47962ed476 | |||
| 6a0c9bd669 | |||
| 76fbf0a755 | |||
| 187193fa6e | |||
| cd9c9539a2 | |||
| 555517c144 | |||
| fc1554140f | |||
| bc5e80c954 | |||
| ab79080f0b | |||
| 4c216dd1ca | |||
| a37a3122f9 | |||
| a905cf729e | |||
| 3a16775188 | |||
| b363d89768 | |||
| 27c39f9cfc | |||
| e8d5b16acc | |||
| 437ad840ef | |||
| c2a232d8f0 | |||
| adaedb70ef | |||
| 5178cf9cbf | |||
| 01a0ef46c9 | |||
| a4c429d53a | |||
| 1fc244e818 | |||
| 8fb4b67372 | |||
| fbd41e3eb4 | |||
| 9c57a94e9f | |||
| 84b7b64ec0 | |||
| 29ed0f2a3b | |||
| 6c32e5266c | |||
| 9e88acaa36 | |||
| 0d10caf489 | |||
| 245d79569e | |||
| 8e86cd255c | |||
| 6ee667c384 | |||
| 21285498ae | |||
| 9751b65dce | |||
| 612217ad89 | |||
| b9ea806c0d | |||
| 22f736ce20 | |||
| deb22bec0f | |||
| 9320e175d1 | |||
| d8f220d825 | |||
| 77b7c82563 | |||
| 3fc392a314 | |||
| edd8882fa0 | |||
| 760369b102 | |||
| 8da527f964 | |||
| 76fbd74d20 | |||
| 384beeca8a | |||
| 869efda214 | |||
| 51b5d723ac | |||
| 33b482ce91 | |||
| 0cb2eefd29 | |||
| 5f5dc9c851 | |||
| 4864d269e8 | |||
| 29e4bed9e6 | |||
| c8da0ab6c4 | |||
| b04a458975 | |||
| 2299bd51ba | |||
| 45fb0c753c | |||
| c82e628e6f | |||
| 383a874bcf | |||
| 18634387c7 | |||
| 4b09f611d6 | |||
| 5c998f5bf9 | |||
| c595a513d5 | |||
| 08bd8bf7f9 | |||
| 1b7308d091 | |||
| 1060cb60ed | |||
| b58696bb7e | |||
| 4d0834069b | |||
| fc41b51bf0 | |||
| 3dbb6321fc | |||
| 7282b91d99 | |||
| 9ad11a021c | |||
| 9f29ac15da | |||
| 3092038e40 | |||
| 738e01bb7c | |||
| 4d0c70de98 | |||
| 701bd57293 | |||
| 85f04447ea | |||
| d931f3071d | |||
| 4a356fe88e | |||
| ca22e9c9d2 | |||
| 4de312c170 | |||
| e7a89a853f | |||
| 7b23618ae7 | |||
| 8b4e1a7428 | |||
| f37813a317 | |||
| 6e600fdbcd | |||
| f4211ad452 | |||
| 058c501e4a | |||
| 74033243c9 | |||
| 9910fd4445 | |||
| 4a66a4a384 | |||
| fa20d00d14 | |||
| f59274ae64 | |||
| b89fb608b6 | |||
| 116cd22ff8 | |||
| 0094ba01cd | |||
| f6fb984ec6 | |||
| 0dba13a354 | |||
| 5500552993 | |||
| fed49dba6d | |||
| fa17b13413 | |||
| 38185b9659 | |||
| 07e7c6ea0f | |||
| b638dccd36 | |||
| 8f7e19fdb1 | |||
| ba76f09a1c | |||
| 8b08c3886c | |||
| 5577e19782 | |||
| 268651bab0 | |||
| fc873e2d6b | |||
| 7389344b6d | |||
| 0a8f1419a6 | |||
| 09938bede4 | |||
| 073013bc61 | |||
| 865b9411da | |||
| 48d1a7d05c | |||
| ab1d3a6aa1 | |||
| d117047711 | |||
| d1c187ab16 | |||
| ecd3ba5918 | |||
| a919ff8611 | |||
| 39cf01c3c9 | |||
| c49bb028c4 | |||
| 0baf7f7750 | |||
| 6e496e102d | |||
| 7cb88a3163 | |||
| ce1a73abce | |||
| 5fddc65468 | |||
| f065e2b8c0 | |||
| f4cf286bb5 | |||
| 549d13f469 | |||
| 45cd192188 | |||
| 030a39dd5a |
@@ -1,2 +1,2 @@
|
|||||||
130078
|
262629
|
||||||
1779618546909
|
1779624255076
|
||||||
|
|||||||
@@ -1 +1,43 @@
|
|||||||
{"blockedId":"el-1of","blockerId":"el-258","type":"parent-child","createdAt":"2026-05-24T09:44:58.759Z","createdBy":"el-2jua"}
|
{"blockedId":"el-1of","blockerId":"el-258","type":"parent-child","createdAt":"2026-05-24T09:44:58.759Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5fe","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:40.892Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1nj","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.010Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1bn","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.127Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4hr","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.244Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.372Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.490Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.607Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5j5","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.726Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-2xl","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.844Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.959Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-107","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.074Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.195Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.311Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-14w","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.425Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.541Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1nj","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.651Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1bn","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.761Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4hr","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.868Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:42.979Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.092Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.205Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.313Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.422Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.529Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:43.647Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5j5","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.758Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-2xl","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.876Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.987Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:44.096Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:44.208Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-107","blockerId":"el-5z8","type":"blocks","createdAt":"2026-05-24T12:44:44.319Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-5j5","type":"blocks","createdAt":"2026-05-24T12:44:44.429Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-2xl","type":"blocks","createdAt":"2026-05-24T12:44:44.539Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-4bc","type":"blocks","createdAt":"2026-05-24T12:44:44.650Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-107","type":"blocks","createdAt":"2026-05-24T12:44:44.761Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-14w","blockerId":"el-32e","type":"blocks","createdAt":"2026-05-24T12:44:44.873Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-3ou","type":"blocks","createdAt":"2026-05-24T12:44:44.987Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-14w","type":"blocks","createdAt":"2026-05-24T12:44:45.107Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-375","blockerId":"el-26p","type":"replies-to","createdAt":"2026-05-24T13:21:42.486Z","createdBy":"el-2i1s"}
|
||||||
|
{"blockedId":"el-3n4","blockerId":"el-31p","type":"replies-to","createdAt":"2026-05-24T13:21:46.044Z","createdBy":"el-13ju"}
|
||||||
|
{"blockedId":"el-3jer","blockerId":"el-1xx","type":"replies-to","createdAt":"2026-05-24T13:24:47.580Z","createdBy":"el-4350"}
|
||||||
|
{"blockedId":"el-1afv","blockerId":"el-1ozw","type":"replies-to","createdAt":"2026-05-24T13:32:42.658Z","createdBy":"el-51a8"}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -7,8 +7,6 @@ Create Date: 2026-05-22 21:50:00.000000
|
|||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "0014_merge_heads"
|
revision: str = "0014_merge_heads"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "0015_single_interface"
|
revision: str = "0015_single_interface"
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""add pi agent tool type
|
||||||
|
|
||||||
|
Revision ID: 20260527_160017_add_pi_agent
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-27T16:00:17
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "20260527_160017_add_pi_agent"
|
||||||
|
down_revision: Union[str, None] = "2026_05_27_external_repos"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
PI_AGENT_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Check if pi-agent already exists
|
||||||
|
conn = op.get_bind()
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
INSERT INTO tool_types (
|
||||||
|
id, name, display_name, description, category,
|
||||||
|
interface_type, requires_port, default_port,
|
||||||
|
definition_type, compose_template, dockerfile_template, required_variables,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:id, :name, :display_name, :description, :category,
|
||||||
|
:interface_type, :requires_port, :default_port,
|
||||||
|
:definition_type, :compose_template, :dockerfile_template, :required_variables,
|
||||||
|
now(), now()
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": PI_AGENT_ID,
|
||||||
|
"name": "pi-agent",
|
||||||
|
"display_name": "Pi Agent",
|
||||||
|
"description": "Pi coding agent terminal environment with nvim, ranger, and tmux",
|
||||||
|
"category": "development",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"compose_template": """services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
volumes:
|
||||||
|
- ${REPO_PATH}:/workspace
|
||||||
|
working_dir: /workspace
|
||||||
|
command: /bin/bash""",
|
||||||
|
"dockerfile_template": """# Pi Coding Agent - Terminal-based coding harness
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \\
|
||||||
|
curl \\
|
||||||
|
wget \\
|
||||||
|
git \\
|
||||||
|
neovim \\
|
||||||
|
ranger \\
|
||||||
|
tmux \\
|
||||||
|
htop \\
|
||||||
|
tree \\
|
||||||
|
jq \\
|
||||||
|
ca-certificates \\
|
||||||
|
python3 \\
|
||||||
|
python3-pip \\
|
||||||
|
build-essential \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js (required for Pi)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
|
||||||
|
&& apt-get install -y nodejs \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Pi Coding Agent globally
|
||||||
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main \\
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \\
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
# Create default tmux config
|
||||||
|
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
|
# Create default ranger config
|
||||||
|
RUN mkdir -p /home/user/.config/ranger \\
|
||||||
|
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
|
# Set up Pi config directory
|
||||||
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
# Default to bash (Pi is invoked manually via `pi` command)
|
||||||
|
CMD ["/bin/bash"]""",
|
||||||
|
"required_variables": json.dumps(["REPO_PATH"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(
|
||||||
|
sa.text("DELETE FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add startup_command to tool_types
|
||||||
|
|
||||||
|
Revision ID: 2026_05_24_220141
|
||||||
|
Revises: 6fc7bfcf199f
|
||||||
|
Create Date: 2026-05-24 22:01:41.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_24_220141"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "6fc7bfcf199f"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_types",
|
||||||
|
sa.Column("startup_command", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_types", "startup_command")
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""add_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 2026_05_24_add_config_profiles
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-24 14:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_24_add_config_profiles"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create config_profiles table
|
||||||
|
op.create_table(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("env_vars", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("runtime_hints", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("mounts", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="[]"),
|
||||||
|
sa.Column("files", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for config_profiles
|
||||||
|
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
||||||
|
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
||||||
|
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
|
||||||
|
|
||||||
|
# Create config_profile_includes table
|
||||||
|
op.create_table(
|
||||||
|
"config_profile_includes",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_profile_includes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for config_profile_includes
|
||||||
|
op.create_index("idx_config_profile_includes_profile", "config_profile_includes", ["profile_id"])
|
||||||
|
op.create_index("idx_config_profile_includes_included", "config_profile_includes", ["included_profile_id"])
|
||||||
|
|
||||||
|
# Add selected_config_profile_id to tool_instances
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("selected_config_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_instances_config_profile", "tool_instances", ["selected_config_profile_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove selected_config_profile_id from tool_instances
|
||||||
|
op.drop_index("idx_tool_instances_config_profile", table_name="tool_instances")
|
||||||
|
op.drop_column("tool_instances", "selected_config_profile_id")
|
||||||
|
|
||||||
|
# Drop config_profile_includes table
|
||||||
|
op.drop_index("idx_config_profile_includes_included", table_name="config_profile_includes")
|
||||||
|
op.drop_index("idx_config_profile_includes_profile", table_name="config_profile_includes")
|
||||||
|
op.drop_table("config_profile_includes")
|
||||||
|
|
||||||
|
# Drop config_profiles table
|
||||||
|
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
|
||||||
|
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
|
||||||
|
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
|
||||||
|
op.drop_table("config_profiles")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add_git_mounts_to_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 2026_05_26_add_git_mounts
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-26 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_26_add_git_mounts"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "2026_05_24_220141"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("git_mounts", sa.JSON(), nullable=True, default=list),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("config_profiles", "git_mounts")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""make_project_id_nullable_in_git_repositories
|
||||||
|
|
||||||
|
Revision ID: 2026_05_27_external_repos
|
||||||
|
Revises: 2026_05_26_add_git_mounts
|
||||||
|
Create Date: 2026-05-27 08:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_27_external_repos"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "2026_05_26_add_git_mounts"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Expand alembic_version version_num to avoid truncation errors
|
||||||
|
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(64)")
|
||||||
|
|
||||||
|
# Make project_id nullable to allow external repositories
|
||||||
|
op.alter_column(
|
||||||
|
"git_repositories",
|
||||||
|
"project_id",
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"git_repositories",
|
||||||
|
"project_id",
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(32)")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""merge_remove_is_builtin_and_add_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 6fc7bfcf199f
|
||||||
|
Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
|
||||||
|
Create Date: 2026-05-24 18:00:43.990361
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '6fc7bfcf199f'
|
||||||
|
down_revision = ('2026_05_23_remove_is_builtin', '2026_05_24_add_config_profiles')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -7,8 +7,6 @@ Create Date: 2026-05-24 10:43:14.000000
|
|||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "f3d2dc90ba3a"
|
revision: str = "f3d2dc90ba3a"
|
||||||
|
|||||||
+10
-10
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
|
|||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
||||||
response = RedirectResponse(location)
|
response = RedirectResponse(location)
|
||||||
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
||||||
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
||||||
@@ -63,7 +63,7 @@ async def callback(
|
|||||||
auth_next: str | None = Cookie(default="/"),
|
auth_next: str | None = Cookie(default="/"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
||||||
|
|
||||||
if auth_state is None or auth_state != state:
|
if auth_state is None or auth_state != state:
|
||||||
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
||||||
@@ -71,7 +71,7 @@ async def callback(
|
|||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||||
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
@@ -92,7 +92,7 @@ async def callback(
|
|||||||
access_token=token_payload["access_token"],
|
access_token=token_payload["access_token"],
|
||||||
client=client,
|
client=client,
|
||||||
)
|
)
|
||||||
logger.info("User info fetched successfully")
|
logger.debug("User info fetched successfully")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("User info fetch failed: %s", exc)
|
logger.error("User info fetch failed: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
||||||
@@ -100,19 +100,19 @@ async def callback(
|
|||||||
authentik_id = str(user_info.get("sub", ""))
|
authentik_id = str(user_info.get("sub", ""))
|
||||||
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||||
name = str(user_info.get("name", email))
|
name = str(user_info.get("name", email))
|
||||||
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
logger.info("Creating new user: authentik_id=%s", authentik_id)
|
logger.debug("Creating new user: authentik_id=%s", authentik_id)
|
||||||
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
logger.info("New user created: id=%s", user.id)
|
logger.info("New user created: id=%s", user.id)
|
||||||
else:
|
else:
|
||||||
logger.info("Existing user found: id=%s, updating info", user.id)
|
logger.debug("Existing user found: id=%s, updating info", user.id)
|
||||||
user.email = email
|
user.email = email
|
||||||
user.name = name
|
user.name = name
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -165,20 +165,20 @@ async def me(
|
|||||||
session_cookie: str | None = Cookie(default=None, alias="session"),
|
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
|
logger.debug("Auth /me called, cookie present: %s", bool(session_cookie))
|
||||||
|
|
||||||
if not session_cookie:
|
if not session_cookie:
|
||||||
logger.warning("Auth /me: missing session cookie")
|
logger.warning("Auth /me: missing session cookie")
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
||||||
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
user_id = payload["user_id"]
|
user_id = payload["user_id"]
|
||||||
logger.info("Auth /me: decoded session for user_id=%s", user_id)
|
logger.debug("Auth /me: decoded session for user_id=%s", user_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("Auth /me: invalid session: %s", exc)
|
logger.warning("Auth /me: invalid session: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -0,0 +1,723 @@
|
|||||||
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
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.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.services.config_profile_resolver import (
|
||||||
|
ConfigProfileCycleError,
|
||||||
|
check_include_cycle,
|
||||||
|
resolve_profile,
|
||||||
|
resolved_profile_to_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
|
MAX_PROFILE_SIZE_MB = 10
|
||||||
|
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_uuid(v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
try:
|
||||||
|
uuid.UUID(v)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid UUID: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_profile_size(data: dict) -> int:
|
||||||
|
"""Calculate approximate serialized size of profile data."""
|
||||||
|
total = 0
|
||||||
|
for key, value in data.get("env_vars", {}).items():
|
||||||
|
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||||
|
for key, value in data.get("runtime_hints", {}).items():
|
||||||
|
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||||
|
for mount in data.get("mounts", []):
|
||||||
|
total += len(str(mount.get("target", "")).encode("utf-8"))
|
||||||
|
total += len(str(mount.get("mode", "")).encode("utf-8"))
|
||||||
|
for path, content in mount.get("files", {}).items():
|
||||||
|
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||||
|
for path, content in data.get("files", {}).items():
|
||||||
|
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
class GitMountItem(BaseModel):
|
||||||
|
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||||
|
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
||||||
|
target_path: str = Field(description="Absolute path inside container")
|
||||||
|
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||||
|
|
||||||
|
@field_validator("remote_url")
|
||||||
|
@classmethod
|
||||||
|
def validate_remote_url(cls, v: str) -> str:
|
||||||
|
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
|
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("source_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_source_path(cls, v: str) -> str:
|
||||||
|
if v.startswith("/"):
|
||||||
|
raise ValueError("source_path must be relative (no leading /)")
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("source_path cannot contain path traversal (..)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("target_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_target_path(cls, v: str) -> str:
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("target_path cannot contain path traversal (..)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class MountItem(BaseModel):
|
||||||
|
target: str = Field(description="Absolute mount target path")
|
||||||
|
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||||
|
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
||||||
|
|
||||||
|
@field_validator("target")
|
||||||
|
@classmethod
|
||||||
|
def validate_target(cls, v: str) -> str:
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount target must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("mode")
|
||||||
|
@classmethod
|
||||||
|
def validate_mode(cls, v: str) -> str:
|
||||||
|
if v not in ("ro", "rw"):
|
||||||
|
raise ValueError("Mount mode must be 'ro' or 'rw'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict) -> dict:
|
||||||
|
for path in v.keys():
|
||||||
|
if ".." in path or not path:
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
if path.startswith("/"):
|
||||||
|
raise ValueError(
|
||||||
|
f"Mount file paths must be relative (got: {path}). "
|
||||||
|
f"The mount target defines the absolute container path."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileCreate(BaseModel):
|
||||||
|
name: str = Field(description="Profile name (unique per user)")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||||
|
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||||
|
env_vars: dict = Field(default_factory=dict, description="Environment variables")
|
||||||
|
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
||||||
|
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
|
||||||
|
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
||||||
|
git_mounts: list[GitMountItem] = Field(default_factory=list, description="Git repository mounts")
|
||||||
|
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
|
||||||
|
|
||||||
|
@field_validator("project_id", "tool_type_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_uuids(cls, v: str | None) -> str | None:
|
||||||
|
return _validate_uuid(v)
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict) -> dict:
|
||||||
|
for path in v.keys():
|
||||||
|
if ".." in path or not path:
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
if path.startswith("/"):
|
||||||
|
raise ValueError(
|
||||||
|
f"File paths must be relative (got: {path}). "
|
||||||
|
f"Use Mounts for absolute container paths."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("env_vars")
|
||||||
|
@classmethod
|
||||||
|
def validate_env_vars(cls, v: dict) -> dict:
|
||||||
|
result = _validate_env_vars(v)
|
||||||
|
if result is None:
|
||||||
|
raise ValueError("env_vars must be a JSON object")
|
||||||
|
return result
|
||||||
|
|
||||||
|
@field_validator("runtime_hints")
|
||||||
|
@classmethod
|
||||||
|
def validate_runtime_hints(cls, v: dict) -> dict:
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("runtime_hints must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("mounts")
|
||||||
|
@classmethod
|
||||||
|
def validate_mounts(cls, v: list) -> list:
|
||||||
|
if not isinstance(v, list):
|
||||||
|
raise ValueError("mounts must be a JSON array")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, description="Profile name")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||||
|
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||||
|
env_vars: dict | None = Field(default=None, description="Environment variables")
|
||||||
|
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
||||||
|
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
|
||||||
|
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
|
||||||
|
git_mounts: list[GitMountItem] | None = Field(default=None, description="Git repository mounts")
|
||||||
|
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
|
||||||
|
|
||||||
|
@field_validator("project_id", "tool_type_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_uuids(cls, v: str | None) -> str | None:
|
||||||
|
return _validate_uuid(v)
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict | None) -> dict | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
for path in v.keys():
|
||||||
|
if ".." in path or path.startswith("/") or not path:
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileIncludeUpdate(BaseModel):
|
||||||
|
includes: list[str] = Field(description="Ordered list of included profile IDs")
|
||||||
|
|
||||||
|
@field_validator("includes")
|
||||||
|
@classmethod
|
||||||
|
def validate_includes(cls, v: list) -> list:
|
||||||
|
for item in v:
|
||||||
|
try:
|
||||||
|
uuid.UUID(item)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid UUID in includes: {item}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
project_id: str | None
|
||||||
|
tool_type_id: str | None
|
||||||
|
env_vars: dict
|
||||||
|
runtime_hints: dict
|
||||||
|
mounts: list
|
||||||
|
files: dict
|
||||||
|
git_mounts: list
|
||||||
|
is_default: bool
|
||||||
|
includes: list[dict]
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_profile_with_includes(session: AsyncSession, profile_id: uuid.UUID) -> ConfigProfile | None:
|
||||||
|
"""Fetch a profile with includes eagerly loaded."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile_id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
tool_type_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Verify user has access to referenced project and tool type."""
|
||||||
|
if project_id is not None:
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
# Add ownership check if needed; for now just verify existence
|
||||||
|
if tool_type_id is not None:
|
||||||
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_git_mounts(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
git_mounts: list[dict],
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Validate git mount URLs.
|
||||||
|
|
||||||
|
Simply checks that remote_url looks like a valid git URL.
|
||||||
|
Actual clone validation happens at instance startup time.
|
||||||
|
"""
|
||||||
|
for mount in git_mounts:
|
||||||
|
remote_url = mount.get("remote_url")
|
||||||
|
if not remote_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Git mount missing remote_url",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid git URL: {remote_url}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(profile.id),
|
||||||
|
"user_id": str(profile.user_id),
|
||||||
|
"name": profile.name,
|
||||||
|
"description": profile.description,
|
||||||
|
"project_id": str(profile.project_id) if profile.project_id else None,
|
||||||
|
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
|
||||||
|
"env_vars": profile.env_vars or {},
|
||||||
|
"runtime_hints": profile.runtime_hints or {},
|
||||||
|
"mounts": profile.mounts or [],
|
||||||
|
"git_mounts": profile.git_mounts or [],
|
||||||
|
"files": profile.files or {},
|
||||||
|
"is_default": profile.is_default,
|
||||||
|
"includes": [
|
||||||
|
{
|
||||||
|
"id": str(inc.id),
|
||||||
|
"included_profile_id": str(inc.included_profile_id),
|
||||||
|
"order_index": inc.order_index,
|
||||||
|
}
|
||||||
|
for inc in (includes or profile.includes)
|
||||||
|
],
|
||||||
|
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||||
|
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
|
async def list_config_profiles(
|
||||||
|
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||||
|
tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"),
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""List config profiles, optionally filtered by compatibility."""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes))
|
||||||
|
|
||||||
|
if project_id or tool_type_id:
|
||||||
|
# Compatibility filter: include portable profiles and matching scoped profiles
|
||||||
|
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||||
|
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
conditions: list = []
|
||||||
|
# Portable profiles (no project, no tool)
|
||||||
|
conditions.append(
|
||||||
|
(ConfigProfile.project_id.is_(None)) & (ConfigProfile.tool_type_id.is_(None))
|
||||||
|
)
|
||||||
|
if project_uuid:
|
||||||
|
# Profiles matching this project (with or without tool)
|
||||||
|
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||||
|
if tool_uuid:
|
||||||
|
# Profiles matching this tool (with or without project)
|
||||||
|
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
if project_uuid and tool_uuid:
|
||||||
|
# Exact match
|
||||||
|
conditions.append(
|
||||||
|
(ConfigProfile.project_id == project_uuid) & (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.where(or_(*conditions))
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
return [_profile_to_response(p) for p in profiles]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_config_profile(
|
||||||
|
data: ConfigProfileCreate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Create a new config profile."""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ConfigProfile).where(
|
||||||
|
ConfigProfile.user_id == user_uuid,
|
||||||
|
ConfigProfile.name == data.name,
|
||||||
|
).options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Profile with name '{data.name}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate references
|
||||||
|
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
||||||
|
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
||||||
|
await _check_access(session, user_uuid, project_uuid, tool_uuid)
|
||||||
|
|
||||||
|
# Validate git mounts reference existing repositories
|
||||||
|
if data.git_mounts:
|
||||||
|
git_mounts_data = [m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts]
|
||||||
|
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
||||||
|
|
||||||
|
# Check size
|
||||||
|
size = _calculate_profile_size(data.model_dump())
|
||||||
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = ConfigProfile(
|
||||||
|
user_id=user_uuid,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
project_id=project_uuid,
|
||||||
|
tool_type_id=tool_uuid,
|
||||||
|
env_vars=data.env_vars,
|
||||||
|
runtime_hints=data.runtime_hints,
|
||||||
|
mounts=[m.model_dump() for m in data.mounts],
|
||||||
|
git_mounts=[m.model_dump() for m in data.git_mounts],
|
||||||
|
files=data.files,
|
||||||
|
is_default=data.is_default,
|
||||||
|
)
|
||||||
|
session.add(profile)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Re-fetch with includes to avoid lazy loading issues
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile.id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
profile = result.scalar_one()
|
||||||
|
|
||||||
|
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
|
async def get_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Get a config profile by ID."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
|
async def update_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
data: ConfigProfileUpdate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Update a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Handle name uniqueness
|
||||||
|
if "name" in update_data:
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ConfigProfile).where(
|
||||||
|
ConfigProfile.user_id == profile.user_id,
|
||||||
|
ConfigProfile.name == update_data["name"],
|
||||||
|
ConfigProfile.id != profile.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Profile with name '{update_data['name']}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate references
|
||||||
|
project_uuid = (
|
||||||
|
uuid.UUID(update_data["project_id"])
|
||||||
|
if "project_id" in update_data and update_data["project_id"]
|
||||||
|
else (profile.project_id if "project_id" not in update_data else None)
|
||||||
|
)
|
||||||
|
tool_uuid = (
|
||||||
|
uuid.UUID(update_data["tool_type_id"])
|
||||||
|
if "tool_type_id" in update_data and update_data["tool_type_id"]
|
||||||
|
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
||||||
|
)
|
||||||
|
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
|
||||||
|
|
||||||
|
# Validate git mounts reference existing repositories
|
||||||
|
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
||||||
|
git_mounts_data = [
|
||||||
|
m.model_dump() if hasattr(m, "model_dump") else m
|
||||||
|
for m in update_data["git_mounts"]
|
||||||
|
]
|
||||||
|
await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid)
|
||||||
|
|
||||||
|
# Check size
|
||||||
|
current_data = _profile_to_response(profile)
|
||||||
|
merged = {**current_data, **update_data}
|
||||||
|
size = _calculate_profile_size(merged)
|
||||||
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply updates
|
||||||
|
for field_name, value in update_data.items():
|
||||||
|
if field_name in ("project_id", "tool_type_id"):
|
||||||
|
value = uuid.UUID(value) if value else None
|
||||||
|
elif field_name == "mounts" and value is not None:
|
||||||
|
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||||
|
elif field_name == "git_mounts" and value is not None:
|
||||||
|
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||||
|
setattr(profile, field_name, value)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Re-fetch with includes to avoid lazy loading issues
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile.id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
profile = result.scalar_one()
|
||||||
|
|
||||||
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Delete a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
|
||||||
|
await session.delete(profile)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.debug("Deleted config profile %s", profile_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||||
|
async def update_profile_includes(
|
||||||
|
profile_id: str,
|
||||||
|
data: ConfigProfileIncludeUpdate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Update the ordered includes for a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
|
||||||
|
# Validate all included profiles exist and belong to the user
|
||||||
|
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||||
|
for inc_uuid in included_uuids:
|
||||||
|
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
||||||
|
if inc_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Included profile not found: {inc_uuid}",
|
||||||
|
)
|
||||||
|
if inc_profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Not authorized to include profile: {inc_uuid}",
|
||||||
|
)
|
||||||
|
if inc_uuid == profile.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Profile cannot include itself",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for cycles
|
||||||
|
cycle = await check_include_cycle(session, profile.id, None)
|
||||||
|
if cycle is None and included_uuids:
|
||||||
|
# Check each new include would not create a cycle
|
||||||
|
for inc_uuid in included_uuids:
|
||||||
|
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
||||||
|
if cycle is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if cycle is not None:
|
||||||
|
cycle_str = " -> ".join(str(c) for c in cycle)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Include cycle detected: {cycle_str}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove existing includes
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
||||||
|
)
|
||||||
|
for existing in result.scalars().all():
|
||||||
|
await session.delete(existing)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Add new includes
|
||||||
|
for order_index, inc_uuid in enumerate(included_uuids):
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
profile_id=profile.id,
|
||||||
|
included_profile_id=inc_uuid,
|
||||||
|
order_index=order_index,
|
||||||
|
)
|
||||||
|
session.add(include)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Re-fetch profile (includes loaded separately due to SQLite async issue)
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
||||||
|
)
|
||||||
|
profile = result.scalar_one()
|
||||||
|
|
||||||
|
inc_result = await session.execute(
|
||||||
|
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
||||||
|
)
|
||||||
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
|
logger.debug("Updated includes for config profile %s", profile.id)
|
||||||
|
return _profile_to_response(profile, list(direct_includes))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{profile_id}/preview")
|
||||||
|
async def preview_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Preview the resolved output of a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = await resolve_profile(session, profile.id)
|
||||||
|
except ConfigProfileCycleError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
return resolved_profile_to_dict(resolved)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults/resolve")
|
||||||
|
async def resolve_default_profile(
|
||||||
|
project_id: str = Query(..., description="Project ID"),
|
||||||
|
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Resolve the default config profile for a project/tool combination.
|
||||||
|
|
||||||
|
Selects by specificity:
|
||||||
|
1. project+tool explicit default
|
||||||
|
2. project explicit default
|
||||||
|
3. tool explicit default
|
||||||
|
4. global/user explicit default
|
||||||
|
5. first created compatible profile
|
||||||
|
6. none (returns null)
|
||||||
|
"""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
project_uuid = uuid.UUID(project_id)
|
||||||
|
tool_uuid = uuid.UUID(tool_type_id)
|
||||||
|
|
||||||
|
# Fetch all compatible profiles ordered by created_at
|
||||||
|
query = (
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.user_id == user_uuid)
|
||||||
|
.where(
|
||||||
|
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
|
||||||
|
| (ConfigProfile.project_id == project_uuid)
|
||||||
|
| (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
| (
|
||||||
|
(ConfigProfile.project_id == project_uuid)
|
||||||
|
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(ConfigProfile.created_at)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
|
||||||
|
if not profiles:
|
||||||
|
return {"profile_id": None, "profile_name": None}
|
||||||
|
|
||||||
|
# Check explicit defaults by specificity
|
||||||
|
explicit_defaults = [p for p in profiles if p.is_default]
|
||||||
|
|
||||||
|
# Most specific: project+tool
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: project only
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_uuid and p.tool_type_id is None:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: tool only
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id is None and p.tool_type_id == tool_uuid:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: global/user (no project, no tool)
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id is None and p.tool_type_id is None:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Fall back to first created compatible profile
|
||||||
|
first = profiles[0]
|
||||||
|
return {"profile_id": str(first.id), "profile_name": first.name}
|
||||||
@@ -10,12 +10,10 @@ 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.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
from src.utils.git_files import (
|
from src.utils.git_files import (
|
||||||
commit_file,
|
commit_file,
|
||||||
get_file_content,
|
get_file_content,
|
||||||
@@ -42,40 +40,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.
|
||||||
|
|
||||||
@@ -256,7 +220,7 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
path: str
|
path: str
|
||||||
project_id: uuid.UUID
|
project_id: uuid.UUID | None
|
||||||
owner_id: uuid.UUID
|
owner_id: uuid.UUID
|
||||||
is_mirror: bool
|
is_mirror: bool
|
||||||
remote_url: str | None
|
remote_url: str | None
|
||||||
@@ -266,6 +230,156 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/repositories",
|
||||||
|
response_model=list[GitRepositoryResponse],
|
||||||
|
summary="List all user repositories",
|
||||||
|
description="List all git repositories owned by the user, including external repositories not tied to any project.",
|
||||||
|
)
|
||||||
|
async def list_user_repositories(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[GitRepository]:
|
||||||
|
"""List all repositories owned by the user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all repositories owned by the user.
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.owner_id == user_id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/repositories/parse-url",
|
||||||
|
response_model=URLParseResponse,
|
||||||
|
summary="Parse a git URL",
|
||||||
|
description="Parse a git URL and detect if it's a browser URL that needs correction.",
|
||||||
|
)
|
||||||
|
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
||||||
|
"""Parse a git URL and detect if it's a browser URL that needs correction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Request containing the URL to parse.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed URL information including whether it needs parsing and suggested corrections.
|
||||||
|
"""
|
||||||
|
result = parse_git_url(data.url)
|
||||||
|
return URLParseResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/repositories",
|
||||||
|
response_model=GitRepositoryResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create an external repository",
|
||||||
|
description="Create a new external git repository (not tied to any project). Can clone from remote URL.",
|
||||||
|
)
|
||||||
|
async def create_external_repository(
|
||||||
|
data: GitRepositoryCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Create a new external git repository.
|
||||||
|
|
||||||
|
External repositories are not tied to any project and can be used
|
||||||
|
across all projects for config profile git mounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Repository creation data including name and optional remote URL.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created external repository.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
|
||||||
|
# Check for duplicate name (external repos only)
|
||||||
|
existing = await session.execute(
|
||||||
|
select(GitRepository).where(
|
||||||
|
GitRepository.project_id.is_(None),
|
||||||
|
GitRepository.owner_id == user_id,
|
||||||
|
GitRepository.name == data.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||||
|
|
||||||
|
# Validate and potentially correct the URL
|
||||||
|
remote_url = data.remote_url
|
||||||
|
if remote_url and not data.force_original_url:
|
||||||
|
parse_result = parse_git_url(remote_url)
|
||||||
|
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||||
|
"suggested_url": parse_result["base_url"],
|
||||||
|
"original_url": remote_url,
|
||||||
|
"error_code": "URL_NEEDS_PARSING",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if parse_result["base_url"]:
|
||||||
|
remote_url = parse_result["base_url"]
|
||||||
|
|
||||||
|
# Validate SSH key if provided
|
||||||
|
ssh_key_id = None
|
||||||
|
ssh_key = None
|
||||||
|
if data.ssh_key_id:
|
||||||
|
try:
|
||||||
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||||
|
if ssh_key is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
if ssh_key.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
_preflight_remote_repository(remote_url, ssh_key)
|
||||||
|
|
||||||
|
# Create external repo with no project
|
||||||
|
repo = GitRepository(
|
||||||
|
name=data.name,
|
||||||
|
path="", # Will be set after clone
|
||||||
|
project_id=None,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url=remote_url,
|
||||||
|
ssh_key_id=ssh_key_id,
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Set path and optionally clone
|
||||||
|
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
|
||||||
|
repo.path = repo_path
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
try:
|
||||||
|
_clone_working_repository(remote_url, repo_path, ssh_key)
|
||||||
|
repo.is_mirror = False
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
|
||||||
|
else:
|
||||||
|
# Initialize empty repo
|
||||||
|
os.makedirs(repo_path, exist_ok=True)
|
||||||
|
subprocess.run(["git", "init", repo_path], check=True, capture_output=True)
|
||||||
|
repo.is_mirror = False
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/repositories",
|
"/{project_id}/repositories",
|
||||||
response_model=list[GitRepositoryResponse],
|
response_model=list[GitRepositoryResponse],
|
||||||
@@ -335,25 +449,6 @@ async def delete_repository(
|
|||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/repositories/parse-url",
|
|
||||||
response_model=URLParseResponse,
|
|
||||||
summary="Parse a git URL",
|
|
||||||
description="Parse a git URL and detect if it's a browser URL that needs correction.",
|
|
||||||
)
|
|
||||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
|
||||||
"""Parse a git URL and detect if it's a browser URL that needs correction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Request containing the URL to parse.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed URL information including whether it needs parsing and suggested corrections.
|
|
||||||
"""
|
|
||||||
result = parse_git_url(data.url)
|
|
||||||
return URLParseResponse(**result)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{project_id}/repositories",
|
"/{project_id}/repositories",
|
||||||
response_model=GitRepositoryResponse,
|
response_model=GitRepositoryResponse,
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.config import Settings
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
|||||||
@@ -7,23 +7,14 @@ 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
|
||||||
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 +123,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,98 @@
|
|||||||
|
"""Shared Pydantic validators for API schemas."""
|
||||||
|
|
||||||
|
|
||||||
|
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,22 +10,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.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
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
+212
-12
@@ -4,17 +4,25 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
from src.services.terminal_manager import terminal_manager
|
from src.services.terminal_manager import terminal_manager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRef:
|
||||||
|
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||||
|
|
||||||
|
def __init__(self, session):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket(
|
||||||
"/ws/tool-instances/{instance_id}/terminal",
|
"/ws/tool-instances/{instance_id}/terminal",
|
||||||
)
|
)
|
||||||
@@ -26,6 +34,7 @@ async def terminal_websocket(
|
|||||||
"""WebSocket endpoint for terminal access to a tool instance.
|
"""WebSocket endpoint for terminal access to a tool instance.
|
||||||
|
|
||||||
Provides an interactive terminal session inside a running tool instance container.
|
Provides an interactive terminal session inside a running tool instance container.
|
||||||
|
Sessions persist across WebSocket disconnections.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
websocket: The WebSocket connection.
|
websocket: The WebSocket connection.
|
||||||
@@ -35,8 +44,9 @@ async def terminal_websocket(
|
|||||||
Returns:
|
Returns:
|
||||||
None. Communicates via WebSocket messages.
|
None. Communicates via WebSocket messages.
|
||||||
"""
|
"""
|
||||||
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
|
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse instance_id
|
# Parse instance_id
|
||||||
@@ -70,32 +80,222 @@ async def terminal_websocket(
|
|||||||
await websocket.close(code=4004, reason="Instance not running")
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id)
|
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||||
# Create terminal session
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
if startup_command:
|
||||||
|
logger.debug("Using startup command for instance %s: %s", instance_id, startup_command)
|
||||||
|
|
||||||
|
# Get or create terminal session
|
||||||
try:
|
try:
|
||||||
session = await terminal_manager.create_session(
|
session = await terminal_manager.get_or_create_session(
|
||||||
instance_uuid,
|
instance_uuid,
|
||||||
instance.container_id,
|
instance.container_id,
|
||||||
websocket,
|
startup_command=startup_command,
|
||||||
)
|
)
|
||||||
logger.info("Terminal session created successfully for instance %s", instance_id)
|
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
||||||
|
|
||||||
|
# Attach WebSocket to session
|
||||||
|
await terminal_manager.attach_websocket(session, websocket)
|
||||||
|
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||||
|
|
||||||
# Send connected status
|
# Send connected status
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
logger.debug("Sent connected status for instance %s", instance_id)
|
||||||
|
|
||||||
# Keep connection alive until session ends
|
# Use mutable session reference so loops can survive reset
|
||||||
# The terminal_manager handles I/O loops, we just wait here
|
session_ref = SessionRef(session)
|
||||||
while session.is_alive() and not session._closed:
|
|
||||||
await asyncio.sleep(0.5)
|
# Start I/O loops and heartbeat
|
||||||
|
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||||
|
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
||||||
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||||
|
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||||
|
|
||||||
|
# Wait for either task to complete (indicating disconnect or error)
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[read_task, write_task, heartbeat_task],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
|
||||||
|
|
||||||
|
# Cancel remaining tasks
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
|
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||||
await websocket.close(code=4000, reason=f"Error: {exc}")
|
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||||
finally:
|
finally:
|
||||||
# Cleanup will be handled by the session manager
|
# Detach WebSocket, don't kill session
|
||||||
|
try:
|
||||||
|
if 'session' in locals():
|
||||||
|
await terminal_manager.detach_websocket(session, websocket)
|
||||||
|
logger.debug("WebSocket detached from session for instance %s", instance_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_loop(session_ref: SessionRef, websocket) -> None:
|
||||||
|
"""Read output from the container and send to WebSocket."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
session = session_ref.session
|
||||||
|
if not session.is_alive() or session._closed:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
data = await session.read_output()
|
||||||
|
if data:
|
||||||
|
try:
|
||||||
|
await websocket.send_bytes(data)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||||
|
"""Read input from WebSocket and send to container."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
session = session_ref.session
|
||||||
|
if not session.is_alive() or session._closed:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message["type"] == "websocket.receive":
|
||||||
|
if "bytes" in message:
|
||||||
|
await session.write_input(message["bytes"])
|
||||||
|
elif "text" in message:
|
||||||
|
text = message["text"]
|
||||||
|
if text.startswith("{"):
|
||||||
|
# Control message (JSON)
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
ctrl = json.loads(text)
|
||||||
|
msg_type = ctrl.get("type")
|
||||||
|
|
||||||
|
if msg_type == "resize":
|
||||||
|
cols = ctrl.get("cols", 80)
|
||||||
|
rows = ctrl.get("rows", 24)
|
||||||
|
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
||||||
|
await session.resize(cols, rows)
|
||||||
|
elif msg_type == "reset":
|
||||||
|
# Reset terminal session
|
||||||
|
logger.debug("Resetting terminal session for instance %s", session.instance_id)
|
||||||
|
await websocket.send_json({"type": "status", "status": "resetting"})
|
||||||
|
|
||||||
|
# Reset the session
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
session.instance_id,
|
||||||
|
session.container_id,
|
||||||
|
startup_command=session.startup_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the mutable session reference so read_loop uses the new session
|
||||||
|
session_ref.session = new_session
|
||||||
|
|
||||||
|
# Attach to new session
|
||||||
|
await terminal_manager.attach_websocket(new_session, websocket)
|
||||||
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
|
||||||
|
# Continue the loop with the new session
|
||||||
|
continue
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not a valid JSON control message, treat as regular input
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
else:
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
elif message["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||||
|
"""Send periodic ping messages to detect disconnections."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30) # Ping every 30 seconds
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "ping"})
|
||||||
|
except Exception:
|
||||||
|
# WebSocket is closed or broken
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset",
|
||||||
|
summary="Reset terminal session",
|
||||||
|
description="Reset the terminal session for a tool instance, killing the current shell and starting fresh.",
|
||||||
|
)
|
||||||
|
async def reset_terminal_session(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Reset the terminal session for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status message.
|
||||||
|
"""
|
||||||
|
# Get instance and verify it exists and is running
|
||||||
|
instance = await db_session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Instance is not running"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Reset the session
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Terminal session reset successfully",
|
||||||
|
"instance_id": str(instance_id),
|
||||||
|
"session_id": new_session.session_id,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to reset terminal session for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to reset terminal session: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_user_from_websocket(
|
async def _get_user_from_websocket(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tool configuration API endpoints."""
|
"""Tool configuration API endpoints."""
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
@@ -8,12 +7,11 @@ 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
|
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):
|
||||||
|
|||||||
+759
-171
File diff suppressed because it is too large
Load Diff
+32
-128
@@ -1,33 +1,23 @@
|
|||||||
import re
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import yaml
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
from 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."""
|
validate_compose_yaml,
|
||||||
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
|
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.
|
||||||
|
|
||||||
@@ -49,6 +39,7 @@ class ToolTypeCreate(BaseModel):
|
|||||||
dockerfile_template: str | None = None
|
dockerfile_template: str | None = None
|
||||||
build_context: dict | None = None
|
build_context: dict | None = None
|
||||||
readiness_probe: dict | None = None
|
readiness_probe: dict | None = None
|
||||||
|
startup_command: str | None = None
|
||||||
required_variables: list[str] = []
|
required_variables: list[str] = []
|
||||||
category: str = "other"
|
category: str = "other"
|
||||||
interface_type: str = "web"
|
interface_type: str = "web"
|
||||||
@@ -71,24 +62,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")
|
||||||
@@ -155,29 +129,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
|
||||||
@@ -192,6 +148,7 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
dockerfile_template: str | None = None
|
dockerfile_template: str | None = None
|
||||||
build_context: dict | None = None
|
build_context: dict | None = None
|
||||||
readiness_probe: dict | None = None
|
readiness_probe: dict | None = None
|
||||||
|
startup_command: str | None = None
|
||||||
required_variables: list[str] | None = None
|
required_variables: list[str] | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
interface_type: str | None = None
|
interface_type: str | None = None
|
||||||
@@ -226,23 +183,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")
|
||||||
@@ -278,6 +219,7 @@ class ToolTypeResponse(BaseModel):
|
|||||||
dockerfile_template: str | None
|
dockerfile_template: str | None
|
||||||
build_context: dict | None
|
build_context: dict | None
|
||||||
readiness_probe: dict | None
|
readiness_probe: dict | None
|
||||||
|
startup_command: str | None
|
||||||
required_variables: list[str]
|
required_variables: list[str]
|
||||||
created_by_id: uuid.UUID | None
|
created_by_id: uuid.UUID | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -324,6 +266,7 @@ async def create_tool_type(
|
|||||||
dockerfile_template=data.dockerfile_template,
|
dockerfile_template=data.dockerfile_template,
|
||||||
build_context=data.build_context,
|
build_context=data.build_context,
|
||||||
readiness_probe=data.readiness_probe,
|
readiness_probe=data.readiness_probe,
|
||||||
|
startup_command=data.startup_command,
|
||||||
required_variables=data.required_variables,
|
required_variables=data.required_variables,
|
||||||
category=data.category,
|
category=data.category,
|
||||||
interface_type=data.interface_type,
|
interface_type=data.interface_type,
|
||||||
@@ -438,54 +381,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)
|
||||||
@@ -530,16 +448,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:
|
||||||
@@ -588,16 +499,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",
|
||||||
|
)
|
||||||
@@ -1,28 +1,19 @@
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
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_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -111,11 +102,11 @@ async def update_user_config(
|
|||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
logger.info("Updating user config for user %s: %s", user_id, update_data)
|
logger.debug("Updating user config for user %s: %s", user_id, update_data)
|
||||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
config.config = {**config.config, **update_data}
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
logger.info("Updated config: %s", config.config)
|
logger.debug("Updated config: %s", config.config)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.auth.session import decode_session_cookie
|
from src.auth.session import decode_session_cookie
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
from src.models.project import Project
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -47,3 +48,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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import hmac
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
@@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str:
|
|||||||
"""Create a signed session cookie value."""
|
"""Create a signed session cookie value."""
|
||||||
payload = {
|
payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
|
"exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
|
||||||
}
|
}
|
||||||
|
|
||||||
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
|
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
|
||||||
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
|
|||||||
payload = json.loads(payload_bytes)
|
payload = json.loads(payload_bytes)
|
||||||
|
|
||||||
# Check expiry
|
# Check expiry
|
||||||
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
|
if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()):
|
||||||
raise ValueError("session expired")
|
raise ValueError("session expired")
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
from src.api.dashboard import router as dashboard_router
|
||||||
@@ -18,6 +16,7 @@ from src.api.ssh_keys import router as ssh_keys_router
|
|||||||
from src.api.terminal import router as terminal_router
|
from src.api.terminal import router as terminal_router
|
||||||
from src.api.instance_proxy import router as instance_proxy_router
|
from src.api.instance_proxy import router as instance_proxy_router
|
||||||
from src.api.config_folders import router as config_folders_router
|
from src.api.config_folders import router as config_folders_router
|
||||||
|
from src.api.config_profiles import router as config_profiles_router
|
||||||
from src.api.tool_configs import router as tool_configs_router
|
from src.api.tool_configs import router as tool_configs_router
|
||||||
from src.api.tool_instances import router as tool_instances_router
|
from src.api.tool_instances import router as tool_instances_router
|
||||||
from src.api.tool_instances import sessions_router
|
from src.api.tool_instances import sessions_router
|
||||||
@@ -125,6 +124,7 @@ app.include_router(git_repositories_router)
|
|||||||
app.include_router(user_config_router)
|
app.include_router(user_config_router)
|
||||||
app.include_router(tool_types_router)
|
app.include_router(tool_types_router)
|
||||||
app.include_router(config_folders_router)
|
app.include_router(config_folders_router)
|
||||||
|
app.include_router(config_profiles_router)
|
||||||
app.include_router(tool_instances_router)
|
app.include_router(tool_instances_router)
|
||||||
app.include_router(tool_configs_router)
|
app.include_router(tool_configs_router)
|
||||||
app.include_router(sessions_router)
|
app.include_router(sessions_router)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.models.config_folder import ConfigFolder
|
from src.models.config_folder import ConfigFolder
|
||||||
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
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
|
||||||
@@ -8,4 +9,4 @@ from src.models.tool_type import ToolType
|
|||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
__all__ = ["Base", "ConfigFolder", "ConfigProfile", "ConfigProfileInclude", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_profiles"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
env_vars: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"VAR_NAME": "value", ...}
|
||||||
|
runtime_hints: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"start_command": "...", "working_dir": "...", ...}
|
||||||
|
mounts: Mapped[list] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...]
|
||||||
|
files: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"rel/path": "content", ...}
|
||||||
|
git_mounts: Mapped[list] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship()
|
||||||
|
project: Mapped["Project | None"] = relationship()
|
||||||
|
tool_type: Mapped["ToolType | None"] = relationship()
|
||||||
|
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
||||||
|
"ConfigProfileInclude",
|
||||||
|
foreign_keys="ConfigProfileInclude.profile_id",
|
||||||
|
order_by="ConfigProfileInclude.order_index",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_profile_includes"
|
||||||
|
|
||||||
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="includes",
|
||||||
|
)
|
||||||
|
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[included_profile_id],
|
||||||
|
)
|
||||||
@@ -19,7 +19,7 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(255))
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
path: Mapped[str] = mapped_column(String(1024))
|
path: Mapped[str] = mapped_column(String(1024))
|
||||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False)
|
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True)
|
||||||
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
||||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
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.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
@@ -71,8 +72,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
branch: Mapped[str | None] = mapped_column(
|
branch: Mapped[str | None] = mapped_column(
|
||||||
String(255), nullable=True, default="main"
|
String(255), nullable=True, default="main"
|
||||||
)
|
)
|
||||||
|
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
project: Mapped["Project"] = relationship()
|
project: Mapped["Project"] = relationship()
|
||||||
owner: Mapped["User"] = relationship()
|
owner: Mapped["User"] = relationship()
|
||||||
|
selected_config_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
JSON, default=dict, nullable=True
|
JSON, default=dict, nullable=True
|
||||||
)
|
)
|
||||||
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
startup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(),
|
UUID(),
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def clone_repository(
|
|||||||
str(clone_path),
|
str(clone_path),
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
logger.debug("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -55,7 +55,7 @@ def clone_repository(
|
|||||||
logger.error("Git clone failed: %s", result.stderr)
|
logger.error("Git clone failed: %s", result.stderr)
|
||||||
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
||||||
|
|
||||||
logger.info("Successfully cloned repository into %s", clone_path)
|
logger.debug("Successfully cloned repository into %s", clone_path)
|
||||||
return str(clone_path)
|
return str(clone_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -94,4 +94,4 @@ def remove_clone_directory(instance_dir: str) -> None:
|
|||||||
if clone_path.exists():
|
if clone_path.exists():
|
||||||
import shutil
|
import shutil
|
||||||
shutil.rmtree(clone_path)
|
shutil.rmtree(clone_path)
|
||||||
logger.info("Removed clone directory: %s", clone_path)
|
logger.debug("Removed clone directory: %s", clone_path)
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
"""Config profile resolver service.
|
||||||
|
|
||||||
|
Provides recursive ordered include resolution with deterministic merge rules
|
||||||
|
and cycle protection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileCycleError(Exception):
|
||||||
|
"""Raised when a cycle is detected in profile includes."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileNotFoundError(Exception):
|
||||||
|
"""Raised when a referenced profile is not found."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedMount:
|
||||||
|
"""A resolved mount with merged files and final mode."""
|
||||||
|
|
||||||
|
target: str
|
||||||
|
mode: str
|
||||||
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
|
overridden_files: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedProfile:
|
||||||
|
"""The fully resolved output of a config profile."""
|
||||||
|
|
||||||
|
profile_id: uuid.UUID
|
||||||
|
profile_name: str
|
||||||
|
env_vars: dict[str, str] = field(default_factory=dict)
|
||||||
|
runtime_hints: dict[str, Any] = field(default_factory=dict)
|
||||||
|
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||||
|
git_mounts: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
|
env_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
hint_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
file_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
mount_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
included_profiles: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) -> bool:
|
||||||
|
"""Detect if adding profile_id to path would create a cycle.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: The profile ID to check.
|
||||||
|
visited: Set of already-visited profile IDs in current resolution.
|
||||||
|
path: Current resolution path for error reporting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a cycle would be created.
|
||||||
|
"""
|
||||||
|
if profile_id in visited:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_env_vars(
|
||||||
|
base: dict[str, str],
|
||||||
|
overlay: dict[str, str],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Merge env vars, tracking overrides.
|
||||||
|
|
||||||
|
Later values replace earlier values.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for key, value in overlay.items():
|
||||||
|
if key in result and result[key] != value:
|
||||||
|
overrides[key] = source_name
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_runtime_hints(
|
||||||
|
base: dict[str, Any],
|
||||||
|
overlay: dict[str, Any],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Merge runtime hints, tracking overrides.
|
||||||
|
|
||||||
|
Later values replace earlier values.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for key, value in overlay.items():
|
||||||
|
if key in result and result[key] != value:
|
||||||
|
overrides[key] = source_name
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_files(
|
||||||
|
base: dict[str, str],
|
||||||
|
overlay: dict[str, str],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Merge file maps, tracking overrides.
|
||||||
|
|
||||||
|
Later relative file paths win.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for path, content in overlay.items():
|
||||||
|
if path in result and result[path] != content:
|
||||||
|
overrides[path] = source_name
|
||||||
|
result[path] = content
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_mounts(
|
||||||
|
base: dict[str, ResolvedMount],
|
||||||
|
overlay: list[dict[str, Any]],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, ResolvedMount]:
|
||||||
|
"""Merge mounts, tracking overrides.
|
||||||
|
|
||||||
|
Mounts with the same target path have their file maps merged and later
|
||||||
|
relative file paths win. Mode conflicts: later layer wins.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for mount_data in overlay:
|
||||||
|
target = mount_data["target"]
|
||||||
|
mode = mount_data.get("mode", "rw")
|
||||||
|
files = mount_data.get("files", {})
|
||||||
|
|
||||||
|
if target in result:
|
||||||
|
existing = result[target]
|
||||||
|
merged_files = dict(existing.files)
|
||||||
|
file_overrides = dict(existing.overridden_files)
|
||||||
|
for rel_path, content in files.items():
|
||||||
|
if rel_path in merged_files and merged_files[rel_path] != content:
|
||||||
|
file_overrides[rel_path] = source_name
|
||||||
|
merged_files[rel_path] = content
|
||||||
|
if existing.mode != mode:
|
||||||
|
overrides[target] = source_name
|
||||||
|
result[target] = ResolvedMount(
|
||||||
|
target=target,
|
||||||
|
mode=mode,
|
||||||
|
files=merged_files,
|
||||||
|
overridden_files=file_overrides,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result[target] = ResolvedMount(
|
||||||
|
target=target,
|
||||||
|
mode=mode,
|
||||||
|
files=dict(files),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_git_mounts(
|
||||||
|
base: list[dict[str, Any]],
|
||||||
|
overlay: list[dict[str, Any]],
|
||||||
|
source_name: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Merge git mounts from included profiles.
|
||||||
|
|
||||||
|
Later mounts override earlier ones with the same remote_url + target_path combo.
|
||||||
|
"""
|
||||||
|
result = list(base)
|
||||||
|
# Build lookup by (remote_url, target_path)
|
||||||
|
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
|
||||||
|
for mount in overlay:
|
||||||
|
key = (mount["remote_url"], mount["target_path"])
|
||||||
|
if key in seen:
|
||||||
|
result[seen[key]] = dict(mount)
|
||||||
|
else:
|
||||||
|
seen[key] = len(result)
|
||||||
|
result.append(dict(mount))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_profile_recursive(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
visited: set[uuid.UUID],
|
||||||
|
path: list[uuid.UUID],
|
||||||
|
) -> ResolvedProfile:
|
||||||
|
"""Recursively resolve a profile and its includes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: Profile ID to resolve.
|
||||||
|
visited: Set of already-visited profile IDs in current resolution chain.
|
||||||
|
path: Current resolution path for error reporting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedProfile with all includes merged.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigProfileCycleError: If a cycle is detected.
|
||||||
|
ConfigProfileNotFoundError: If the profile is not found.
|
||||||
|
"""
|
||||||
|
if _detect_cycle(profile_id, visited, path):
|
||||||
|
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
|
||||||
|
raise ConfigProfileCycleError(f"Cycle detected in profile includes: {cycle_path}")
|
||||||
|
|
||||||
|
profile = await session.get(ConfigProfile, profile_id)
|
||||||
|
if profile is None:
|
||||||
|
raise ConfigProfileNotFoundError(f"Config profile not found: {profile_id}")
|
||||||
|
|
||||||
|
new_visited = visited | {profile_id}
|
||||||
|
new_path = path + [profile_id]
|
||||||
|
|
||||||
|
result = ResolvedProfile(
|
||||||
|
profile_id=profile.id,
|
||||||
|
profile_name=profile.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve includes in order
|
||||||
|
include_query = (
|
||||||
|
select(ConfigProfileInclude)
|
||||||
|
.where(ConfigProfileInclude.profile_id == profile_id)
|
||||||
|
.order_by(ConfigProfileInclude.order_index)
|
||||||
|
)
|
||||||
|
include_result = await session.execute(include_query)
|
||||||
|
includes = include_result.scalars().all()
|
||||||
|
|
||||||
|
for include in includes:
|
||||||
|
included = await _resolve_profile_recursive(
|
||||||
|
session, include.included_profile_id, new_visited, new_path
|
||||||
|
)
|
||||||
|
result.included_profiles.append({
|
||||||
|
"id": str(included.profile_id),
|
||||||
|
"name": included.profile_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
result.env_vars = _merge_env_vars(
|
||||||
|
result.env_vars, included.env_vars, result.env_overrides, included.profile_name
|
||||||
|
)
|
||||||
|
result.runtime_hints = _merge_runtime_hints(
|
||||||
|
result.runtime_hints,
|
||||||
|
included.runtime_hints,
|
||||||
|
result.hint_overrides,
|
||||||
|
included.profile_name,
|
||||||
|
)
|
||||||
|
result.files = _merge_files(
|
||||||
|
result.files, included.files, result.file_overrides, included.profile_name
|
||||||
|
)
|
||||||
|
result.mounts = _merge_mounts(
|
||||||
|
result.mounts,
|
||||||
|
[
|
||||||
|
{"target": m.target, "mode": m.mode, "files": m.files}
|
||||||
|
for m in included.mounts.values()
|
||||||
|
],
|
||||||
|
result.mount_overrides,
|
||||||
|
included.profile_name,
|
||||||
|
)
|
||||||
|
result.git_mounts = _merge_git_mounts(
|
||||||
|
result.git_mounts, included.git_mounts, included.profile_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply the profile's own settings (selected profile overrides includes)
|
||||||
|
result.env_vars = _merge_env_vars(
|
||||||
|
result.env_vars,
|
||||||
|
profile.env_vars or {},
|
||||||
|
result.env_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.runtime_hints = _merge_runtime_hints(
|
||||||
|
result.runtime_hints,
|
||||||
|
profile.runtime_hints or {},
|
||||||
|
result.hint_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.files = _merge_files(
|
||||||
|
result.files,
|
||||||
|
profile.files or {},
|
||||||
|
result.file_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.mounts = _merge_mounts(
|
||||||
|
result.mounts,
|
||||||
|
profile.mounts or [],
|
||||||
|
result.mount_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.git_mounts = _merge_git_mounts(
|
||||||
|
result.git_mounts,
|
||||||
|
profile.git_mounts or [],
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_profile(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
) -> ResolvedProfile:
|
||||||
|
"""Resolve a config profile with all includes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: Profile ID to resolve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedProfile with merged env vars, runtime hints, mounts, and files.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigProfileCycleError: If a cycle is detected in includes.
|
||||||
|
ConfigProfileNotFoundError: If the profile is not found.
|
||||||
|
"""
|
||||||
|
return await _resolve_profile_recursive(session, profile_id, set(), [])
|
||||||
|
|
||||||
|
|
||||||
|
async def check_include_cycle(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
new_include_id: uuid.UUID | None = None,
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
"""Check if adding an include would create a cycle.
|
||||||
|
|
||||||
|
Used at save time to validate include relationships before persisting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: The profile that would receive the new include.
|
||||||
|
new_include_id: Optional new profile to include. If None, checks existing includes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The cycle path as a list of UUIDs if a cycle exists, otherwise None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _check_from(
|
||||||
|
current_id: uuid.UUID,
|
||||||
|
target_id: uuid.UUID,
|
||||||
|
visited: set[uuid.UUID],
|
||||||
|
path: list[uuid.UUID],
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
if current_id in visited:
|
||||||
|
if current_id == target_id:
|
||||||
|
return path + [current_id]
|
||||||
|
return None
|
||||||
|
if current_id == target_id and path:
|
||||||
|
return path + [current_id]
|
||||||
|
|
||||||
|
new_visited = visited | {current_id}
|
||||||
|
new_path = path + [current_id]
|
||||||
|
|
||||||
|
include_query = (
|
||||||
|
select(ConfigProfileInclude)
|
||||||
|
.where(ConfigProfileInclude.profile_id == current_id)
|
||||||
|
.order_by(ConfigProfileInclude.order_index)
|
||||||
|
)
|
||||||
|
include_result = await session.execute(include_query)
|
||||||
|
includes = include_result.scalars().all()
|
||||||
|
|
||||||
|
for include in includes:
|
||||||
|
cycle = await _check_from(
|
||||||
|
include.included_profile_id, target_id, new_visited, new_path
|
||||||
|
)
|
||||||
|
if cycle is not None:
|
||||||
|
return cycle
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if new_include_id can reach profile_id (would create cycle)
|
||||||
|
if new_include_id is not None:
|
||||||
|
cycle = await _check_from(new_include_id, profile_id, set(), [])
|
||||||
|
if cycle is not None:
|
||||||
|
return cycle
|
||||||
|
|
||||||
|
# Also check existing includes for cycles
|
||||||
|
cycle = await _check_from(profile_id, profile_id, set(), [])
|
||||||
|
if cycle is not None and len(cycle) > 1:
|
||||||
|
return cycle
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_resolved_profile(
|
||||||
|
instance_dir: str,
|
||||||
|
resolved: ResolvedProfile,
|
||||||
|
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
||||||
|
"""Apply a resolved profile to an instance directory.
|
||||||
|
|
||||||
|
Stages files, writes env vars, and prepares mount volumes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to the instance directory.
|
||||||
|
resolved: The resolved profile.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (env_vars, files, volume_mounts, runtime_hints).
|
||||||
|
env_vars: Merged environment variables.
|
||||||
|
files: Relative file paths to content for the instance.
|
||||||
|
volume_mounts: List of Docker volume mount dicts.
|
||||||
|
runtime_hints: Extracted runtime hints.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
env_vars = dict(resolved.env_vars)
|
||||||
|
files = dict(resolved.files)
|
||||||
|
volume_mounts = []
|
||||||
|
|
||||||
|
# Write profile files to instance directory
|
||||||
|
for file_path, content in files.items():
|
||||||
|
full_path = instance_path / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Profile file path escapes instance directory: %s", file_path)
|
||||||
|
continue
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
# Stage mount files and prepare volume mounts
|
||||||
|
for mount in resolved.mounts.values():
|
||||||
|
mount_dir = instance_path / "mounts" / mount.target.lstrip("/").replace("/", "_")
|
||||||
|
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for file_path, content in mount.files.items():
|
||||||
|
full_path = mount_dir / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(mount_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Mount file path escapes mount directory: %s", file_path)
|
||||||
|
continue
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
volume_mounts.append({
|
||||||
|
"source": str(mount_dir),
|
||||||
|
"target": mount.target,
|
||||||
|
"type": "bind",
|
||||||
|
})
|
||||||
|
|
||||||
|
return env_vars, files, volume_mounts, resolved.runtime_hints
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
||||||
|
"""Convert a ResolvedProfile to a plain dict for serialization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resolved: The resolved profile.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with env_vars, runtime_hints, mounts, files, and metadata.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"profile_id": str(resolved.profile_id),
|
||||||
|
"profile_name": resolved.profile_name,
|
||||||
|
"env_vars": resolved.env_vars,
|
||||||
|
"runtime_hints": resolved.runtime_hints,
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"target": m.target,
|
||||||
|
"mode": m.mode,
|
||||||
|
"files": m.files,
|
||||||
|
"overridden_files": m.overridden_files,
|
||||||
|
}
|
||||||
|
for m in resolved.mounts.values()
|
||||||
|
],
|
||||||
|
"files": resolved.files,
|
||||||
|
"overrides": {
|
||||||
|
"env_vars": resolved.env_overrides,
|
||||||
|
"runtime_hints": resolved.hint_overrides,
|
||||||
|
"files": resolved.file_overrides,
|
||||||
|
"mounts": resolved.mount_overrides,
|
||||||
|
},
|
||||||
|
"git_mounts": resolved.git_mounts,
|
||||||
|
"included_profiles": resolved.included_profiles,
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Docker service for managing tool instances."""
|
"""Docker service for managing tool instances."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ def ensure_instance_directory(instance_id: str, base_path: str | None = None) ->
|
|||||||
"""
|
"""
|
||||||
if base_path is None:
|
if base_path is None:
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
|
||||||
base_path = Settings().instance_base_path
|
base_path = Settings().instance_base_path
|
||||||
instance_dir = Path(base_path) / instance_id
|
instance_dir = Path(base_path) / instance_id
|
||||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -92,59 +95,6 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
|||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
|
||||||
"""Write config folder files to the instance directory and return volume mounts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_dir: Path to instance directory
|
|
||||||
folders: List of ConfigFolder objects
|
|
||||||
project_id: Optional project ID for applying overrides
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
|
||||||
"""
|
|
||||||
instance_path = Path(instance_dir)
|
|
||||||
volume_mounts = []
|
|
||||||
|
|
||||||
for folder in folders:
|
|
||||||
# Determine mount path (with project override if applicable)
|
|
||||||
mount_path = folder.mount_path
|
|
||||||
files = folder.files.copy()
|
|
||||||
|
|
||||||
if project_id and folder.project_overrides:
|
|
||||||
override = folder.project_overrides.get(str(project_id))
|
|
||||||
if override:
|
|
||||||
if override.get("mount_path"):
|
|
||||||
mount_path = override["mount_path"]
|
|
||||||
if override.get("files"):
|
|
||||||
files.update(override["files"])
|
|
||||||
|
|
||||||
# Write files to instance directory
|
|
||||||
folder_dir = instance_path / "volumes" / folder.name
|
|
||||||
folder_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for file_path, content in files.items():
|
|
||||||
# Security: ensure path doesn't escape folder_dir
|
|
||||||
full_path = folder_dir / file_path
|
|
||||||
try:
|
|
||||||
full_path.resolve().relative_to(folder_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Config folder file path escapes directory: %s", file_path)
|
|
||||||
continue
|
|
||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
# Add volume mount
|
|
||||||
volume_mounts.append({
|
|
||||||
"source": str(folder_dir),
|
|
||||||
"target": mount_path,
|
|
||||||
"type": "bind",
|
|
||||||
})
|
|
||||||
|
|
||||||
return volume_mounts
|
|
||||||
|
|
||||||
|
|
||||||
def execute_compose_command(
|
def execute_compose_command(
|
||||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||||
) -> tuple[int, str, str]:
|
) -> tuple[int, str, str]:
|
||||||
@@ -189,6 +139,8 @@ def execute_compose_command(
|
|||||||
def get_container_id(instance_name: str) -> str | None:
|
def get_container_id(instance_name: str) -> str | None:
|
||||||
"""Get the container ID for a compose service.
|
"""Get the container ID for a compose service.
|
||||||
|
|
||||||
|
Searches all containers including stopped/exited ones.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
instance_name: The service name in compose
|
||||||
|
|
||||||
@@ -196,7 +148,7 @@ def get_container_id(instance_name: str) -> str | None:
|
|||||||
Container ID or None if not found
|
Container ID or None if not found
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
@@ -209,6 +161,8 @@ def get_container_id(instance_name: str) -> str | None:
|
|||||||
def get_container_name(instance_name: str) -> str | None:
|
def get_container_name(instance_name: str) -> str | None:
|
||||||
"""Get the full container name for a compose service.
|
"""Get the full container name for a compose service.
|
||||||
|
|
||||||
|
Searches all containers including stopped/exited ones.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
instance_name: The service name in compose
|
||||||
|
|
||||||
@@ -216,7 +170,15 @@ def get_container_name(instance_name: str) -> str | None:
|
|||||||
Container name or None if not found
|
Container name or None if not found
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
[
|
||||||
|
"docker",
|
||||||
|
"ps",
|
||||||
|
"-a",
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}",
|
||||||
|
"--filter",
|
||||||
|
f"name={instance_name}",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
@@ -226,7 +188,9 @@ def get_container_name(instance_name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
def connect_container_to_network(
|
||||||
|
container_name: str, network_name: str = "backend"
|
||||||
|
) -> bool:
|
||||||
"""Connect a Docker container to an existing network.
|
"""Connect a Docker container to an existing network.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -256,7 +220,9 @@ def get_container_status(container_id: str) -> dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"docker", "inspect", "-f",
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||||
container_id,
|
container_id,
|
||||||
],
|
],
|
||||||
@@ -291,7 +257,6 @@ def wait_for_container_running(
|
|||||||
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||||
and 'waited_seconds' (float)
|
and 'waited_seconds' (float)
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
@@ -375,11 +340,6 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
|||||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||||
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def start_cloudflared_tunnel(
|
def start_cloudflared_tunnel(
|
||||||
container_name: str, port: int, timeout: int = 30
|
container_name: str, port: int, timeout: int = 30
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -397,8 +357,6 @@ def start_cloudflared_tunnel(
|
|||||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
|
||||||
import re
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -407,18 +365,29 @@ def start_cloudflared_tunnel(
|
|||||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||||
for attempt in range(10):
|
for attempt in range(10):
|
||||||
check = subprocess.run(
|
check = subprocess.run(
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
[
|
||||||
f"http://{container_name}:{port}"],
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
f"http://{container_name}:{port}",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
logger.info(
|
||||||
|
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
|
||||||
|
)
|
||||||
if check.returncode == 0:
|
if check.returncode == 0:
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
else:
|
else:
|
||||||
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
logger.warning(
|
||||||
|
"Container %s:%d not responding to curl checks", container_name, port
|
||||||
|
)
|
||||||
|
|
||||||
# Run cloudflared in background, capture output
|
# Run cloudflared in background, capture output
|
||||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||||
@@ -437,6 +406,7 @@ def start_cloudflared_tunnel(
|
|||||||
while time.time() - start_time < timeout:
|
while time.time() - start_time < timeout:
|
||||||
# Read available output
|
# Read available output
|
||||||
import select
|
import select
|
||||||
|
|
||||||
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||||
if readable:
|
if readable:
|
||||||
line = proc.stdout.readline()
|
line = proc.stdout.readline()
|
||||||
@@ -463,7 +433,6 @@ def stop_cloudflared_tunnel(pid: str) -> None:
|
|||||||
Args:
|
Args:
|
||||||
pid: Process ID of the cloudflared tunnel
|
pid: Process ID of the cloudflared tunnel
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -508,8 +477,17 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
[
|
||||||
"--max-time", str(timeout), url],
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
str(timeout),
|
||||||
|
url,
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout + 5,
|
timeout=timeout + 5,
|
||||||
@@ -548,7 +526,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|||||||
except (ValueError, Exception) as e:
|
except (ValueError, Exception) as e:
|
||||||
error_str = str(e).lower()
|
error_str = str(e).lower()
|
||||||
# Classify connection errors
|
# Classify connection errors
|
||||||
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
|
if any(
|
||||||
|
err in error_str
|
||||||
|
for err in [
|
||||||
|
"connection refused",
|
||||||
|
"econnrefused",
|
||||||
|
"could not resolve",
|
||||||
|
"nodename",
|
||||||
|
]
|
||||||
|
):
|
||||||
return {
|
return {
|
||||||
"tunnel_status": "unreachable",
|
"tunnel_status": "unreachable",
|
||||||
"status_code": None,
|
"status_code": None,
|
||||||
|
|||||||
@@ -18,13 +18,12 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (returncode, stdout, stderr)
|
Tuple of (returncode, stdout, stderr)
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Write Dockerfile
|
# Write Dockerfile
|
||||||
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||||
dockerfile_path.write_text(dockerfile)
|
dockerfile_path.write_text(dockerfile)
|
||||||
logger.info("Wrote Dockerfile to %s", dockerfile_path)
|
logger.debug("Wrote Dockerfile to %s", dockerfile_path)
|
||||||
|
|
||||||
# Write build context files
|
# Write build context files
|
||||||
if build_context:
|
if build_context:
|
||||||
@@ -39,10 +38,10 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
logger.info("Wrote build context file: %s", full_path)
|
logger.debug("Wrote build context file: %s", full_path)
|
||||||
|
|
||||||
# Build image
|
# Build image
|
||||||
logger.info("Building Docker image with tag: %s", tag)
|
logger.debug("Building Docker image with tag: %s", tag)
|
||||||
cmd = [
|
cmd = [
|
||||||
"docker", "build",
|
"docker", "build",
|
||||||
"-t", tag,
|
"-t", tag,
|
||||||
@@ -57,7 +56,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=300, # 5 minute timeout for builds
|
timeout=300, # 5 minute timeout for builds
|
||||||
)
|
)
|
||||||
logger.info("Docker build completed: returncode=%d", result.returncode)
|
logger.debug("Docker build completed: returncode=%d", result.returncode)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("Docker build failed: %s", result.stderr[:1000])
|
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||||
return result.returncode, result.stdout, result.stderr
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|||||||
@@ -1,88 +1,150 @@
|
|||||||
"""Terminal session manager for WebSocket connections."""
|
"""Terminal session manager for WebSocket connections."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
from src.services.terminal_session import TerminalSession
|
from src.services.terminal_session import TerminalSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TerminalManager:
|
class TerminalManager:
|
||||||
"""Manages active terminal sessions."""
|
"""Manages active terminal sessions with persistence support."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
# Track sessions by instance_id for persistence
|
||||||
self._sessions: dict[str, TerminalSession] = {}
|
self._sessions: dict[str, TerminalSession] = {}
|
||||||
|
self._idle_check_task: asyncio.Task | None = None
|
||||||
|
self._start_idle_check()
|
||||||
|
|
||||||
async def create_session(
|
def _start_idle_check(self) -> None:
|
||||||
|
"""Start the idle timeout background task."""
|
||||||
|
if self._idle_check_task is not None and not self._idle_check_task.done():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._idle_check_task = loop.create_task(self._idle_check_loop())
|
||||||
|
except RuntimeError:
|
||||||
|
# No event loop running yet, will be started lazily
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _idle_check_loop(self) -> None:
|
||||||
|
"""Periodically check for idle sessions and clean them up."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(60) # Check every minute
|
||||||
|
await self._cleanup_idle_sessions()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error in idle check loop: %s", exc)
|
||||||
|
|
||||||
|
async def _cleanup_idle_sessions(self) -> None:
|
||||||
|
"""Clean up sessions that have been idle for too long."""
|
||||||
|
idle_sessions = []
|
||||||
|
for instance_id, session in list(self._sessions.items()):
|
||||||
|
if session.is_idle():
|
||||||
|
idle_sessions.append(instance_id)
|
||||||
|
|
||||||
|
for instance_id in idle_sessions:
|
||||||
|
logger.info("Cleaning up idle terminal session for instance %s", instance_id)
|
||||||
|
session = self._sessions.pop(instance_id, None)
|
||||||
|
if session:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
async def get_or_create_session(
|
||||||
self,
|
self,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
container_id: str,
|
container_id: str,
|
||||||
websocket: WebSocket,
|
startup_command: str | None = None,
|
||||||
) -> TerminalSession:
|
) -> TerminalSession:
|
||||||
"""Create a new terminal session."""
|
"""Get existing session or create a new one."""
|
||||||
session_id = str(uuid.uuid4())
|
# Ensure idle check is running (lazy start)
|
||||||
session = TerminalSession(session_id, instance_id, container_id)
|
self._start_idle_check()
|
||||||
await session.start()
|
|
||||||
self._sessions[session_id] = session
|
|
||||||
|
|
||||||
# Start background tasks for I/O streaming
|
instance_id_str = str(instance_id)
|
||||||
asyncio.create_task(self._read_loop(session, websocket))
|
|
||||||
asyncio.create_task(self._write_loop(session, websocket))
|
# Check for existing session
|
||||||
|
if instance_id_str in self._sessions:
|
||||||
|
session = self._sessions[instance_id_str]
|
||||||
|
|
||||||
|
# Check if session is still alive
|
||||||
|
if session.is_alive():
|
||||||
|
logger.debug("Reattaching to existing terminal session for instance %s", instance_id)
|
||||||
|
return session
|
||||||
|
else:
|
||||||
|
# Session died, clean it up
|
||||||
|
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id)
|
||||||
|
await session.close()
|
||||||
|
del self._sessions[instance_id_str]
|
||||||
|
|
||||||
|
# Create new session
|
||||||
|
logger.info("Creating new terminal session for instance %s", instance_id)
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
|
||||||
|
await session.start(startup_command=startup_command)
|
||||||
|
self._sessions[instance_id_str] = session
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
async def attach_websocket(
|
||||||
"""Read output from the container and send to WebSocket."""
|
self,
|
||||||
try:
|
session: TerminalSession,
|
||||||
while session.is_alive() and not session._closed:
|
websocket: WebSocket,
|
||||||
data = await session.read_output()
|
) -> None:
|
||||||
if data:
|
"""Attach a WebSocket to an existing session."""
|
||||||
await websocket.send_bytes(data)
|
# Handle concurrent connections - close existing ones
|
||||||
else:
|
if session.has_websockets():
|
||||||
await asyncio.sleep(0.01)
|
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
|
||||||
except Exception:
|
for ws in list(session._websockets):
|
||||||
pass
|
try:
|
||||||
finally:
|
await ws.close(code=4000, reason="New connection established")
|
||||||
await self._cleanup_session(session)
|
except Exception:
|
||||||
|
pass
|
||||||
|
session._websockets.clear()
|
||||||
|
|
||||||
async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
# Attach new WebSocket
|
||||||
"""Read input from WebSocket and send to container."""
|
session.attach_websocket(websocket)
|
||||||
try:
|
|
||||||
while session.is_alive() and not session._closed:
|
|
||||||
message = await websocket.receive()
|
|
||||||
if message["type"] == "websocket.receive":
|
|
||||||
if "bytes" in message:
|
|
||||||
await session.write_input(message["bytes"])
|
|
||||||
elif "text" in message:
|
|
||||||
text = message["text"]
|
|
||||||
if text.startswith("{"):
|
|
||||||
# Control message (JSON)
|
|
||||||
import json
|
|
||||||
try:
|
|
||||||
ctrl = json.loads(text)
|
|
||||||
if ctrl.get("type") == "resize":
|
|
||||||
await session.resize(
|
|
||||||
ctrl.get("cols", 80),
|
|
||||||
ctrl.get("rows", 24),
|
|
||||||
)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
await session.write_input(text.encode("utf-8"))
|
|
||||||
elif message["type"] == "websocket.disconnect":
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
await self._cleanup_session(session)
|
|
||||||
|
|
||||||
async def _cleanup_session(self, session: TerminalSession) -> None:
|
# Replay buffer
|
||||||
"""Clean up a session."""
|
buffer = session.get_buffer()
|
||||||
if session.session_id in self._sessions:
|
if buffer:
|
||||||
del self._sessions[session.session_id]
|
try:
|
||||||
await session.close()
|
await websocket.send_bytes(buffer)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def detach_websocket(
|
||||||
|
self,
|
||||||
|
session: TerminalSession,
|
||||||
|
websocket: WebSocket,
|
||||||
|
) -> None:
|
||||||
|
"""Detach a WebSocket from a session."""
|
||||||
|
session.detach_websocket(websocket)
|
||||||
|
|
||||||
|
async def reset_session(
|
||||||
|
self,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
container_id: str,
|
||||||
|
startup_command: str | None = None,
|
||||||
|
) -> TerminalSession:
|
||||||
|
"""Reset a session by killing it and creating a new one."""
|
||||||
|
instance_id_str = str(instance_id)
|
||||||
|
|
||||||
|
# Close existing session if any
|
||||||
|
if instance_id_str in self._sessions:
|
||||||
|
logger.debug("Resetting terminal session for instance %s", instance_id)
|
||||||
|
old_session = self._sessions.pop(instance_id_str)
|
||||||
|
await old_session.close()
|
||||||
|
|
||||||
|
# Create new session
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
|
||||||
|
await session.start(startup_command=startup_command)
|
||||||
|
self._sessions[instance_id_str] = session
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
async def close_all(self) -> None:
|
async def close_all(self) -> None:
|
||||||
"""Close all active sessions."""
|
"""Close all active sessions."""
|
||||||
@@ -91,6 +153,9 @@ class TerminalManager:
|
|||||||
for session in sessions:
|
for session in sessions:
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
if self._idle_check_task and not self._idle_check_task.done():
|
||||||
|
self._idle_check_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
# Global terminal manager instance
|
# Global terminal manager instance
|
||||||
terminal_manager = TerminalManager()
|
terminal_manager = TerminalManager()
|
||||||
|
|||||||
@@ -1,34 +1,73 @@
|
|||||||
"""Terminal session management for tool instances."""
|
"""Terminal session management for tool instances."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import pty
|
import pty
|
||||||
import select
|
import select
|
||||||
|
import signal
|
||||||
import struct
|
import struct
|
||||||
import fcntl
|
import fcntl
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TerminalSession:
|
class TerminalSession:
|
||||||
"""Manages a single terminal session connected to a docker container."""
|
"""Manages a single terminal session connected to a docker container.
|
||||||
|
|
||||||
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
|
Supports persistent sessions that survive WebSocket disconnections.
|
||||||
|
Multiple WebSocket connections can attach/detach from the same session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Circular buffer size (10KB)
|
||||||
|
BUFFER_SIZE = 10 * 1024
|
||||||
|
|
||||||
|
# Idle timeout in seconds (30 minutes)
|
||||||
|
IDLE_TIMEOUT = 30 * 60
|
||||||
|
|
||||||
|
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command: str | None = None) -> None:
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.instance_id = instance_id
|
self.instance_id = instance_id
|
||||||
self.container_id = container_id
|
self.container_id = container_id
|
||||||
|
self.startup_command = startup_command
|
||||||
self.process: asyncio.subprocess.Process | None = None
|
self.process: asyncio.subprocess.Process | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._master_fd: int | None = None
|
self._master_fd: int | None = None
|
||||||
self._slave_fd: int | None = None
|
self._slave_fd: int | None = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
# Circular buffer for output replay
|
||||||
|
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
||||||
|
self._buffer_size = 0
|
||||||
|
|
||||||
|
# WebSocket connections
|
||||||
|
self._websockets: set[Any] = set()
|
||||||
|
|
||||||
|
# Activity tracking
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
# Terminal size
|
||||||
|
self._cols = 80
|
||||||
|
self._rows = 24
|
||||||
|
|
||||||
|
async def start(self, startup_command: str | None = None) -> None:
|
||||||
"""Start the docker exec process with a shell using a PTY."""
|
"""Start the docker exec process with a shell using a PTY."""
|
||||||
# Create a pseudo-terminal on the host
|
# Create a pseudo-terminal on the host
|
||||||
self._master_fd, self._slave_fd = pty.openpty()
|
self._master_fd, self._slave_fd = pty.openpty()
|
||||||
|
|
||||||
# Set the terminal size initially
|
# Set the terminal size initially
|
||||||
self._set_terminal_size(80, 24)
|
self._set_terminal_size(self._cols, self._rows)
|
||||||
|
logger.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
|
||||||
|
|
||||||
|
# Build the shell command
|
||||||
|
if startup_command:
|
||||||
|
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
|
||||||
|
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
|
||||||
|
else:
|
||||||
|
shell_cmd = "bash -il"
|
||||||
|
|
||||||
# Start docker exec with the slave fd as stdin/stdout/stderr
|
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||||
# Using -it because the slave fd IS a TTY
|
# Using -it because the slave fd IS a TTY
|
||||||
@@ -40,7 +79,8 @@ class TerminalSession:
|
|||||||
"TERM=xterm",
|
"TERM=xterm",
|
||||||
self.container_id,
|
self.container_id,
|
||||||
"bash",
|
"bash",
|
||||||
"-il",
|
"-c",
|
||||||
|
shell_cmd,
|
||||||
stdin=self._slave_fd,
|
stdin=self._slave_fd,
|
||||||
stdout=self._slave_fd,
|
stdout=self._slave_fd,
|
||||||
stderr=self._slave_fd,
|
stderr=self._slave_fd,
|
||||||
@@ -50,46 +90,103 @@ class TerminalSession:
|
|||||||
os.close(self._slave_fd)
|
os.close(self._slave_fd)
|
||||||
self._slave_fd = None
|
self._slave_fd = None
|
||||||
|
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||||
"""Set the terminal size using TIOCSWINSZ."""
|
"""Set the terminal size using TIOCSWINSZ."""
|
||||||
if self._master_fd is None:
|
if self._master_fd is None:
|
||||||
|
logger.warning("Cannot resize: master_fd is None (session not started)")
|
||||||
return
|
return
|
||||||
# TIOCSWINSZ = 0x5414 on Linux
|
# TIOCSWINSZ = 0x5414 on Linux
|
||||||
TIOCSWINSZ = 0x5414
|
TIOCSWINSZ = 0x5414
|
||||||
size = struct.pack('HHHH', rows, cols, 0, 0)
|
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||||
try:
|
try:
|
||||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||||
except (OSError, IOError):
|
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
||||||
pass
|
except (OSError, IOError) as e:
|
||||||
|
logger.error(f"Failed to resize PTY: {e}")
|
||||||
|
|
||||||
async def read_output(self) -> bytes:
|
async def read_output(self) -> bytes:
|
||||||
"""Read output from the PTY master."""
|
"""Read output from the PTY master and store in buffer."""
|
||||||
if self._master_fd is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
return b""
|
return b""
|
||||||
try:
|
try:
|
||||||
# Use select to check if data is available
|
# Use select to check if data is available
|
||||||
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
||||||
if readable:
|
if readable:
|
||||||
return os.read(self._master_fd, 4096)
|
data = os.read(self._master_fd, 4096)
|
||||||
|
if data:
|
||||||
|
self._add_to_buffer(data)
|
||||||
|
self.last_activity = time.time()
|
||||||
|
return data
|
||||||
return b""
|
return b""
|
||||||
except (OSError, IOError, ValueError):
|
except (OSError, IOError, ValueError):
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
def _add_to_buffer(self, data: bytes) -> None:
|
||||||
|
"""Add data to circular buffer, maintaining size limit."""
|
||||||
|
self._output_buffer.append(data)
|
||||||
|
self._buffer_size += len(data)
|
||||||
|
|
||||||
|
# Trim if exceeds max size
|
||||||
|
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
|
||||||
|
removed = self._output_buffer.popleft()
|
||||||
|
self._buffer_size -= len(removed)
|
||||||
|
|
||||||
|
def get_buffer(self) -> bytes:
|
||||||
|
"""Get buffered output for replay."""
|
||||||
|
return b"".join(self._output_buffer)
|
||||||
|
|
||||||
async def write_input(self, data: bytes) -> None:
|
async def write_input(self, data: bytes) -> None:
|
||||||
"""Write input to the PTY master."""
|
"""Write input to the PTY master."""
|
||||||
if self._master_fd is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
os.write(self._master_fd, data)
|
os.write(self._master_fd, data)
|
||||||
|
self.last_activity = time.time()
|
||||||
except (OSError, IOError):
|
except (OSError, IOError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def resize(self, cols: int, rows: int) -> None:
|
async def resize(self, cols: int, rows: int) -> None:
|
||||||
"""Resize the terminal."""
|
"""Resize the terminal."""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
|
logger.warning("Cannot resize: session is closed")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Only resize if dimensions actually changed
|
||||||
|
if cols == self._cols and rows == self._rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._cols = cols
|
||||||
|
self._rows = rows
|
||||||
|
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
||||||
self._set_terminal_size(cols, rows)
|
self._set_terminal_size(cols, rows)
|
||||||
|
|
||||||
|
# Docker exec -it creates its own PTY inside the container,
|
||||||
|
# so host PTY resize doesn't propagate to the container shell.
|
||||||
|
# Send SIGWINCH to the docker exec process on the host.
|
||||||
|
# Docker exec forwards signals to the container process, which should
|
||||||
|
# cause the container's shell to re-read its terminal size.
|
||||||
|
if self.process and self.process.pid:
|
||||||
|
try:
|
||||||
|
os.kill(self.process.pid, signal.SIGWINCH)
|
||||||
|
logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}")
|
||||||
|
except ProcessLookupError:
|
||||||
|
logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send SIGWINCH: {e}")
|
||||||
|
|
||||||
|
async def reset(self) -> None:
|
||||||
|
"""Reset the session by killing the process and clearing state."""
|
||||||
|
await self.close()
|
||||||
|
self._closed = False
|
||||||
|
self._output_buffer.clear()
|
||||||
|
self._buffer_size = 0
|
||||||
|
self._websockets.clear()
|
||||||
|
self.process = None
|
||||||
|
self._master_fd = None
|
||||||
|
self._slave_fd = None
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Close the session and cleanup."""
|
"""Close the session and cleanup."""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
@@ -115,3 +212,35 @@ class TerminalSession:
|
|||||||
if self.process is None:
|
if self.process is None:
|
||||||
return False
|
return False
|
||||||
return self.process.returncode is None
|
return self.process.returncode is None
|
||||||
|
|
||||||
|
def is_idle(self) -> bool:
|
||||||
|
"""Check if the session has been idle for too long."""
|
||||||
|
if self._websockets:
|
||||||
|
return False
|
||||||
|
return time.time() - self.last_activity > self.IDLE_TIMEOUT
|
||||||
|
|
||||||
|
def attach_websocket(self, websocket: Any) -> None:
|
||||||
|
"""Attach a WebSocket to this session."""
|
||||||
|
self._websockets.add(websocket)
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
def detach_websocket(self, websocket: Any) -> None:
|
||||||
|
"""Detach a WebSocket from this session."""
|
||||||
|
self._websockets.discard(websocket)
|
||||||
|
|
||||||
|
def has_websockets(self) -> bool:
|
||||||
|
"""Check if any WebSockets are attached."""
|
||||||
|
return len(self._websockets) > 0
|
||||||
|
|
||||||
|
async def send_to_all(self, data: bytes) -> None:
|
||||||
|
"""Send data to all attached WebSockets."""
|
||||||
|
dead_sockets = set()
|
||||||
|
for ws in self._websockets:
|
||||||
|
try:
|
||||||
|
await ws.send_bytes(data)
|
||||||
|
except Exception:
|
||||||
|
dead_sockets.add(ws)
|
||||||
|
|
||||||
|
# Clean up dead sockets
|
||||||
|
for ws in dead_sockets:
|
||||||
|
self._websockets.discard(ws)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def _run_git_command(repo_path: str, *args: str) -> str:
|
def _run_git_command(repo_path: str, *args: str) -> str:
|
||||||
|
|||||||
@@ -8,16 +8,14 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
# Set test environment BEFORE importing app modules
|
# Set test environment BEFORE importing app modules
|
||||||
os.environ["APP_ENV"] = "testing"
|
os.environ["APP_ENV"] = "testing"
|
||||||
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings
|
||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.main import app
|
from src.main import app
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
@@ -47,10 +45,8 @@ def test_client() -> Generator[TestClient, None, None]:
|
|||||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||||
|
|
||||||
# Patch startup events to prevent PostgreSQL connection attempts
|
# Patch startup events to prevent PostgreSQL connection attempts
|
||||||
with patch("src.main.init_database") as mock_init, \
|
with patch("src.main.init_database") as mock_init:
|
||||||
patch("src.main.seed_builtin_tool_types") as mock_seed:
|
|
||||||
mock_init.return_value = True
|
mock_init.return_value = True
|
||||||
mock_seed.return_value = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
@@ -61,6 +57,31 @@ def test_client() -> Generator[TestClient, None, None]:
|
|||||||
asyncio.run(engine.dispose())
|
asyncio.run(engine.dispose())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session(test_client) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Provide an async database session for unit tests."""
|
||||||
|
# Get the override function from the test_client fixture
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
else:
|
||||||
|
# Fallback: create a new engine and session
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||||
|
yield session
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
"""Provide an authenticated test client with a test user."""
|
"""Provide an authenticated test client with a test user."""
|
||||||
@@ -110,6 +131,65 @@ def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
|||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_project_and_repo(authenticated_client) -> tuple[str, str]:
|
||||||
|
"""Create a project and repository directly in the database."""
|
||||||
|
import uuid
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
|
||||||
|
project_id = uuid.uuid4()
|
||||||
|
repo_id = uuid.uuid4()
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
# Get user ID from session
|
||||||
|
async def get_user_id():
|
||||||
|
nonlocal user_id
|
||||||
|
from src.auth.session import decode_session_cookie
|
||||||
|
settings = Settings()
|
||||||
|
session_cookie = authenticated_client.cookies.get("session")
|
||||||
|
if session_cookie:
|
||||||
|
session = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
|
if session:
|
||||||
|
user_id = uuid.UUID(session["user_id"])
|
||||||
|
|
||||||
|
asyncio.run(get_user_id())
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise RuntimeError("Could not get user ID from authenticated client")
|
||||||
|
|
||||||
|
async def create_project_and_repo():
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
project = Project(
|
||||||
|
id=project_id,
|
||||||
|
name="test-project",
|
||||||
|
description="Test project",
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(project)
|
||||||
|
|
||||||
|
repo = GitRepository(
|
||||||
|
id=repo_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/tmp/test-repo",
|
||||||
|
project_id=project_id,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url="https://github.com/test/repo.git",
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.commit()
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
|
||||||
|
asyncio.run(create_project_and_repo())
|
||||||
|
|
||||||
|
return str(project_id), str(repo_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
"""Provide an authenticated test client with an admin user."""
|
"""Provide an authenticated test client with an admin user."""
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestConfigProfilesAPI:
|
||||||
|
"""Integration tests for config profiles API."""
|
||||||
|
|
||||||
|
def test_list_config_profiles_requires_authentication(self, test_client: TestClient) -> None:
|
||||||
|
"""Test that listing config profiles requires authentication."""
|
||||||
|
response = test_client.get("/config-profiles")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_list_config_profiles_returns_user_profiles(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that authenticated users can list their profiles."""
|
||||||
|
response = authenticated_client.get("/config-profiles")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
|
||||||
|
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a config profile."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "test-profile",
|
||||||
|
"description": "Test profile",
|
||||||
|
"env_vars": {"VAR": "value"},
|
||||||
|
"runtime_hints": {"start_command": "npm start"},
|
||||||
|
"mounts": [{"target": "/app", "mode": "rw", "files": {}}],
|
||||||
|
"files": {"test.txt": "hello"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "test-profile"
|
||||||
|
assert data["env_vars"] == {"VAR": "value"}
|
||||||
|
assert data["files"] == {"test.txt": "hello"}
|
||||||
|
assert data["mounts"][0]["target"] == "/app"
|
||||||
|
|
||||||
|
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that duplicate profile names are rejected."""
|
||||||
|
# Create first profile
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "duplicate-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
# Try to create second with same name
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "duplicate-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
def test_create_config_profile_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that profiles exceeding 10MB are rejected."""
|
||||||
|
large_content = "x" * (11 * 1024 * 1024) # 11MB
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "large-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {"large.txt": large_content},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 413
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_file_path(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid file paths are rejected."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {"../../../etc/passwd": "malicious"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_mount_target(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid mount targets are rejected."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-mount-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"mounts": [{"target": "relative/path", "mode": "rw", "files": {}}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting a config profile by ID."""
|
||||||
|
# Create profile first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "get-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Get it back
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "get-test"
|
||||||
|
|
||||||
|
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting a non-existent profile."""
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{uuid.uuid4()}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a config profile."""
|
||||||
|
# Create profile first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "update-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update it
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{profile_id}",
|
||||||
|
json={
|
||||||
|
"name": "updated-name",
|
||||||
|
"env_vars": {"NEW_VAR": "new_value"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "updated-name"
|
||||||
|
assert data["env_vars"] == {"NEW_VAR": "new_value"}
|
||||||
|
|
||||||
|
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test deleting a config profile."""
|
||||||
|
# Create profile first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "delete-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Delete it
|
||||||
|
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||||
|
assert get_response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_profile_includes_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating profile includes."""
|
||||||
|
# Create base profile
|
||||||
|
base_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "base-profile",
|
||||||
|
"env_vars": {"BASE_VAR": "base_value"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
base_id = base_response.json()["id"]
|
||||||
|
|
||||||
|
# Create child profile
|
||||||
|
child_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "child-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
child_id = child_response.json()["id"]
|
||||||
|
|
||||||
|
# Update includes
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{child_id}/includes",
|
||||||
|
json={"includes": [base_id]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
print(f"Response data: {data}")
|
||||||
|
print(f"Includes: {data.get('includes', 'NO INCLUDES KEY')}")
|
||||||
|
assert len(data["includes"]) == 1, f"Expected 1 include, got {len(data.get('includes', []))}: {data.get('includes', [])}"
|
||||||
|
assert data["includes"][0]["included_profile_id"] == base_id
|
||||||
|
|
||||||
|
def test_update_profile_includes_cycle_detection(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that include cycles are detected."""
|
||||||
|
# Create profile A
|
||||||
|
a_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "profile-a",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
a_id = a_response.json()["id"]
|
||||||
|
|
||||||
|
# Create profile B
|
||||||
|
b_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "profile-b",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
b_id = b_response.json()["id"]
|
||||||
|
|
||||||
|
# Make B include A
|
||||||
|
authenticated_client.put(
|
||||||
|
f"/config-profiles/{b_id}/includes",
|
||||||
|
json={"includes": [a_id]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to make A include B (would create cycle)
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{a_id}/includes",
|
||||||
|
json={"includes": [b_id]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_preview_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test previewing a resolved config profile."""
|
||||||
|
# Create base profile
|
||||||
|
base_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "preview-base",
|
||||||
|
"env_vars": {"BASE_VAR": "base"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
base_id = base_response.json()["id"]
|
||||||
|
|
||||||
|
# Create child profile
|
||||||
|
child_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "preview-child",
|
||||||
|
"env_vars": {"CHILD_VAR": "child"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
child_id = child_response.json()["id"]
|
||||||
|
|
||||||
|
# Make child include base
|
||||||
|
authenticated_client.put(
|
||||||
|
f"/config-profiles/{child_id}/includes",
|
||||||
|
json={"includes": [base_id]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preview child
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{child_id}/preview")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_name"] == "preview-child"
|
||||||
|
assert data["env_vars"]["BASE_VAR"] == "base"
|
||||||
|
assert data["env_vars"]["CHILD_VAR"] == "child"
|
||||||
|
assert len(data["included_profiles"]) == 1
|
||||||
|
|
||||||
|
def test_resolve_default_profile(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test resolving default profile for project/tool."""
|
||||||
|
# Create a global default profile (no project/tool scoping)
|
||||||
|
authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "default-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"is_default": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve default with random project/tool (should fall back to global)
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
tool_type_id = str(uuid.uuid4())
|
||||||
|
response = authenticated_client.get(
|
||||||
|
"/config-profiles/defaults/resolve",
|
||||||
|
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_name"] == "default-profile"
|
||||||
|
|
||||||
|
def test_resolve_default_profile_no_match(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test resolving default profile when no profiles exist."""
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
tool_type_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
response = authenticated_client.get(
|
||||||
|
"/config-profiles/defaults/resolve",
|
||||||
|
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_id"] is None
|
||||||
|
|
||||||
|
def test_create_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test creating a config profile with git mounts."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "git-mount-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "git-mount-profile"
|
||||||
|
assert len(data["git_mounts"]) == 1
|
||||||
|
assert data["git_mounts"][0]["target_path"] == "/app"
|
||||||
|
assert data["git_mounts"][0]["branch"] == "main"
|
||||||
|
|
||||||
|
def test_update_config_profile_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test updating git mounts on a config profile."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
# Create profile first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "update-git-mounts",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update with git mounts
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{profile_id}",
|
||||||
|
json={
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": "config",
|
||||||
|
"target_path": "/config",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["git_mounts"]) == 1
|
||||||
|
assert data["git_mounts"][0]["source_path"] == "config"
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_git_mount_source_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test that invalid git mount source paths are rejected."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-git-mount",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": "/absolute/path",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test that git mount target paths with traversal are rejected."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-git-mount-target",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "../../../etc/passwd",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_preview_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test previewing a profile with git mounts."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
# Create profile with git mounts
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "preview-git-mounts",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Preview
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{profile_id}/preview")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["git_mounts"]) == 1
|
||||||
|
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
|
||||||
@@ -82,28 +82,6 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
|
|||||||
assert UserConfig.user.property.mapper.class_ is User
|
assert UserConfig.user.property.mapper.class_ is User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
|
|
||||||
def test_refresh_token_table_has_required_columns_and_relationships() -> None:
|
|
||||||
columns = RefreshToken.__table__.columns
|
|
||||||
user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys))
|
|
||||||
|
|
||||||
assert set(columns.keys()) == {
|
|
||||||
"id",
|
|
||||||
"user_id",
|
|
||||||
"token_hash",
|
|
||||||
"expires_at",
|
|
||||||
"revoked_at",
|
|
||||||
"user_agent",
|
|
||||||
"ip_address",
|
|
||||||
"created_at",
|
|
||||||
}
|
|
||||||
assert columns["token_hash"].unique is True
|
|
||||||
assert columns["revoked_at"].nullable is True
|
|
||||||
assert user_fk.target_fullname == "users.id"
|
|
||||||
assert RefreshToken.user.property.mapper.class_ is User
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import uuid
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import uuid
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -221,5 +220,79 @@ class TestToolTypesAPIExtended:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
_ = response.json()
|
||||||
|
|
||||||
|
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a tool type with startup_command."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "startup-tool",
|
||||||
|
"display_name": "Startup Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||||
|
"startup_command": "cd /workspace && ls",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
assert data["startup_command"] == "cd /workspace && ls"
|
||||||
|
assert data["interface_type"] == "terminal"
|
||||||
|
|
||||||
|
def test_update_tool_type_startup_command(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a tool type's startup_command."""
|
||||||
|
# Create tool type first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "update-startup-tool",
|
||||||
|
"display_name": "Update Startup Tool",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update with startup_command
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/tool-types/{tool_id}",
|
||||||
|
json={
|
||||||
|
"startup_command": "source /etc/profile",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["startup_command"] == "source /etc/profile"
|
||||||
|
|
||||||
|
def test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that GET returns startup_command."""
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "get-startup-tool",
|
||||||
|
"display_name": "Get Startup Tool",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||||
|
"startup_command": "echo hello",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
response = authenticated_client.get(f"/tool-types/{tool_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["startup_command"] == "echo hello"
|
||||||
assert "Port 9999 is not exposed" in str(data)
|
assert "Port 9999 is not exposed" in str(data)
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
|
from src.services.config_profile_resolver import (
|
||||||
|
ConfigProfileCycleError,
|
||||||
|
ConfigProfileNotFoundError,
|
||||||
|
check_include_cycle,
|
||||||
|
resolve_profile,
|
||||||
|
_merge_env_vars,
|
||||||
|
_merge_files,
|
||||||
|
_merge_mounts,
|
||||||
|
_merge_runtime_hints,
|
||||||
|
_merge_git_mounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeFunctions:
|
||||||
|
"""Unit tests for merge helper functions."""
|
||||||
|
|
||||||
|
def test_merge_env_vars_basic(self) -> None:
|
||||||
|
"""Test basic env var merging."""
|
||||||
|
result = _merge_env_vars(
|
||||||
|
{"A": "1", "B": "2"},
|
||||||
|
{"B": "3", "C": "4"},
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result == {"A": "1", "B": "3", "C": "4"}
|
||||||
|
|
||||||
|
def test_merge_env_vars_tracks_overrides(self) -> None:
|
||||||
|
"""Test that env var overrides are tracked."""
|
||||||
|
overrides = {}
|
||||||
|
_merge_env_vars(
|
||||||
|
{"A": "1"},
|
||||||
|
{"A": "2"},
|
||||||
|
overrides,
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert overrides == {"A": "source"}
|
||||||
|
|
||||||
|
def test_merge_runtime_hints_basic(self) -> None:
|
||||||
|
"""Test basic runtime hint merging."""
|
||||||
|
result = _merge_runtime_hints(
|
||||||
|
{"command": "old"},
|
||||||
|
{"command": "new", "port": 8080},
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result == {"command": "new", "port": 8080}
|
||||||
|
|
||||||
|
def test_merge_files_basic(self) -> None:
|
||||||
|
"""Test basic file merging."""
|
||||||
|
result = _merge_files(
|
||||||
|
{"a.txt": "old"},
|
||||||
|
{"a.txt": "new", "b.txt": "content"},
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result == {"a.txt": "new", "b.txt": "content"}
|
||||||
|
|
||||||
|
def test_merge_mounts_basic(self) -> None:
|
||||||
|
"""Test basic mount merging."""
|
||||||
|
result = _merge_mounts(
|
||||||
|
{},
|
||||||
|
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert "/app" in result
|
||||||
|
assert result["/app"].mode == "rw"
|
||||||
|
assert result["/app"].files == {"a.txt": "content"}
|
||||||
|
|
||||||
|
def test_merge_mounts_file_override(self) -> None:
|
||||||
|
"""Test mount file map merging with overrides."""
|
||||||
|
from src.services.config_profile_resolver import ResolvedMount
|
||||||
|
result = _merge_mounts(
|
||||||
|
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
|
||||||
|
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result["/app"].files == {"a.txt": "new"}
|
||||||
|
|
||||||
|
def test_merge_mounts_mode_conflict(self) -> None:
|
||||||
|
"""Test that mount mode conflicts are resolved (later wins)."""
|
||||||
|
from src.services.config_profile_resolver import ResolvedMount
|
||||||
|
overrides = {}
|
||||||
|
result = _merge_mounts(
|
||||||
|
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||||
|
[{"target": "/app", "mode": "ro", "files": {}}],
|
||||||
|
overrides,
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result["/app"].mode == "ro"
|
||||||
|
assert overrides == {"/app": "source"}
|
||||||
|
|
||||||
|
def test_merge_git_mounts_basic(self) -> None:
|
||||||
|
"""Test basic git mount merging."""
|
||||||
|
result = _merge_git_mounts(
|
||||||
|
[],
|
||||||
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
|
assert result[0]["target_path"] == "/app"
|
||||||
|
|
||||||
|
def test_merge_git_mounts_override_same_repo_target(self) -> None:
|
||||||
|
"""Test that git mounts with same repo+target override."""
|
||||||
|
result = _merge_git_mounts(
|
||||||
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
||||||
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}],
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["source_path"] == "src"
|
||||||
|
assert result[0]["branch"] == "dev"
|
||||||
|
|
||||||
|
def test_merge_git_mounts_different_targets(self) -> None:
|
||||||
|
"""Test that git mounts with different targets are preserved."""
|
||||||
|
result = _merge_git_mounts(
|
||||||
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||||
|
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert len(result) == 2
|
||||||
|
targets = {m["target_path"] for m in result}
|
||||||
|
assert targets == {"/app", "/config"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfile:
|
||||||
|
"""Unit tests for profile resolution."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_simple_profile(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a profile with no includes."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
profile = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="simple",
|
||||||
|
env_vars={"VAR": "value"},
|
||||||
|
runtime_hints={"command": "run"},
|
||||||
|
files={"test.txt": "content"},
|
||||||
|
mounts=[{"target": "/app", "mode": "rw", "files": {}}],
|
||||||
|
)
|
||||||
|
db_session.add(profile)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, profile.id)
|
||||||
|
assert result.profile_name == "simple"
|
||||||
|
assert result.env_vars == {"VAR": "value"}
|
||||||
|
assert result.runtime_hints == {"command": "run"}
|
||||||
|
assert result.files == {"test.txt": "content"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_with_includes(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a profile that includes another."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Create base profile
|
||||||
|
base = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="base",
|
||||||
|
env_vars={"BASE_VAR": "base_value"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(base)
|
||||||
|
|
||||||
|
# Create child profile
|
||||||
|
child = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="child",
|
||||||
|
env_vars={"CHILD_VAR": "child_value"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(child)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Create include relationship
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=child.id,
|
||||||
|
included_profile_id=base.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, child.id)
|
||||||
|
assert result.env_vars == {
|
||||||
|
"BASE_VAR": "base_value",
|
||||||
|
"CHILD_VAR": "child_value",
|
||||||
|
}
|
||||||
|
assert len(result.included_profiles) == 1
|
||||||
|
assert result.included_profiles[0]["name"] == "base"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_child_overrides_parent(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test that child profile values override parent values."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
base = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="base",
|
||||||
|
env_vars={"VAR": "base"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(base)
|
||||||
|
|
||||||
|
child = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="child",
|
||||||
|
env_vars={"VAR": "child"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(child)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=child.id,
|
||||||
|
included_profile_id=base.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, child.id)
|
||||||
|
assert result.env_vars == {"VAR": "child"}
|
||||||
|
assert result.env_overrides == {"VAR": "child"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_cycle_detection(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test that cycles are detected during resolution."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include_ab = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ab)
|
||||||
|
|
||||||
|
# B includes A (creates cycle)
|
||||||
|
include_ba = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_b.id,
|
||||||
|
included_profile_id=profile_a.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ba)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ConfigProfileCycleError):
|
||||||
|
await resolve_profile(db_session, profile_a.id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a profile with git mounts."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="with-git-mounts",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
git_mounts=[
|
||||||
|
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db_session.add(profile)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, profile.id)
|
||||||
|
assert len(result.git_mounts) == 1
|
||||||
|
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
|
assert result.git_mounts[0]["target_path"] == "/app"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a profile that includes another with git mounts."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Create base profile with git mount
|
||||||
|
base = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="base",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
git_mounts=[
|
||||||
|
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db_session.add(base)
|
||||||
|
|
||||||
|
# Create child profile with its own git mount
|
||||||
|
child = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="child",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
git_mounts=[
|
||||||
|
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db_session.add(child)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Create include relationship
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=child.id,
|
||||||
|
included_profile_id=base.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, child.id)
|
||||||
|
assert len(result.git_mounts) == 2
|
||||||
|
targets = {m["target_path"] for m in result.git_mounts}
|
||||||
|
assert targets == {"/app", "/config"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a non-existent profile."""
|
||||||
|
with pytest.raises(ConfigProfileNotFoundError):
|
||||||
|
await resolve_profile(db_session, uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckIncludeCycle:
|
||||||
|
"""Unit tests for include cycle checking."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_no_cycle(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test checking when no cycle exists."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await check_include_cycle(db_session, profile_a.id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_detects_cycle(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test detecting an existing cycle."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include_ab = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ab)
|
||||||
|
|
||||||
|
# B includes A
|
||||||
|
include_ba = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_b.id,
|
||||||
|
included_profile_id=profile_a.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ba)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await check_include_cycle(db_session, profile_a.id)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) > 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_would_create_cycle(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test detecting a cycle that would be created."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Check if adding B includes A would create cycle
|
||||||
|
result = await check_include_cycle(db_session, profile_b.id, profile_a.id)
|
||||||
|
assert result is not None
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Unit tests for git mount resolution in tool instances."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.api.tool_instances import (
|
||||||
|
_checkout_branch,
|
||||||
|
_expand_glob_source,
|
||||||
|
_resolve_single_git_mount,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandGlobSource:
|
||||||
|
"""Unit tests for glob pattern expansion."""
|
||||||
|
|
||||||
|
def test_no_glob_single_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Test non-glob path returns single file."""
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(test_file), str(tmp_path))
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0] == str(test_file)
|
||||||
|
|
||||||
|
def test_no_glob_missing_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Test non-glob missing file returns empty list."""
|
||||||
|
missing_file = tmp_path / "missing.txt"
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(missing_file), str(tmp_path))
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
def test_glob_pattern(self, tmp_path: Path) -> None:
|
||||||
|
"""Test glob pattern matches files."""
|
||||||
|
(tmp_path / "file1.txt").write_text("content1")
|
||||||
|
(tmp_path / "file2.txt").write_text("content2")
|
||||||
|
(tmp_path / "other.py").write_text("code")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
|
||||||
|
assert len(result) == 2
|
||||||
|
assert all(f.endswith(".txt") for f in result)
|
||||||
|
|
||||||
|
def test_glob_recursive(self, tmp_path: Path) -> None:
|
||||||
|
"""Test recursive glob pattern."""
|
||||||
|
subdir = tmp_path / "subdir"
|
||||||
|
subdir.mkdir()
|
||||||
|
(subdir / "nested.txt").write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path / "**" / "*.txt"), str(tmp_path))
|
||||||
|
assert len(result) == 1
|
||||||
|
assert "nested.txt" in result[0]
|
||||||
|
|
||||||
|
def test_glob_limit_enforced(self, tmp_path: Path) -> None:
|
||||||
|
"""Test that glob matches are limited to prevent abuse."""
|
||||||
|
# Create more than 100 files
|
||||||
|
for i in range(105):
|
||||||
|
(tmp_path / f"file{i}.txt").write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
|
||||||
|
assert len(result) == 100 # MAX_GLOB_MATCHES limit
|
||||||
|
|
||||||
|
def test_glob_escapes_repo(self, tmp_path: Path) -> None:
|
||||||
|
"""Test that glob results outside repo are filtered."""
|
||||||
|
other_dir = tmp_path.parent / "other"
|
||||||
|
other_dir.mkdir(exist_ok=True)
|
||||||
|
(other_dir / "outside.txt").write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path.parent / "*" / "*.txt"), str(tmp_path))
|
||||||
|
# Should only include files within tmp_path, not other_dir
|
||||||
|
assert all(r.startswith(str(tmp_path)) for r in result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckoutBranch:
|
||||||
|
"""Unit tests for branch checkout."""
|
||||||
|
|
||||||
|
def test_checkout_existing_branch(self, tmp_path: Path) -> None:
|
||||||
|
"""Test checking out an existing branch."""
|
||||||
|
# Initialize git repo
|
||||||
|
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||||
|
(tmp_path / "file.txt").write_text("content")
|
||||||
|
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||||
|
os.system(f"cd {tmp_path} && git branch feature")
|
||||||
|
|
||||||
|
_checkout_branch(str(tmp_path), "feature")
|
||||||
|
|
||||||
|
# Verify we're on feature branch
|
||||||
|
result = os.popen(f"cd {tmp_path} && git branch --show-current").read().strip()
|
||||||
|
assert result == "feature"
|
||||||
|
|
||||||
|
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
|
||||||
|
"""Test checking out a non-existent branch returns False."""
|
||||||
|
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||||
|
(tmp_path / "file.txt").write_text("content")
|
||||||
|
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||||
|
|
||||||
|
result = _checkout_branch(str(tmp_path), "nonexistent")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveSingleGitMount:
|
||||||
|
"""Unit tests for resolving a single git mount."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_missing_remote_url(self, db_session) -> None:
|
||||||
|
"""Test that missing remote_url returns empty list."""
|
||||||
|
git_mount = {
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_missing_target_path(self, db_session) -> None:
|
||||||
|
"""Test that missing target path returns empty list."""
|
||||||
|
git_mount = {
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||||
|
assert result == []
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for git URL parsing utilities."""
|
"""Tests for git URL parsing utilities."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
|
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""Unit tests for readiness probe service."""
|
"""Unit tests for readiness probe service."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.services.readiness_probe import execute_probe
|
from src.services.readiness_probe import execute_probe
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from src.api.tool_instances import CreateInstanceRequest
|
from src.api.tool_instances import CreateInstanceRequest
|
||||||
|
|
||||||
|
|||||||
Generated
+512
-69
@@ -896,6 +896,24 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||||
@@ -913,6 +931,24 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||||
@@ -930,6 +966,24 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||||
@@ -1390,9 +1444,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1410,9 +1461,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1430,9 +1478,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1450,9 +1495,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1470,9 +1512,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1490,9 +1529,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1671,9 +1707,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1688,9 +1721,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1705,9 +1735,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1722,9 +1749,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1739,9 +1763,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1756,9 +1777,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1773,9 +1791,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1790,9 +1805,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1807,9 +1819,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1824,9 +1833,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1841,9 +1847,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1858,9 +1861,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1875,9 +1875,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4360,9 +4357,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4384,9 +4378,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4408,9 +4399,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4432,9 +4420,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6122,6 +6107,420 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitest/node_modules/@vitest/mocker": {
|
"node_modules/vitest/node_modules/@vitest/mocker": {
|
||||||
"version": "4.1.6",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||||
@@ -6149,6 +6548,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest/node_modules/esbuild": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.0",
|
||||||
|
"@esbuild/android-arm": "0.28.0",
|
||||||
|
"@esbuild/android-arm64": "0.28.0",
|
||||||
|
"@esbuild/android-x64": "0.28.0",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.0",
|
||||||
|
"@esbuild/darwin-x64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.0",
|
||||||
|
"@esbuild/linux-arm": "0.28.0",
|
||||||
|
"@esbuild/linux-arm64": "0.28.0",
|
||||||
|
"@esbuild/linux-ia32": "0.28.0",
|
||||||
|
"@esbuild/linux-loong64": "0.28.0",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.0",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.0",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.0",
|
||||||
|
"@esbuild/linux-s390x": "0.28.0",
|
||||||
|
"@esbuild/linux-x64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.0",
|
||||||
|
"@esbuild/sunos-x64": "0.28.0",
|
||||||
|
"@esbuild/win32-arm64": "0.28.0",
|
||||||
|
"@esbuild/win32-ia32": "0.28.0",
|
||||||
|
"@esbuild/win32-x64": "0.28.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitest/node_modules/picomatch": {
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import axios from "axios";
|
import axios, { type AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
@@ -14,13 +14,38 @@ export const shouldSkipAuthRedirect = (path: string): boolean => {
|
|||||||
return path.startsWith("/login") || path.startsWith("/auth");
|
return path.startsWith("/login") || path.startsWith("/auth");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Retry config for transient network errors
|
||||||
|
const MAX_RETRIES = 2;
|
||||||
|
const RETRY_DELAY_MS = 1000;
|
||||||
|
|
||||||
|
// Track retry count per request
|
||||||
|
const retryCount = new WeakMap<AxiosRequestConfig, number>();
|
||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
const status = error?.response?.status;
|
const status = error?.response?.status;
|
||||||
if (status === 401 && !shouldSkipAuthRedirect(window.location.pathname)) {
|
if (status === 401 && !shouldSkipAuthRedirect(window.location.pathname)) {
|
||||||
window.location.assign(`${BASE_URL}/auth/login`);
|
window.location.assign(`${BASE_URL}/auth/login`);
|
||||||
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry on transient network errors (ERR_NETWORK_CHANGED, etc.)
|
||||||
|
const isNetworkError = !error.response && error.message?.includes("Network");
|
||||||
|
const isRetryable = isNetworkError || status >= 502; // 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout
|
||||||
|
|
||||||
|
if (isRetryable) {
|
||||||
|
const config = error.config;
|
||||||
|
const currentRetry = retryCount.get(config) || 0;
|
||||||
|
|
||||||
|
if (currentRetry < MAX_RETRIES) {
|
||||||
|
retryCount.set(config, currentRetry + 1);
|
||||||
|
// Wait before retrying
|
||||||
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * (currentRetry + 1)));
|
||||||
|
return apiClient(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ConfigProfile {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
project_id: string | null;
|
||||||
|
tool_type_id: string | null;
|
||||||
|
env_vars: Record<string, string>;
|
||||||
|
runtime_hints: Record<string, unknown>;
|
||||||
|
mounts: ConfigProfileMount[];
|
||||||
|
git_mounts: GitMount[];
|
||||||
|
files: Record<string, string>;
|
||||||
|
is_default: boolean;
|
||||||
|
includes: ConfigProfileInclude[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigProfileMount {
|
||||||
|
target: string;
|
||||||
|
mode: "ro" | "rw";
|
||||||
|
files: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitMount {
|
||||||
|
remote_url: string;
|
||||||
|
source_path: string;
|
||||||
|
target_path: string;
|
||||||
|
branch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigProfileInclude {
|
||||||
|
id: string;
|
||||||
|
included_profile_id: string;
|
||||||
|
order_index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedProfile {
|
||||||
|
profile_id: string;
|
||||||
|
profile_name: string;
|
||||||
|
env_vars: Record<string, string>;
|
||||||
|
runtime_hints: Record<string, unknown>;
|
||||||
|
mounts: ResolvedMount[];
|
||||||
|
git_mounts: GitMount[];
|
||||||
|
files: Record<string, string>;
|
||||||
|
overrides: {
|
||||||
|
env_vars: Record<string, string>;
|
||||||
|
runtime_hints: Record<string, string>;
|
||||||
|
files: Record<string, string>;
|
||||||
|
mounts: Record<string, string>;
|
||||||
|
};
|
||||||
|
included_profiles: Array<{ id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedMount {
|
||||||
|
target: string;
|
||||||
|
mode: "ro" | "rw";
|
||||||
|
files: Record<string, string>;
|
||||||
|
overridden_files: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateConfigProfileRequest {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
project_id?: string;
|
||||||
|
tool_type_id?: string;
|
||||||
|
env_vars?: Record<string, string>;
|
||||||
|
runtime_hints?: Record<string, unknown>;
|
||||||
|
mounts?: ConfigProfileMount[];
|
||||||
|
git_mounts?: GitMount[];
|
||||||
|
files?: Record<string, string>;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateConfigProfileRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
project_id?: string;
|
||||||
|
tool_type_id?: string;
|
||||||
|
env_vars?: Record<string, string>;
|
||||||
|
runtime_hints?: Record<string, unknown>;
|
||||||
|
mounts?: ConfigProfileMount[];
|
||||||
|
git_mounts?: GitMount[];
|
||||||
|
files?: Record<string, string>;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateIncludesRequest {
|
||||||
|
includes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listConfigProfiles = async (
|
||||||
|
projectId?: string,
|
||||||
|
toolTypeId?: string
|
||||||
|
): Promise<ConfigProfile[]> => {
|
||||||
|
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
|
||||||
|
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
|
||||||
|
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConfigProfile = async (
|
||||||
|
data: CreateConfigProfileRequest
|
||||||
|
): Promise<ConfigProfile> => {
|
||||||
|
const response = await apiClient.post<ConfigProfile>("/config-profiles", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateConfigProfile = async (
|
||||||
|
id: string,
|
||||||
|
data: UpdateConfigProfileRequest
|
||||||
|
): Promise<ConfigProfile> => {
|
||||||
|
const response = await apiClient.put<ConfigProfile>(`/config-profiles/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteConfigProfile = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/config-profiles/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProfileIncludes = async (
|
||||||
|
id: string,
|
||||||
|
data: UpdateIncludesRequest
|
||||||
|
): Promise<ConfigProfile> => {
|
||||||
|
const response = await apiClient.put<ConfigProfile>(
|
||||||
|
`/config-profiles/${id}/includes`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const previewConfigProfile = async (
|
||||||
|
id: string
|
||||||
|
): Promise<ResolvedProfile> => {
|
||||||
|
const response = await apiClient.get<ResolvedProfile>(
|
||||||
|
`/config-profiles/${id}/preview`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveDefaultProfile = async (
|
||||||
|
projectId: string,
|
||||||
|
toolTypeId: string
|
||||||
|
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
|
||||||
|
const response = await apiClient.get("/config-profiles/defaults/resolve", {
|
||||||
|
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
@@ -31,12 +31,24 @@ export interface URLParseResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
||||||
const response = await apiClient.post("/projects/repositories/parse-url", { url });
|
const response = await apiClient.post("/repositories/parse-url", { url });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
export async function listRepositories(projectId?: string): Promise<GitRepository[]> {
|
||||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
if (projectId) {
|
||||||
|
const response = await apiClient.get<GitRepository[]>(
|
||||||
|
`/projects/${projectId}/repositories`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
// List all user repositories (including external)
|
||||||
|
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAllUserRepositories(): Promise<GitRepository[]> {
|
||||||
|
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +60,13 @@ export async function createRepository(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createExternalRepository(
|
||||||
|
data: GitRepositoryCreate
|
||||||
|
): Promise<GitRepository> {
|
||||||
|
const response = await apiClient.post<GitRepository>("/repositories", data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AxiosError } from "axios";
|
||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
export interface ToolInstance {
|
export interface ToolInstance {
|
||||||
@@ -10,6 +11,7 @@ export interface ToolInstance {
|
|||||||
status: string;
|
status: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
port: number | null;
|
port: number | null;
|
||||||
|
selected_config_profile_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +51,8 @@ export async function createInstance(
|
|||||||
displayName?: string,
|
displayName?: string,
|
||||||
cloneMode?: string,
|
cloneMode?: string,
|
||||||
branch?: string,
|
branch?: string,
|
||||||
newBranch?: string
|
newBranch?: string,
|
||||||
|
configProfileId?: string
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
@@ -59,6 +62,7 @@ export async function createInstance(
|
|||||||
clone_mode: cloneMode || "mount",
|
clone_mode: cloneMode || "mount",
|
||||||
branch: branch || undefined,
|
branch: branch || undefined,
|
||||||
new_branch: newBranch || undefined,
|
new_branch: newBranch || undefined,
|
||||||
|
config_profile_id: configProfileId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -67,12 +71,25 @@ export async function createInstance(
|
|||||||
export async function startInstance(
|
export async function startInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
|
configProfileId?: string,
|
||||||
|
retries = 2
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
try {
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
|
const response = await apiClient.post(
|
||||||
);
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||||
return response.data;
|
{ config_profile_id: configProfileId }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||||
|
const axiosError = error as AxiosError;
|
||||||
|
if (retries > 0 && !axiosError.response) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopInstance(
|
export async function stopInstance(
|
||||||
@@ -89,12 +106,25 @@ export async function stopInstance(
|
|||||||
export async function restartInstance(
|
export async function restartInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
|
configProfileId?: string,
|
||||||
|
retries = 2
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
try {
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`
|
const response = await apiClient.post(
|
||||||
);
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||||
return response.data;
|
{ config_profile_id: configProfileId }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||||
|
const axiosError = error as AxiosError;
|
||||||
|
if (retries > 0 && !axiosError.response) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInstance(
|
export async function deleteInstance(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface ToolType {
|
|||||||
dockerfile_template: string | null;
|
dockerfile_template: string | null;
|
||||||
build_context: Record<string, string> | null;
|
build_context: Record<string, string> | null;
|
||||||
readiness_probe: ReadinessProbe | null;
|
readiness_probe: ReadinessProbe | null;
|
||||||
|
startup_command: string | null;
|
||||||
required_variables: string[];
|
required_variables: string[];
|
||||||
created_by_id: string | null;
|
created_by_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -39,6 +40,7 @@ export interface CreateToolTypeRequest {
|
|||||||
dockerfile_template?: string;
|
dockerfile_template?: string;
|
||||||
build_context?: Record<string, string>;
|
build_context?: Record<string, string>;
|
||||||
readiness_probe?: ReadinessProbe;
|
readiness_probe?: ReadinessProbe;
|
||||||
|
startup_command?: string;
|
||||||
required_variables: string[];
|
required_variables: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ export interface UpdateToolTypeRequest {
|
|||||||
dockerfile_template?: string;
|
dockerfile_template?: string;
|
||||||
build_context?: Record<string, string>;
|
build_context?: Record<string, string>;
|
||||||
readiness_probe?: ReadinessProbe;
|
readiness_probe?: ReadinessProbe;
|
||||||
|
startup_command?: string;
|
||||||
required_variables?: string[];
|
required_variables?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { getUserSessions } from "../api/sessions";
|
import { getUserSessions } from "../api/sessions";
|
||||||
@@ -8,6 +8,7 @@ import { useAuth } from "../state/auth";
|
|||||||
import { useSessions } from "../state/sessions";
|
import { useSessions } from "../state/sessions";
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
|
import { MobileNav } from "./mobile-nav";
|
||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
|
const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
|
||||||
@@ -15,6 +16,7 @@ const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions"
|
|||||||
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||||
|
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
|
||||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -42,8 +44,6 @@ export const AppShell = () => {
|
|||||||
const { sessions, setAllSessions } = useSessions();
|
const { sessions, setAllSessions } = useSessions();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isMobile = useMobileViewport();
|
const isMobile = useMobileViewport();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
|
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
@@ -57,18 +57,13 @@ export const AppShell = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSessions();
|
void loadSessions();
|
||||||
// Poll every 10 seconds
|
// Poll every 30 seconds (reduced from 10s to avoid ERR_NETWORK_CHANGED from Docker network changes)
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
void loadSessions();
|
void loadSessions();
|
||||||
}, 10000);
|
}, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
// Close mobile menu on route change
|
|
||||||
useEffect(() => {
|
|
||||||
setMobileMenuOpen(false);
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
if (isMobileTerminal) {
|
if (isMobileTerminal) {
|
||||||
return (
|
return (
|
||||||
<div className="shell mobile-terminal-shell">
|
<div className="shell mobile-terminal-shell">
|
||||||
@@ -101,57 +96,46 @@ export const AppShell = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="shell-body">
|
<div className="shell-body">
|
||||||
<aside className={`shell-nav ${mobileMenuOpen ? "mobile-open" : ""}`} aria-label="Primary navigation">
|
{!isMobile && (
|
||||||
{isMobile && (
|
<aside className="shell-nav" aria-label="Primary navigation">
|
||||||
<button
|
{NAV_ITEMS.map((item) => {
|
||||||
className="mobile-menu-close"
|
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
return (
|
||||||
type="button"
|
<NavLink
|
||||||
aria-label="Close menu"
|
key={item.to}
|
||||||
>
|
to={item.to}
|
||||||
<Icon name="close" size="sm" />
|
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
||||||
</button>
|
end={item.to === "/"}
|
||||||
)}
|
>
|
||||||
{NAV_ITEMS.map((item) => {
|
<Icon name={item.icon} size="sm" />
|
||||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
{item.label}
|
||||||
return (
|
{item.badge === "sessions" && activeCount > 0 && (
|
||||||
<NavLink
|
<span className="nav-badge">{activeCount}</span>
|
||||||
key={item.to}
|
)}
|
||||||
to={item.to}
|
</NavLink>
|
||||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
);
|
||||||
end={item.to === "/"}
|
})}
|
||||||
>
|
|
||||||
<Icon name={item.icon} size="sm" />
|
|
||||||
{item.label}
|
|
||||||
{item.badge === "sessions" && activeCount > 0 && (
|
|
||||||
<span className="nav-badge">{activeCount}</span>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="nav-divider" />
|
<div className="nav-divider" />
|
||||||
<div className="nav-section-title">Live sessions</div>
|
<div className="nav-section-title">Live sessions</div>
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<SessionItem key={session.id} session={session} />
|
<SessionItem key={session.id} session={session} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{isMobile && mobileMenuOpen && (
|
|
||||||
<div
|
|
||||||
className="mobile-menu-overlay"
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="shell-content">
|
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<MobileNav sessionCount={sessions.filter((s) => s.status === "running").length} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Project } from "../types";
|
|||||||
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
|
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||||
|
|
||||||
interface CreateSessionFormProps {
|
interface CreateSessionFormProps {
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
@@ -46,6 +47,8 @@ export const CreateSessionForm = ({
|
|||||||
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||||
const [branch, setBranch] = useState("main");
|
const [branch, setBranch] = useState("main");
|
||||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||||
|
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
||||||
|
|
||||||
const [branches, setBranches] = useState<Branch[]>([]);
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
@@ -71,6 +74,30 @@ export const CreateSessionForm = ({
|
|||||||
void loadKeys();
|
void loadKeys();
|
||||||
}, [showCloneMode]);
|
}, [showCloneMode]);
|
||||||
|
|
||||||
|
// Load config profiles when tool type is selected
|
||||||
|
useEffect(() => {
|
||||||
|
const projectId = fixedProjectId || selectedProject;
|
||||||
|
if (!selectedToolType || !projectId) {
|
||||||
|
setConfigProfiles([]);
|
||||||
|
setSelectedConfigProfile("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadProfiles = async () => {
|
||||||
|
try {
|
||||||
|
const profiles = await listConfigProfiles(projectId, selectedToolType);
|
||||||
|
setConfigProfiles(profiles);
|
||||||
|
// Auto-select default if available
|
||||||
|
const defaultProfile = profiles.find((p) => p.is_default);
|
||||||
|
if (defaultProfile) {
|
||||||
|
setSelectedConfigProfile(defaultProfile.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadProfiles();
|
||||||
|
}, [selectedToolType, selectedProject, fixedProjectId]);
|
||||||
|
|
||||||
// Load branches when selected repo changes
|
// Load branches when selected repo changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const projectId = fixedProjectId || selectedProject;
|
const projectId = fixedProjectId || selectedProject;
|
||||||
@@ -138,7 +165,8 @@ export const CreateSessionForm = ({
|
|||||||
: undefined,
|
: undefined,
|
||||||
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||||
? newBranchName
|
? newBranchName
|
||||||
: undefined
|
: undefined,
|
||||||
|
selectedConfigProfile || undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
setProgress("Starting container...");
|
setProgress("Starting container...");
|
||||||
@@ -298,8 +326,26 @@ export const CreateSessionForm = ({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 4: Clone Mode & Branch */}
|
{/* Step 4: Config Profile */}
|
||||||
{showCloneMode && hasToolType && renderStep("Repository Access", 4, true, false,
|
{hasToolType && renderStep("Config Profile (optional)", 4, true, false,
|
||||||
|
<label className="form-field">
|
||||||
|
<select
|
||||||
|
value={selectedConfigProfile}
|
||||||
|
onChange={(e) => setSelectedConfigProfile(e.target.value)}
|
||||||
|
disabled={!hasToolType || isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">No profile (use tool defaults)</option>
|
||||||
|
{configProfiles.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} {p.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: Clone Mode & Branch */}
|
||||||
|
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<div className="radio-group">
|
<div className="radio-group">
|
||||||
@@ -422,8 +468,8 @@ export const CreateSessionForm = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 5: Display Name */}
|
{/* Step 6: Display Name */}
|
||||||
{hasToolType && renderStep("Display Name (optional)", 5, true, !!displayName,
|
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -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,226 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import type { GitMount } from "../api/config_profiles";
|
||||||
|
|
||||||
|
interface GitMountEditorProps {
|
||||||
|
mounts: GitMount[];
|
||||||
|
onChange: (mounts: GitMount[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
||||||
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
|
const [newMount, setNewMount] = useState<GitMount>({
|
||||||
|
remote_url: "",
|
||||||
|
source_path: ".",
|
||||||
|
target_path: "",
|
||||||
|
branch: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAdd = (mount: GitMount) => {
|
||||||
|
onChange([...mounts, mount]);
|
||||||
|
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = (index: number, updated: GitMount) => {
|
||||||
|
const updatedMounts = [...mounts];
|
||||||
|
updatedMounts[index] = updated;
|
||||||
|
onChange(updatedMounts);
|
||||||
|
setEditingIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
onChange(mounts.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePath = (path: string, isTarget: boolean): string | null => {
|
||||||
|
if (!path) return isTarget ? "Target path is required" : null;
|
||||||
|
if (path.includes("..")) return "Path cannot contain ..";
|
||||||
|
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateUrl = (url: string): string | null => {
|
||||||
|
if (!url) return "Git URL is required";
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("git@") && !url.startsWith("ssh://")) {
|
||||||
|
return "Must be a valid git URL (https://, git@, or ssh://)";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="git-mount-editor">
|
||||||
|
<h4 className="section-subtitle">Git Mounts</h4>
|
||||||
|
|
||||||
|
{mounts.length > 0 && (
|
||||||
|
<div className="git-mount-list">
|
||||||
|
{mounts.map((mount, index) => (
|
||||||
|
<div key={index} className="git-mount-item">
|
||||||
|
{editingIndex === index ? (
|
||||||
|
<GitMountForm
|
||||||
|
mount={mount}
|
||||||
|
onSave={(updated) => handleUpdate(index, updated)}
|
||||||
|
onCancel={() => setEditingIndex(null)}
|
||||||
|
validatePath={validatePath}
|
||||||
|
validateUrl={validateUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="git-mount-display">
|
||||||
|
<div className="git-mount-info">
|
||||||
|
<span className="git-mount-repo">{mount.remote_url}</span>
|
||||||
|
<span className="git-mount-paths">
|
||||||
|
{mount.source_path || "."} → {mount.target_path}
|
||||||
|
</span>
|
||||||
|
{mount.branch && (
|
||||||
|
<span className="git-mount-branch">@{mount.branch}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="git-mount-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => setEditingIndex(index)}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="git-mount-add">
|
||||||
|
<h5>Add Git Mount</h5>
|
||||||
|
<GitMountForm
|
||||||
|
mount={newMount}
|
||||||
|
onSave={handleAdd}
|
||||||
|
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
|
||||||
|
validatePath={validatePath}
|
||||||
|
validateUrl={validateUrl}
|
||||||
|
isNew
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GitMountFormProps {
|
||||||
|
mount: GitMount;
|
||||||
|
onSave: (mount: GitMount) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
validatePath: (path: string, isTarget: boolean) => string | null;
|
||||||
|
validateUrl: (url: string) => string | null;
|
||||||
|
isNew?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
|
||||||
|
const [form, setForm] = useState<GitMount>({ ...mount });
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const handleChange = (field: keyof GitMount, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const urlError = validateUrl(form.remote_url);
|
||||||
|
if (urlError) newErrors.remote_url = urlError;
|
||||||
|
|
||||||
|
const sourceError = validatePath(form.source_path || ".", false);
|
||||||
|
if (sourceError) newErrors.source_path = sourceError;
|
||||||
|
|
||||||
|
const targetError = validatePath(form.target_path, true);
|
||||||
|
if (targetError) newErrors.target_path = targetError;
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(form);
|
||||||
|
if (isNew) {
|
||||||
|
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="git-mount-form">
|
||||||
|
<div className="form-row">
|
||||||
|
<label>Git URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.remote_url}
|
||||||
|
onChange={(e) => handleChange("remote_url", e.target.value)}
|
||||||
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
className={errors.remote_url ? "error" : ""}
|
||||||
|
/>
|
||||||
|
<span className="hint">Repository URL (HTTPS or SSH)</span>
|
||||||
|
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label>Source Path</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.source_path || "."}
|
||||||
|
onChange={(e) => handleChange("source_path", e.target.value)}
|
||||||
|
placeholder="e.g., . or configs/*.json"
|
||||||
|
className={errors.source_path ? "error" : ""}
|
||||||
|
/>
|
||||||
|
<span className="hint">Relative path in repo (supports glob patterns)</span>
|
||||||
|
{errors.source_path && <span className="error-text">{errors.source_path}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label>Target Path</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.target_path}
|
||||||
|
onChange={(e) => handleChange("target_path", e.target.value)}
|
||||||
|
placeholder="e.g., /app/config"
|
||||||
|
className={errors.target_path ? "error" : ""}
|
||||||
|
/>
|
||||||
|
<span className="hint">Use absolute path (e.g. /app/config). Relative paths need working_directory set in tool config.</span>
|
||||||
|
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label>Branch (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.branch || ""}
|
||||||
|
onChange={(e) => handleChange("branch", e.target.value)}
|
||||||
|
placeholder="e.g., main or v1.0"
|
||||||
|
/>
|
||||||
|
<span className="hint">Branch or tag to checkout</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="primary-button" onClick={handleSubmit}>
|
||||||
|
{isNew ? "Add" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="secondary-button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
Stop,
|
Stop,
|
||||||
Terminal,
|
Terminal,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
DotsSixVertical,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
export type IconName =
|
export type IconName =
|
||||||
@@ -75,7 +76,8 @@ export type IconName =
|
|||||||
| "play"
|
| "play"
|
||||||
| "stop"
|
| "stop"
|
||||||
| "terminal"
|
| "terminal"
|
||||||
| "arrow-left";
|
| "arrow-left"
|
||||||
|
| "drag";
|
||||||
|
|
||||||
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
||||||
dashboard: House,
|
dashboard: House,
|
||||||
@@ -117,6 +119,7 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
|
|||||||
stop: Stop,
|
stop: Stop,
|
||||||
terminal: Terminal,
|
terminal: Terminal,
|
||||||
"arrow-left": ArrowLeft,
|
"arrow-left": ArrowLeft,
|
||||||
|
drag: DotsSixVertical,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
@@ -147,7 +150,6 @@ export const Icon: React.FC<IconProps> = ({
|
|||||||
const sizeValue = sizeMap[size];
|
const sizeValue = sizeMap[size];
|
||||||
|
|
||||||
if (!IconComponent) {
|
if (!IconComponent) {
|
||||||
console.warn(`Icon "${name}" not found`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
import { CreateSessionForm } from "./create-session-form";
|
import { CreateSessionForm } from "./create-session-form";
|
||||||
|
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
@@ -37,6 +38,14 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
// Health check state
|
// Health check state
|
||||||
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
|
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
|
||||||
|
|
||||||
|
// Config profile selection for start/restart
|
||||||
|
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||||
|
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<string | null>(null);
|
||||||
|
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
|
||||||
|
|
||||||
|
// Per-instance busy state for actions
|
||||||
|
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadInstances = useCallback(async () => {
|
const loadInstances = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -88,51 +97,79 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
await loadInstances();
|
await loadInstances();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStart = async (instanceId: string) => {
|
const loadConfigProfiles = useCallback(async (toolTypeId: string) => {
|
||||||
try {
|
try {
|
||||||
await startInstance(projectId, repoId, instanceId);
|
const profiles = await listConfigProfiles(projectId, toolTypeId);
|
||||||
|
setConfigProfiles(profiles);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const handleStart = async (instanceId: string, configProfileId?: string) => {
|
||||||
|
setBusyInstanceId(instanceId);
|
||||||
|
try {
|
||||||
|
await startInstance(projectId, repoId, instanceId, configProfileId);
|
||||||
|
setProfileSelectInstanceId(null);
|
||||||
|
setSelectedProfileForAction("");
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to start instance");
|
setError("Failed to start instance");
|
||||||
|
} finally {
|
||||||
|
setBusyInstanceId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (instanceId: string) => {
|
const handleStop = async (instanceId: string) => {
|
||||||
|
setBusyInstanceId(instanceId);
|
||||||
try {
|
try {
|
||||||
await stopInstance(projectId, repoId, instanceId);
|
await stopInstance(projectId, repoId, instanceId);
|
||||||
setStopConfirmId(null);
|
setStopConfirmId(null);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to stop instance");
|
setError("Failed to stop instance");
|
||||||
|
} finally {
|
||||||
|
setBusyInstanceId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestart = async (instanceId: string) => {
|
const handleRestart = async (instanceId: string, configProfileId?: string) => {
|
||||||
|
setBusyInstanceId(instanceId);
|
||||||
try {
|
try {
|
||||||
await restartInstance(projectId, repoId, instanceId);
|
await restartInstance(projectId, repoId, instanceId, configProfileId);
|
||||||
|
setProfileSelectInstanceId(null);
|
||||||
|
setSelectedProfileForAction("");
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to restart instance");
|
setError("Failed to restart instance");
|
||||||
|
} finally {
|
||||||
|
setBusyInstanceId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (instanceId: string) => {
|
const handleDelete = async (instanceId: string) => {
|
||||||
if (!confirm("Are you sure you want to delete this instance?")) return;
|
if (!confirm("Are you sure you want to delete this instance?")) return;
|
||||||
|
setBusyInstanceId(instanceId);
|
||||||
try {
|
try {
|
||||||
await deleteInstance(projectId, repoId, instanceId);
|
await deleteInstance(projectId, repoId, instanceId);
|
||||||
// Update state immediately instead of reloading
|
// Update state immediately instead of reloading
|
||||||
setInstances(prev => prev.filter(i => i.id !== instanceId));
|
setInstances(prev => prev.filter(i => i.id !== instanceId));
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to delete instance");
|
setError("Failed to delete instance");
|
||||||
|
} finally {
|
||||||
|
setBusyInstanceId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRecreateTunnel = async (instanceId: string) => {
|
const handleRecreateTunnel = async (instanceId: string) => {
|
||||||
|
setBusyInstanceId(instanceId);
|
||||||
try {
|
try {
|
||||||
await recreateInstanceTunnel(projectId, repoId, instanceId);
|
await recreateInstanceTunnel(projectId, repoId, instanceId);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to recreate tunnel");
|
setError("Failed to recreate tunnel");
|
||||||
|
} finally {
|
||||||
|
setBusyInstanceId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,7 +220,12 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
) : (
|
) : (
|
||||||
<div className="instance-grid">
|
<div className="instance-grid">
|
||||||
{instances.map((instance) => (
|
{instances.map((instance) => (
|
||||||
<div key={instance.id} className="instance-card">
|
<div key={instance.id} className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}>
|
||||||
|
{busyInstanceId === instance.id && (
|
||||||
|
<div className="instance-busy-overlay">
|
||||||
|
<Icon name="loading" size="md" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="instance-info">
|
<div className="instance-info">
|
||||||
<div className="instance-name">{instance.display_name}</div>
|
<div className="instance-name">{instance.display_name}</div>
|
||||||
<div className="instance-meta">
|
<div className="instance-meta">
|
||||||
@@ -199,6 +241,13 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{instance.selected_config_profile_id && (
|
||||||
|
<div className="instance-profile">
|
||||||
|
<span className="badge">
|
||||||
|
Profile: {configProfiles.find((p) => p.id === instance.selected_config_profile_id)?.name || instance.selected_config_profile_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="instance-actions">
|
<div className="instance-actions">
|
||||||
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
||||||
@@ -218,6 +267,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
onClick={() => void handleRecreateTunnel(instance.id)}
|
onClick={() => void handleRecreateTunnel(instance.id)}
|
||||||
type="button"
|
type="button"
|
||||||
title="Recreate tunnel"
|
title="Recreate tunnel"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
>
|
>
|
||||||
<Icon name="refresh" size="sm" />
|
<Icon name="refresh" size="sm" />
|
||||||
Fix Tunnel
|
Fix Tunnel
|
||||||
@@ -230,20 +280,67 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
|
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
>
|
>
|
||||||
<Icon name="terminal" size="sm" />
|
<Icon name="terminal" size="sm" />
|
||||||
Terminal
|
Terminal
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{instance.status !== "running" && (
|
{instance.status !== "running" && (
|
||||||
<button
|
<>
|
||||||
className="secondary-button small"
|
{profileSelectInstanceId === instance.id ? (
|
||||||
onClick={() => void handleStart(instance.id)}
|
<div className="inline-profile-select">
|
||||||
type="button"
|
<select
|
||||||
>
|
value={selectedProfileForAction}
|
||||||
<Icon name="play" size="sm" />
|
onChange={(e) => setSelectedProfileForAction(e.target.value)}
|
||||||
Start
|
>
|
||||||
</button>
|
<option value="">Default (none)</option>
|
||||||
|
{configProfiles.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="primary-button small"
|
||||||
|
onClick={() => void handleStart(instance.id, selectedProfileForAction || undefined)}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileSelectInstanceId(null);
|
||||||
|
setSelectedProfileForAction("");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => {
|
||||||
|
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
|
||||||
|
if (toolType) {
|
||||||
|
void loadConfigProfiles(toolType.id);
|
||||||
|
}
|
||||||
|
setProfileSelectInstanceId(instance.id);
|
||||||
|
setSelectedProfileForAction(instance.selected_config_profile_id || "");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{instance.status === "running" && (
|
{instance.status === "running" && (
|
||||||
<>
|
<>
|
||||||
@@ -254,6 +351,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
className="ghost-button small danger-text"
|
className="ghost-button small danger-text"
|
||||||
onClick={() => void handleStop(instance.id)}
|
onClick={() => void handleStop(instance.id)}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
>
|
>
|
||||||
Yes
|
Yes
|
||||||
</button>
|
</button>
|
||||||
@@ -261,6 +359,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
className="ghost-button small"
|
className="ghost-button small"
|
||||||
onClick={() => setStopConfirmId(null)}
|
onClick={() => setStopConfirmId(null)}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
>
|
>
|
||||||
No
|
No
|
||||||
</button>
|
</button>
|
||||||
@@ -270,23 +369,69 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
className="ghost-button small"
|
className="ghost-button small"
|
||||||
onClick={() => setStopConfirmId(instance.id)}
|
onClick={() => setStopConfirmId(instance.id)}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
>
|
>
|
||||||
<Icon name="stop" size="sm" />
|
<Icon name="stop" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{profileSelectInstanceId === instance.id ? (
|
||||||
className="ghost-button small"
|
<div className="inline-profile-select">
|
||||||
onClick={() => void handleRestart(instance.id)}
|
<select
|
||||||
type="button"
|
value={selectedProfileForAction}
|
||||||
>
|
onChange={(e) => setSelectedProfileForAction(e.target.value)}
|
||||||
<Icon name="refresh" size="sm" />
|
>
|
||||||
</button>
|
<option value="">Default (none)</option>
|
||||||
|
{configProfiles.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="primary-button small"
|
||||||
|
onClick={() => void handleRestart(instance.id, selectedProfileForAction || undefined)}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileSelectInstanceId(null);
|
||||||
|
setSelectedProfileForAction("");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => {
|
||||||
|
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
|
||||||
|
if (toolType) {
|
||||||
|
void loadConfigProfiles(toolType.id);
|
||||||
|
}
|
||||||
|
setProfileSelectInstanceId(instance.id);
|
||||||
|
setSelectedProfileForAction(instance.selected_config_profile_id || "");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="ghost-button small danger-text"
|
className="ghost-button small danger-text"
|
||||||
onClick={() => void handleDelete(instance.id)}
|
onClick={() => void handleDelete(instance.id)}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="sm" />
|
<Icon name="delete" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import type { IconName } from "./icon";
|
||||||
|
|
||||||
|
export interface MobileActionSheetItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: IconName;
|
||||||
|
variant?: "default" | "danger";
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileActionSheetProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
actions: MobileActionSheetItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileActionSheet({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
actions,
|
||||||
|
}: MobileActionSheetProps) {
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-action-sheet-overlay" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
ref={sheetRef}
|
||||||
|
className="mobile-action-sheet"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mobile-action-sheet-header">
|
||||||
|
<div className="mobile-action-sheet-handle" />
|
||||||
|
<h3>{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-action-sheet-actions">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
className={`mobile-action-sheet-button ${action.variant || "default"}`}
|
||||||
|
onClick={() => {
|
||||||
|
action.onClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{action.icon && <Icon name={action.icon} size="md" />}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="mobile-action-sheet-cancel"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
interface Field {
|
||||||
|
label: string;
|
||||||
|
value: string | number | boolean | null;
|
||||||
|
type?: "text" | "code" | "json" | "boolean";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileDetailViewProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
fields: Field[];
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
fields,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onBack,
|
||||||
|
}) => {
|
||||||
|
const renderValue = (field: Field) => {
|
||||||
|
if (field.value === null || field.value === undefined) {
|
||||||
|
return <span className="text-muted">Not set</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === "boolean") {
|
||||||
|
return field.value ? (
|
||||||
|
<span className="badge badge-success">Yes</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-secondary">No</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === "code" || field.type === "json") {
|
||||||
|
return (
|
||||||
|
<pre className="mobile-detail-code">
|
||||||
|
{typeof field.value === "string" ? field.value : JSON.stringify(field.value, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span>{String(field.value)}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-detail-view">
|
||||||
|
<header className="mobile-detail-header">
|
||||||
|
<button
|
||||||
|
className="mobile-detail-back"
|
||||||
|
onClick={onBack}
|
||||||
|
type="button"
|
||||||
|
aria-label="Go back"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size="md" />
|
||||||
|
</button>
|
||||||
|
<div className="mobile-detail-header-content">
|
||||||
|
<h1 className="mobile-detail-title">{title}</h1>
|
||||||
|
{subtitle && <p className="mobile-detail-subtitle">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="mobile-detail-actions">
|
||||||
|
<button
|
||||||
|
className="mobile-detail-action"
|
||||||
|
onClick={onEdit}
|
||||||
|
type="button"
|
||||||
|
aria-label="Edit"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="mobile-detail-action mobile-detail-action-danger"
|
||||||
|
onClick={onDelete}
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mobile-detail-fields">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={index} className="mobile-detail-field">
|
||||||
|
<label className="mobile-detail-field-label">{field.label}</label>
|
||||||
|
<div className="mobile-detail-field-value">{renderValue(field)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface FormField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: "text" | "textarea" | "number" | "select" | "checkbox" | "code";
|
||||||
|
value: string | number | boolean;
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileEditViewProps {
|
||||||
|
title: string;
|
||||||
|
fields?: FormField[];
|
||||||
|
onSave: (data: Record<string, string | number | boolean>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isSaving?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileEditView: React.FC<MobileEditViewProps> = ({
|
||||||
|
title,
|
||||||
|
fields,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
isSaving = false,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<Record<string, string | number | boolean>>(
|
||||||
|
() => {
|
||||||
|
const initial: Record<string, string | number | boolean> = {};
|
||||||
|
fields?.forEach((field) => {
|
||||||
|
initial[field.name] = field.value;
|
||||||
|
});
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = (name: string, value: string | number | boolean) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-edit-view">
|
||||||
|
<header className="mobile-edit-header">
|
||||||
|
<button
|
||||||
|
className="mobile-edit-cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<h1 className="mobile-edit-title">{title}</h1>
|
||||||
|
<button
|
||||||
|
className="mobile-edit-save"
|
||||||
|
onClick={() => onSave(formData)}
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="mobile-edit-form" onSubmit={handleSubmit}>
|
||||||
|
{children || fields?.map((field) => (
|
||||||
|
<div key={field.name} className="mobile-edit-field">
|
||||||
|
<label className="mobile-edit-field-label" htmlFor={field.name}>
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="required">*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{field.type === "textarea" && (
|
||||||
|
<textarea
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
value={String(formData[field.name] ?? "")}
|
||||||
|
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
rows={field.rows || 4}
|
||||||
|
className="mobile-edit-input mobile-edit-textarea"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === "select" && (
|
||||||
|
<select
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
value={String(formData[field.name] ?? "")}
|
||||||
|
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||||
|
required={field.required}
|
||||||
|
className="mobile-edit-input"
|
||||||
|
>
|
||||||
|
{field.options?.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === "checkbox" && (
|
||||||
|
<label className="mobile-edit-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
checked={Boolean(formData[field.name])}
|
||||||
|
onChange={(e) => handleChange(field.name, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{field.label}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === "code" && (
|
||||||
|
<textarea
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
value={String(formData[field.name] ?? "")}
|
||||||
|
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
rows={field.rows || 8}
|
||||||
|
className="mobile-edit-input mobile-edit-code"
|
||||||
|
style={{ fontFamily: "monospace" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(field.type === "text" || field.type === "number") && (
|
||||||
|
<input
|
||||||
|
type={field.type === "number" ? "number" : "text"}
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
value={String(formData[field.name] ?? "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange(
|
||||||
|
field.name,
|
||||||
|
field.type === "number"
|
||||||
|
? Number(e.target.value)
|
||||||
|
: e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
className="mobile-edit-input"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
interface MobileFABProps {
|
||||||
|
onClick: () => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileFAB: React.FC<MobileFABProps> = ({
|
||||||
|
onClick,
|
||||||
|
label = "Create new",
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="mobile-fab"
|
||||||
|
onClick={onClick}
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="md" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Icon } from "./icon";
|
||||||
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
|
interface MobileListItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileListViewProps {
|
||||||
|
items: MobileListItem[];
|
||||||
|
onItemClick: (id: string) => void;
|
||||||
|
onItemDelete?: (id: string) => void;
|
||||||
|
onItemDuplicate?: (id: string) => void;
|
||||||
|
emptyMessage?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileListView: React.FC<MobileListViewProps> = ({
|
||||||
|
items,
|
||||||
|
onItemClick,
|
||||||
|
emptyMessage = "No items found",
|
||||||
|
searchPlaceholder = "Search...",
|
||||||
|
onSearch,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="mobile-list-view">
|
||||||
|
{onSearch && (
|
||||||
|
<div className="mobile-list-search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
|
className="mobile-list-search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="mobile-list-empty">
|
||||||
|
<Icon name="folder" size="lg" />
|
||||||
|
<p>{emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mobile-list-items">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
className="mobile-list-item"
|
||||||
|
onClick={() => onItemClick(item.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<div className="mobile-list-item-icon">
|
||||||
|
<Icon name={item.icon as IconName} size="md" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mobile-list-item-content">
|
||||||
|
<div className="mobile-list-item-title">{item.title}</div>
|
||||||
|
{item.subtitle && (
|
||||||
|
<div className="mobile-list-item-subtitle">{item.subtitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mobile-list-item-actions" style={{ transform: "rotate(180deg)" }}>
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import { ToolsBottomSheet } from "./tools-bottom-sheet";
|
||||||
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
|
interface MobileNavProps {
|
||||||
|
sessionCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
icon: IconName;
|
||||||
|
isGroup?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOBILE_NAV_ITEMS: NavItem[] = [
|
||||||
|
{ to: "/", label: "Home", icon: "dashboard" },
|
||||||
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
|
{ to: "/sessions", label: "Sessions", icon: "terminal" },
|
||||||
|
{ to: "/tools", label: "Tools", icon: "settings", isGroup: true },
|
||||||
|
{ to: "/settings", label: "Settings", icon: "settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MobileNav: React.FC<MobileNavProps> = ({ sessionCount }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||||
|
|
||||||
|
const isToolsActive =
|
||||||
|
location.pathname === "/tool-workshop" ||
|
||||||
|
location.pathname === "/config-profiles";
|
||||||
|
|
||||||
|
const handleNavClick = (item: NavItem) => {
|
||||||
|
if (item.isGroup) {
|
||||||
|
setToolsSheetOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav className="mobile-nav" role="navigation" aria-label="Mobile navigation">
|
||||||
|
{MOBILE_NAV_ITEMS.map((item) => {
|
||||||
|
if (item.isGroup) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.to}
|
||||||
|
className={`mobile-nav-item ${isToolsActive ? "active" : ""}`}
|
||||||
|
onClick={() => handleNavClick(item)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="mobile-nav-icon-wrapper">
|
||||||
|
<Icon name={item.icon} size="md" />
|
||||||
|
</div>
|
||||||
|
<span className="mobile-nav-label">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`mobile-nav-item ${isActive ? "active" : ""}`
|
||||||
|
}
|
||||||
|
end={item.to === "/"}
|
||||||
|
>
|
||||||
|
<div className="mobile-nav-icon-wrapper">
|
||||||
|
<Icon name={item.icon} size="md" />
|
||||||
|
{item.to === "/sessions" && sessionCount ? (
|
||||||
|
<span className="mobile-nav-badge">{sessionCount}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="mobile-nav-label">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<ToolsBottomSheet
|
||||||
|
isOpen={toolsSheetOpen}
|
||||||
|
onClose={() => setToolsSheetOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
interface MobilePageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
showBack?: boolean;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobilePageHeader({ title, showBack = true, actions }: MobilePageHeaderProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
|
||||||
|
if (!isMobile) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-page-header">
|
||||||
|
{showBack && (
|
||||||
|
<button
|
||||||
|
className="mobile-page-header-back"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Go back"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size="md" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{actions && <div className="mobile-page-header-actions">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ interface MobileTerminalHeaderProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onFontSizeChange?: (delta: number) => void;
|
onFontSizeChange?: (delta: number) => void;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
connectionStatus?: "connecting" | "connected" | "disconnected" | "error";
|
connectionStatus?: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
|
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
|||||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
||||||
const [terminalRef, setTerminalRef] = useState<{
|
const [terminalRef, setTerminalRef] = useState<{
|
||||||
sendData: (data: string) => void;
|
sendData: (data: string) => void;
|
||||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error";
|
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||||
focusInput: () => void;
|
focusInput: () => void;
|
||||||
changeFontSize: (delta: number) => void;
|
changeFontSize: (delta: number) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -42,7 +42,7 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
|||||||
}, [headerAutoHide]);
|
}, [headerAutoHide]);
|
||||||
|
|
||||||
const handleTerminalReady = useCallback(
|
const handleTerminalReady = useCallback(
|
||||||
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error", focusInput: () => void, changeFontSize: (delta: number) => void) => {
|
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => {
|
||||||
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
|
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { Session } from "../api/sessions";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
import { MobileActionSheet } from "./mobile-action-sheet";
|
||||||
|
import type { IconName } from "./icon";
|
||||||
|
|
||||||
|
export interface SessionCardProps {
|
||||||
|
session: Session;
|
||||||
|
onOpen?: (session: Session) => void;
|
||||||
|
onStart?: (session: Session) => void;
|
||||||
|
onStop?: (session: Session) => void;
|
||||||
|
onDelete?: (session: Session) => void;
|
||||||
|
onRecreateTunnel?: (session: Session) => void;
|
||||||
|
isBusy?: boolean;
|
||||||
|
tunnelHealth?: {
|
||||||
|
healthy: boolean;
|
||||||
|
container_status: string;
|
||||||
|
container_health: string | null;
|
||||||
|
tunnel_status: string;
|
||||||
|
tunnel_status_code: number | null;
|
||||||
|
probe_status: string;
|
||||||
|
last_probe_output: string | null;
|
||||||
|
error: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { color: string; label: string }> = {
|
||||||
|
running: { color: "green", label: "Running" },
|
||||||
|
building: { color: "yellow", label: "Building" },
|
||||||
|
starting: { color: "yellow", label: "Starting" },
|
||||||
|
probing: { color: "yellow", label: "Probing" },
|
||||||
|
pending: { color: "yellow", label: "Pending" },
|
||||||
|
stopped: { color: "gray", label: "Stopped" },
|
||||||
|
error: { color: "red", label: "Error" },
|
||||||
|
unhealthy: { color: "orange", label: "Unhealthy" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SessionCard({
|
||||||
|
session,
|
||||||
|
onOpen,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onDelete,
|
||||||
|
onRecreateTunnel,
|
||||||
|
isBusy = false,
|
||||||
|
tunnelHealth = null,
|
||||||
|
}: SessionCardProps) {
|
||||||
|
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showActionSheet, setShowActionSheet] = useState(false);
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
|
||||||
|
const status = statusConfig[session.status] || { color: "gray", label: session.status };
|
||||||
|
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
|
||||||
|
const hasTunnelError = !isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
|
||||||
|
const hasAppError = !isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
if (showStopConfirm) {
|
||||||
|
setShowStopConfirm(false);
|
||||||
|
onStop?.(session);
|
||||||
|
} else {
|
||||||
|
setShowStopConfirm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (showDeleteConfirm) {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onDelete?.(session);
|
||||||
|
} else {
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelStop = () => setShowStopConfirm(false);
|
||||||
|
const handleCancelDelete = () => setShowDeleteConfirm(false);
|
||||||
|
|
||||||
|
const isActive = ["running", "building", "starting", "probing", "pending", "unhealthy"].includes(session.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
|
||||||
|
{isBusy && (
|
||||||
|
<div className="session-busy-overlay">
|
||||||
|
<Icon name="loading" size="md" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="session-card-content">
|
||||||
|
<div className="session-card-header">
|
||||||
|
<div className="session-card-title">
|
||||||
|
<h4>{session.display_name}</h4>
|
||||||
|
<div className="session-card-status-badges">
|
||||||
|
<span className={`status-badge ${status.color}`}>{status.label}</span>
|
||||||
|
{hasTunnelError && (
|
||||||
|
<span className="status-badge error">Tunnel Error</span>
|
||||||
|
)}
|
||||||
|
{hasAppError && (
|
||||||
|
<span className="status-badge warning">App Error {tunnelHealth?.tunnel_status_code}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="muted session-card-meta">
|
||||||
|
{session.tool_type_name}
|
||||||
|
{session.project_name && ` · ${session.project_name}`}
|
||||||
|
{session.repository_name && ` · ${session.repository_name}`}
|
||||||
|
</p>
|
||||||
|
{session.clone_mode && (
|
||||||
|
<p className="muted session-card-meta">
|
||||||
|
<Icon name="branch" size="sm" />
|
||||||
|
{session.clone_mode === "clone"
|
||||||
|
? `Clone${session.branch ? ` (${session.branch})` : ""}`
|
||||||
|
: "Mount"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{session.url && (
|
||||||
|
<p className="session-card-url">
|
||||||
|
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{session.url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{session.created_at && (
|
||||||
|
<p className="muted session-card-meta">
|
||||||
|
Created: {new Date(session.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="session-card-actions mobile">
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
{session.url ? (
|
||||||
|
<a
|
||||||
|
href={session.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="secondary-button mobile-primary"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button mobile-primary"
|
||||||
|
onClick={() => onOpen?.(session)}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="ghost-button mobile-more"
|
||||||
|
onClick={() => setShowActionSheet(true)}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="menu" size="sm" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isActive && onStart && (
|
||||||
|
<button
|
||||||
|
className="secondary-button mobile-primary"
|
||||||
|
onClick={() => onStart(session)}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isActive && (
|
||||||
|
<button
|
||||||
|
className="ghost-button mobile-more"
|
||||||
|
onClick={() => setShowActionSheet(true)}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="menu" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="session-card-actions">
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
{session.url ? (
|
||||||
|
<a
|
||||||
|
href={session.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="secondary-button small"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
<span className="action-label">Open</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => onOpen?.(session)}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
<span className="action-label">Open</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasTunnelError && onRecreateTunnel && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => onRecreateTunnel(session)}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
<span className="action-label">Tunnel</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showStopConfirm ? (
|
||||||
|
<div className="confirm-inline">
|
||||||
|
<span className="confirm-text">Stop?</span>
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={handleStop}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={handleCancelStop}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={handleStop}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
<span className="action-label">Stop</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isActive && onStart && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => onStart(session)}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
<span className="action-label">Start</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDeleteConfirm ? (
|
||||||
|
<div className="confirm-inline">
|
||||||
|
<span className="confirm-text">Delete?</span>
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={handleDelete}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={handleCancelDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={handleDelete}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MobileActionSheet
|
||||||
|
isOpen={showActionSheet}
|
||||||
|
onClose={() => setShowActionSheet(false)}
|
||||||
|
title={session.display_name}
|
||||||
|
actions={[
|
||||||
|
...(isActive && hasTunnelError && onRecreateTunnel
|
||||||
|
? [{
|
||||||
|
id: "tunnel",
|
||||||
|
label: "Recreate Tunnel",
|
||||||
|
icon: "refresh" as IconName,
|
||||||
|
onClick: () => onRecreateTunnel(session),
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
...(isActive && onStop
|
||||||
|
? [{
|
||||||
|
id: "stop",
|
||||||
|
label: "Stop",
|
||||||
|
icon: "stop" as IconName,
|
||||||
|
variant: "danger" as const,
|
||||||
|
onClick: () => onStop(session),
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
...(onDelete
|
||||||
|
? [{
|
||||||
|
id: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "delete" as IconName,
|
||||||
|
variant: "danger" as const,
|
||||||
|
onClick: () => onDelete(session),
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { Session } from "../api/sessions";
|
||||||
|
import { SessionCard } from "./session-card";
|
||||||
|
import type { InstanceHealth } from "../api/sessions";
|
||||||
|
|
||||||
|
export interface SessionListProps {
|
||||||
|
sessions: Session[];
|
||||||
|
onOpen?: (session: Session) => void;
|
||||||
|
onStart?: (session: Session) => void;
|
||||||
|
onStop?: (session: Session) => void;
|
||||||
|
onDelete?: (session: Session) => void;
|
||||||
|
onRecreateTunnel?: (session: Session) => void;
|
||||||
|
actionBusyId?: string | null;
|
||||||
|
tunnelHealth?: Record<string, InstanceHealth>;
|
||||||
|
showGrouping?: boolean;
|
||||||
|
activeTitle?: string;
|
||||||
|
recentTitle?: string;
|
||||||
|
maxRecent?: number;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeStatuses = ["running", "building", "starting", "probing", "pending", "unhealthy"];
|
||||||
|
const recentStatuses = ["stopped", "error"];
|
||||||
|
|
||||||
|
export function SessionList({
|
||||||
|
sessions,
|
||||||
|
onOpen,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onDelete,
|
||||||
|
onRecreateTunnel,
|
||||||
|
actionBusyId = null,
|
||||||
|
tunnelHealth = {},
|
||||||
|
showGrouping = true,
|
||||||
|
activeTitle = "Active Sessions",
|
||||||
|
recentTitle = "Recent Sessions",
|
||||||
|
maxRecent = 5,
|
||||||
|
emptyMessage = "No sessions",
|
||||||
|
}: SessionListProps) {
|
||||||
|
const activeSessions = sessions.filter((s) => activeStatuses.includes(s.status));
|
||||||
|
const recentSessions = sessions
|
||||||
|
.filter((s) => recentStatuses.includes(s.status))
|
||||||
|
.slice(0, maxRecent);
|
||||||
|
|
||||||
|
if (!showGrouping) {
|
||||||
|
return (
|
||||||
|
<div className="sessions-grid">
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<p className="muted">{emptyMessage}</p>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onStart={onStart}
|
||||||
|
onStop={onStop}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
|
isBusy={actionBusyId === session.id}
|
||||||
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="session-list">
|
||||||
|
{/* Active Sessions */}
|
||||||
|
<div className="session-group">
|
||||||
|
<div className="session-group-header">
|
||||||
|
<h3>{activeTitle}</h3>
|
||||||
|
{activeSessions.length > 0 && (
|
||||||
|
<span className="badge">{activeSessions.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{activeSessions.length === 0 ? (
|
||||||
|
<p className="muted">No active sessions</p>
|
||||||
|
) : (
|
||||||
|
<div className="sessions-grid">
|
||||||
|
{activeSessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onStart={onStart}
|
||||||
|
onStop={onStop}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
|
isBusy={actionBusyId === session.id}
|
||||||
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Sessions */}
|
||||||
|
{recentSessions.length > 0 && (
|
||||||
|
<div className="session-group">
|
||||||
|
<div className="session-group-header">
|
||||||
|
<h3>{recentTitle}</h3>
|
||||||
|
<span className="badge">{recentSessions.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sessions-grid">
|
||||||
|
{recentSessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onStart={onStart}
|
||||||
|
onStop={onStop}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
|
isBusy={actionBusyId === session.id}
|
||||||
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { getSequenceWithModifier, type SpecialKey, type ModifierKey, KEY_SEQUENCES } from "../hooks/use-special-keys";
|
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
|
||||||
|
|
||||||
interface SpecialKeysStripProps {
|
interface SpecialKeysStripProps {
|
||||||
onSend: (data: string) => void;
|
onSend: (data: string) => void;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
interface ToolsBottomSheetProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLS_ITEMS = [
|
||||||
|
{ to: "/tool-workshop", label: "Tool Workshop" },
|
||||||
|
{ to: "/config-profiles", label: "Config Profiles" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ToolsBottomSheet: React.FC<ToolsBottomSheetProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSelect = (to: string) => {
|
||||||
|
onClose();
|
||||||
|
navigate(to);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mobile-bottom-sheet-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mobile-bottom-sheet"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Tools menu"
|
||||||
|
>
|
||||||
|
<div className="mobile-bottom-sheet-header">
|
||||||
|
<div className="mobile-bottom-sheet-handle" />
|
||||||
|
<h3 className="mobile-bottom-sheet-title">Tools</h3>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-bottom-sheet-content">
|
||||||
|
{TOOLS_ITEMS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.to}
|
||||||
|
className={`mobile-bottom-sheet-item ${
|
||||||
|
location.pathname === item.to ? "active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelect(item.to)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="mobile-bottom-sheet-item-label">{item.label}</span>
|
||||||
|
{location.pathname === item.to && <Icon name="success" size="sm" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,16 @@ 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 { 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 { useInstanceActions } from "../hooks/use-instance-actions";
|
||||||
|
|
||||||
type HomeStatus = "loading" | "ready" | "error";
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -30,9 +32,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 [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = 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 : [];
|
||||||
|
|
||||||
@@ -59,6 +58,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 () => {
|
||||||
@@ -125,71 +133,12 @@ export const HomePage = () => {
|
|||||||
[safeSessions]
|
[safeSessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const recentSessions = useMemo(
|
|
||||||
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
|
|
||||||
[safeSessions]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||||
await updateUserConfig({ last_session_id: instance.id });
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
setSelectedProject("");
|
setSelectedProject("");
|
||||||
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 (stopConfirmId !== session.id) {
|
|
||||||
setStopConfirmId(session.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActionBusy(session.id);
|
|
||||||
setStopConfirmId(null);
|
|
||||||
try {
|
|
||||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
await loadHome();
|
|
||||||
} finally {
|
|
||||||
setActionBusy(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (session: SessionView) => {
|
|
||||||
if (deleteConfirmId !== session.id) {
|
|
||||||
setDeleteConfirmId(session.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActionBusy(session.id);
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
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) => {
|
|
||||||
setActionBusy(session.id);
|
|
||||||
try {
|
|
||||||
await recreateInstanceTunnel(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">
|
||||||
@@ -204,17 +153,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 && (
|
||||||
<>
|
<>
|
||||||
@@ -237,74 +178,20 @@ export const HomePage = () => {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Open sessions</p>
|
<p className="eyebrow">Open sessions</p>
|
||||||
<h2>{activeSessions.length}</h2>
|
<h2>{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activeSessions.length === 0 ? (
|
<SessionList
|
||||||
<p className="muted">No active sessions right now.</p>
|
sessions={safeSessions}
|
||||||
) : (
|
onOpen={handleOpen}
|
||||||
<div className="home-session-grid">
|
onStop={handleStop}
|
||||||
{activeSessions.map((session) => {
|
onDelete={handleDelete}
|
||||||
const health = tunnelHealth[session.id];
|
onRecreateTunnel={handleRecreateTunnel}
|
||||||
const isUnhealthy = health && !health.healthy;
|
actionBusyId={actionBusy}
|
||||||
return (
|
tunnelHealth={tunnelHealth}
|
||||||
<article className="card session-card" key={session.id}>
|
showGrouping={false}
|
||||||
<div className="stack-sm">
|
emptyMessage="No active sessions right now."
|
||||||
<div className="row row-tight">
|
/>
|
||||||
<h3>{session.display_name}</h3>
|
|
||||||
<div className="row row-tight">
|
|
||||||
{isUnhealthy && (
|
|
||||||
<span className="status-badge error" title={health.error || "unhealthy"}>!</span>
|
|
||||||
)}
|
|
||||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
|
||||||
<p className="muted">{session.tool_type_name}</p>
|
|
||||||
</div>
|
|
||||||
<div className="session-actions">
|
|
||||||
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
Open
|
|
||||||
</button>
|
|
||||||
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Tunnel
|
|
||||||
</button>
|
|
||||||
{stopConfirmId === session.id ? (
|
|
||||||
<div className="stop-confirm-inline">
|
|
||||||
<span className="confirm-text">Stop?</span>
|
|
||||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
|
||||||
<Icon name="stop" size="sm" /> Stop
|
|
||||||
</button>
|
|
||||||
<button className="ghost-button small" type="button" onClick={() => setStopConfirmId(null)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
|
||||||
<Icon name="stop" size="sm" />
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{deleteConfirmId === session.id ? (
|
|
||||||
<div className="delete-confirm-inline">
|
|
||||||
<span className="confirm-text">Delete?</span>
|
|
||||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
|
||||||
<Icon name="delete" size="sm" /> Delete
|
|
||||||
</button>
|
|
||||||
<button className="ghost-button small" type="button" onClick={() => setDeleteConfirmId(null)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card stack home-section">
|
<section className="card stack home-section">
|
||||||
@@ -316,7 +203,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) => (
|
||||||
@@ -350,25 +237,24 @@ export const HomePage = () => {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{recentSessions.length > 0 && (
|
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
|
||||||
<section className="card stack home-section">
|
<section className="card stack home-section">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Recent sessions</p>
|
<p className="eyebrow">Recent sessions</p>
|
||||||
<h2>{recentSessions.length}</h2>
|
<h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="recent-sessions-list">
|
<SessionList
|
||||||
{recentSessions.map((session) => (
|
sessions={safeSessions}
|
||||||
<article className="recent-session-item" key={session.id}>
|
onOpen={handleOpen}
|
||||||
<div className="recent-session-info">
|
onStart={handleStart}
|
||||||
<span className="recent-session-name">{session.display_name}</span>
|
onDelete={handleDelete}
|
||||||
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
|
actionBusyId={actionBusy}
|
||||||
</div>
|
showGrouping={false}
|
||||||
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
maxRecent={5}
|
||||||
</article>
|
emptyMessage="No recent sessions."
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { 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 CommitHistoryResponse } from "../api/git_repositories";
|
||||||
|
import { 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 { 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(() => {
|
||||||
setError(null);
|
if (profile) {
|
||||||
try {
|
setName(profile.name);
|
||||||
const data = await getProfile();
|
setEmail(profile.email);
|
||||||
setProfile(data);
|
setDisplayStatus("ready");
|
||||||
setName(data.name);
|
setError(null);
|
||||||
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,5 +1,7 @@
|
|||||||
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 { Link, useParams, useSearchParams } from "react-router-dom";
|
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ import { WorkspaceHeader } from "../components/workspace-header";
|
|||||||
import { listToolTypes } from "../api/tool_types";
|
import { listToolTypes } from "../api/tool_types";
|
||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
|
|
||||||
|
type MobileTab = "files" | "editor" | "git" | "terminal";
|
||||||
|
|
||||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||||
|
|
||||||
interface FileTreeEntry {
|
interface FileTreeEntry {
|
||||||
@@ -43,6 +47,8 @@ interface Project {
|
|||||||
export const RepoWorkspace = () => {
|
export const RepoWorkspace = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
|
||||||
|
|
||||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
@@ -159,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`}
|
||||||
@@ -190,67 +186,70 @@ export const RepoWorkspace = () => {
|
|||||||
|
|
||||||
{status === "ready" && repositories.length > 0 && (
|
{status === "ready" && repositories.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{selectedRepoId && (
|
{isMobile ? (
|
||||||
<GitToolbar
|
// Mobile Layout
|
||||||
projectId={projectId!}
|
<div className="mobile-workspace">
|
||||||
repoId={selectedRepoId}
|
<div className="mobile-workspace-header">
|
||||||
currentBranch={currentBranch}
|
<select
|
||||||
branches={branches}
|
value={selectedRepoId || ""}
|
||||||
hasRemote={Boolean(selectedRepo?.remote_url)}
|
onChange={(e) => handleRepoChange(e.target.value)}
|
||||||
isMirror={Boolean(selectedRepo?.is_mirror)}
|
className="mobile-repo-selector"
|
||||||
onBranchChange={(branch) => {
|
>
|
||||||
setCurrentBranch(branch);
|
{repositories.map((repo) => (
|
||||||
const newParams = new URLSearchParams(searchParams);
|
<option key={repo.id} value={repo.id}>
|
||||||
newParams.set("branch", branch);
|
{repo.name}
|
||||||
setSearchParams(newParams);
|
</option>
|
||||||
}}
|
))}
|
||||||
onRefresh={() => {
|
</select>
|
||||||
void loadBranches();
|
{selectedRepoId && (
|
||||||
void loadGitStatus();
|
|
||||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="workspace-layout">
|
|
||||||
<aside className="workspace-sidebar">
|
|
||||||
<div className="sidebar-section">
|
|
||||||
<label className="form-field">
|
|
||||||
Repository
|
|
||||||
<select
|
<select
|
||||||
value={selectedRepoId || ""}
|
value={currentBranch}
|
||||||
onChange={(e) => handleRepoChange(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const branch = e.target.value;
|
||||||
|
setCurrentBranch(branch);
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set("branch", branch);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
}}
|
||||||
|
className="mobile-branch-selector"
|
||||||
>
|
>
|
||||||
{repositories.map((repo) => (
|
{branches.map((branch) => (
|
||||||
<option key={repo.id} value={repo.id}>
|
<option key={branch} value={branch}>
|
||||||
{repo.name}
|
{branch}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedRepoId && (
|
<div className="mobile-workspace-content">
|
||||||
<>
|
{mobileTab === "files" && selectedRepoId && (
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
projectId={projectId!}
|
projectId={projectId!}
|
||||||
repoId={selectedRepoId}
|
repoId={selectedRepoId}
|
||||||
gitStatus={gitStatus}
|
gitStatus={gitStatus}
|
||||||
/>
|
/>
|
||||||
{gitStatus && (
|
|
||||||
<CommitPanel
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
modified={gitStatus.modified}
|
|
||||||
added={gitStatus.added}
|
|
||||||
deleted={gitStatus.deleted}
|
|
||||||
untracked={gitStatus.untracked}
|
|
||||||
onCommit={() => {
|
|
||||||
void loadGitStatus();
|
|
||||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{selectedRepoId && (
|
{mobileTab === "editor" && selectedRepoId && (
|
||||||
|
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
||||||
|
)}
|
||||||
|
{mobileTab === "git" && selectedRepoId && gitStatus && (
|
||||||
|
<div className="mobile-git-view">
|
||||||
|
<CommitPanel
|
||||||
|
projectId={projectId!}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
modified={gitStatus.modified}
|
||||||
|
added={gitStatus.added}
|
||||||
|
deleted={gitStatus.deleted}
|
||||||
|
untracked={gitStatus.untracked}
|
||||||
|
onCommit={() => {
|
||||||
|
void loadGitStatus();
|
||||||
|
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mobileTab === "terminal" && selectedRepoId && (
|
||||||
<InstanceList
|
<InstanceList
|
||||||
projectId={projectId!}
|
projectId={projectId!}
|
||||||
repoId={selectedRepoId}
|
repoId={selectedRepoId}
|
||||||
@@ -259,17 +258,126 @@ export const RepoWorkspace = () => {
|
|||||||
toolTypes={toolTypes}
|
toolTypes={toolTypes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="workspace-main">
|
<div className="mobile-workspace-tabs">
|
||||||
{selectedRepoId && (
|
<button
|
||||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
|
||||||
)}
|
onClick={() => setMobileTab("files")}
|
||||||
</main>
|
type="button"
|
||||||
</div>
|
>
|
||||||
</>
|
<Icon name="folder" size="sm" />
|
||||||
|
<span>Files</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
|
||||||
|
onClick={() => setMobileTab("editor")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
<span>Editor</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
|
||||||
|
onClick={() => setMobileTab("git")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="branch" size="sm" />
|
||||||
|
<span>Git</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
|
||||||
|
onClick={() => setMobileTab("terminal")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="terminal" size="sm" />
|
||||||
|
<span>Terminal</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Desktop Layout
|
||||||
|
<>
|
||||||
|
{selectedRepoId && (
|
||||||
|
<GitToolbar
|
||||||
|
projectId={projectId!}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
currentBranch={currentBranch}
|
||||||
|
branches={branches}
|
||||||
|
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||||
|
isMirror={Boolean(selectedRepo?.is_mirror)}
|
||||||
|
onBranchChange={(branch) => {
|
||||||
|
setCurrentBranch(branch);
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set("branch", branch);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
}}
|
||||||
|
onRefresh={() => {
|
||||||
|
void loadBranches();
|
||||||
|
void loadGitStatus();
|
||||||
|
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="workspace-layout">
|
||||||
|
<aside className="workspace-sidebar">
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select
|
||||||
|
value={selectedRepoId || ""}
|
||||||
|
onChange={(e) => handleRepoChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<option key={repo.id} value={repo.id}>
|
||||||
|
{repo.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRepoId && (
|
||||||
|
<>
|
||||||
|
<FileBrowser
|
||||||
|
projectId={projectId!}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
gitStatus={gitStatus}
|
||||||
|
/>
|
||||||
|
{gitStatus && (
|
||||||
|
<CommitPanel
|
||||||
|
projectId={projectId!}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
modified={gitStatus.modified}
|
||||||
|
added={gitStatus.added}
|
||||||
|
deleted={gitStatus.deleted}
|
||||||
|
untracked={gitStatus.untracked}
|
||||||
|
onCommit={() => {
|
||||||
|
void loadGitStatus();
|
||||||
|
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<InstanceList
|
||||||
|
projectId={projectId!}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
projectName={project?.name}
|
||||||
|
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
||||||
|
toolTypes={toolTypes}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="workspace-main">
|
||||||
|
{selectedRepoId && (
|
||||||
|
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -369,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;
|
||||||
|
|||||||
+43
-424
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
@@ -7,20 +6,20 @@ import { listRepositories, type GitRepository } from "../api/git_repositories";
|
|||||||
import {
|
import {
|
||||||
getUserSessions,
|
getUserSessions,
|
||||||
type Session,
|
type Session,
|
||||||
deleteInstance,
|
|
||||||
stopInstance,
|
|
||||||
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 { 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 { SessionCard } from "../components/session-card";
|
||||||
|
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||||
|
import type { InstanceHealth } from "../api/sessions";
|
||||||
|
|
||||||
type SessionsStatus = "loading" | "ready" | "error";
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
export const SessionsPage = () => {
|
export const SessionsPage = () => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||||
@@ -30,25 +29,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 [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
|
||||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
|
||||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, {
|
|
||||||
healthy: boolean;
|
|
||||||
container_status: string;
|
|
||||||
container_health: string | null;
|
|
||||||
tunnel_status: string;
|
|
||||||
tunnel_status_code: number | null;
|
|
||||||
probe_status: string;
|
|
||||||
last_probe_output: string | null;
|
|
||||||
error: string | null;
|
|
||||||
}>>({});
|
|
||||||
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
|
||||||
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
|
|
||||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
|
||||||
const [loadingAction, setLoadingAction] = useState<string>("");
|
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -93,7 +74,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(() => {
|
||||||
@@ -125,7 +117,7 @@ export const SessionsPage = () => {
|
|||||||
probe_status: "unknown",
|
probe_status: "unknown",
|
||||||
last_probe_output: null,
|
last_probe_output: null,
|
||||||
error: "check failed",
|
error: "check failed",
|
||||||
},
|
} as InstanceHealth,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,16 +145,6 @@ export const SessionsPage = () => {
|
|||||||
void loadRepos();
|
void loadRepos();
|
||||||
}, [selectedProject]);
|
}, [selectedProject]);
|
||||||
|
|
||||||
const activeSessions = useMemo(
|
|
||||||
() => sessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)),
|
|
||||||
[sessions]
|
|
||||||
);
|
|
||||||
|
|
||||||
const recentSessions = useMemo(
|
|
||||||
() => sessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5),
|
|
||||||
[sessions]
|
|
||||||
);
|
|
||||||
|
|
||||||
const lastSession = useMemo(
|
const lastSession = useMemo(
|
||||||
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
||||||
[sessions, lastSessionId]
|
[sessions, lastSessionId]
|
||||||
@@ -174,104 +156,15 @@ export const SessionsPage = () => {
|
|||||||
await loadSessions();
|
await loadSessions();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
|
||||||
setLoadingSessionId(sessionId);
|
|
||||||
setLoadingAction("Stopping...");
|
|
||||||
try {
|
|
||||||
await stopInstance(projectId, repoId, sessionId);
|
|
||||||
setStopConfirmId(null);
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
setStopConfirmId(null);
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
setLoadingAction("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (sessionId: string, projectId: string, repoId: string, force = false) => {
|
|
||||||
setLoadingSessionId(sessionId);
|
|
||||||
setLoadingAction("Deleting...");
|
|
||||||
try {
|
|
||||||
await deleteInstance(projectId, repoId, sessionId, force);
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
setDirtyDeleteSession(null);
|
|
||||||
setDirtyDeleteFiles([]);
|
|
||||||
// Remove from local state immediately
|
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
|
||||||
} catch (error) {
|
|
||||||
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
|
||||||
if (axiosError.response?.status === 409) {
|
|
||||||
const detail = axiosError.response.data?.detail;
|
|
||||||
if (detail?.changed_files) {
|
|
||||||
setDirtyDeleteSession(sessions.find((s) => s.id === sessionId) ?? null);
|
|
||||||
setDirtyDeleteFiles(detail.changed_files);
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
setLoadingAction("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRecreateTunnel = async (session: Session) => {
|
|
||||||
setLoadingSessionId(session.id);
|
|
||||||
setLoadingAction("Recreating tunnel...");
|
|
||||||
try {
|
|
||||||
await recreateInstanceTunnel(
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id,
|
|
||||||
session.id
|
|
||||||
);
|
|
||||||
// Refresh sessions to get new URL
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
setLoadingAction("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResumeLast = async () => {
|
|
||||||
if (!lastSession) return;
|
|
||||||
// Find the project and repo IDs
|
|
||||||
const project = projects.find((p) => p.name === lastSession.project_name);
|
|
||||||
if (project) {
|
|
||||||
navigate(`/projects/${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" && (
|
||||||
<>
|
<>
|
||||||
@@ -279,297 +172,30 @@ export const SessionsPage = () => {
|
|||||||
{lastSession && (
|
{lastSession && (
|
||||||
<div className="last-session-section">
|
<div className="last-session-section">
|
||||||
<h2>Last Session</h2>
|
<h2>Last Session</h2>
|
||||||
<div className="card last-session-card">
|
<SessionCard
|
||||||
<div className="last-session-info">
|
session={lastSession}
|
||||||
<h3>{lastSession.display_name}</h3>
|
onOpen={handleOpen}
|
||||||
<p className="muted">
|
onDelete={handleDelete}
|
||||||
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
|
isBusy={loadingSessionId === lastSession.id}
|
||||||
</p>
|
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
||||||
{lastSession.url && (
|
/>
|
||||||
<p className="session-url">
|
|
||||||
<a href={lastSession.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{lastSession.url}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
|
|
||||||
</div>
|
|
||||||
<div className="last-session-actions">
|
|
||||||
{lastSession.url ? (
|
|
||||||
<a
|
|
||||||
href={lastSession.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="primary-button"
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button className="primary-button" onClick={handleResumeLast} type="button">
|
|
||||||
<Icon name="play" size="sm" />
|
|
||||||
Resume
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Sessions */}
|
{/* Session List */}
|
||||||
<div className={`active-sessions-section ${loadingSessionId ? "dimmed" : ""}`}>
|
<div className="sessions-list-wrapper">
|
||||||
{loadingSessionId && (
|
<SessionList
|
||||||
<div className="loading-overlay">
|
sessions={sessions}
|
||||||
<div className="loading-content">
|
onOpen={handleOpen}
|
||||||
<Icon name="loading" size="lg" />
|
onStart={handleStart}
|
||||||
<p>{loadingAction}</p>
|
onStop={handleStop}
|
||||||
</div>
|
onDelete={handleDelete}
|
||||||
</div>
|
onRecreateTunnel={handleRecreateTunnel}
|
||||||
)}
|
actionBusyId={loadingSessionId}
|
||||||
<h2>
|
tunnelHealth={tunnelHealth}
|
||||||
Active Sessions
|
/>
|
||||||
{activeSessions.length > 0 && (
|
|
||||||
<span className="badge">{activeSessions.length}</span>
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
{activeSessions.length === 0 ? (
|
|
||||||
<p className="muted">No active sessions</p>
|
|
||||||
) : (
|
|
||||||
<div className="sessions-grid">
|
|
||||||
{activeSessions.map((session) => {
|
|
||||||
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
|
|
||||||
return (
|
|
||||||
<div className="card session-card" key={session.id}>
|
|
||||||
<div className="session-info">
|
|
||||||
<h4>{session.display_name}</h4>
|
|
||||||
<p className="muted">
|
|
||||||
{session.tool_type_name} · {session.project_name}
|
|
||||||
</p>
|
|
||||||
{session.created_at && (
|
|
||||||
<p className="session-meta">
|
|
||||||
Started: {new Date(session.created_at).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{session.clone_mode && (
|
|
||||||
<p className="session-meta">
|
|
||||||
Repo: {session.clone_mode === "clone" ? `clone (${session.branch || "main"})` : "mount"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{session.url && (
|
|
||||||
<p className="session-url">
|
|
||||||
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{session.url}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
|
||||||
{session.status === "starting" && (
|
|
||||||
<span className="status-badge starting">starting...</span>
|
|
||||||
)}
|
|
||||||
{session.status === "probing" && (
|
|
||||||
<span className="status-badge probing">checking...</span>
|
|
||||||
)}
|
|
||||||
{!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
|
||||||
<span className="status-badge error">tunnel error</span>
|
|
||||||
)}
|
|
||||||
{!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
|
|
||||||
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
|
|
||||||
)}
|
|
||||||
{!isTerminalOnly && tunnelHealth[session.id]?.probe_status && tunnelHealth[session.id]?.probe_status !== "not_applicable" && (
|
|
||||||
<div className="probe-output-section">
|
|
||||||
<button
|
|
||||||
className={`probe-toggle probe-${tunnelHealth[session.id].probe_status}`}
|
|
||||||
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="info" size="sm" />
|
|
||||||
Probe: {tunnelHealth[session.id].probe_status}
|
|
||||||
{expandedProbeId === session.id ? " (hide)" : " (show)"}
|
|
||||||
</button>
|
|
||||||
{expandedProbeId === session.id && tunnelHealth[session.id]?.last_probe_output && (
|
|
||||||
<pre className="probe-output">
|
|
||||||
{tunnelHealth[session.id].last_probe_output}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="session-actions">
|
|
||||||
{session.url ? (
|
|
||||||
<a
|
|
||||||
href={session.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="secondary-button small"
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => handleOpen(session)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
Open
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
|
||||||
<button
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => void handleRecreateTunnel(session)}
|
|
||||||
type="button"
|
|
||||||
disabled={recreatingId === session.id}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
{recreatingId === session.id ? "Recreating..." : "Recreate Tunnel"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{stopConfirmId === session.id ? (
|
|
||||||
<div className="stop-confirm-inline">
|
|
||||||
<span className="confirm-text">Stop?</span>
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={() =>
|
|
||||||
void handleStop(
|
|
||||||
session.id,
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => setStopConfirmId(null)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => setStopConfirmId(session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="stop" size="sm" />
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{deleteConfirmId === session.id ? (
|
|
||||||
<div className="delete-confirm-inline">
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={() =>
|
|
||||||
void handleDelete(
|
|
||||||
session.id,
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => setDeleteConfirmId(null)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button small danger-text"
|
|
||||||
onClick={() => setDeleteConfirmId(session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Sessions */}
|
|
||||||
{recentSessions.length > 0 && (
|
|
||||||
<div className="recent-sessions-section">
|
|
||||||
<h2>Recent Sessions</h2>
|
|
||||||
<div className="recent-sessions-list">
|
|
||||||
{recentSessions.map((session) => (
|
|
||||||
<div className="recent-session-item" key={session.id}>
|
|
||||||
<div className="recent-session-info">
|
|
||||||
<span className="recent-session-name">{session.display_name}</span>
|
|
||||||
<span className="muted">
|
|
||||||
{session.tool_type_name} · {session.project_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="recent-session-actions">
|
|
||||||
{session.url ? (
|
|
||||||
<a
|
|
||||||
href={session.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="ghost-button small"
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => handleOpen(session)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{deleteConfirmId === session.id ? (
|
|
||||||
<div className="delete-confirm-inline">
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={() =>
|
|
||||||
void handleDelete(
|
|
||||||
session.id,
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => setDeleteConfirmId(null)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button small danger-text"
|
|
||||||
onClick={() => setDeleteConfirmId(session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Session */}
|
{/* Create Session */}
|
||||||
<div className="create-session-section">
|
<div className="create-session-section">
|
||||||
<h2>Create New Session</h2>
|
<h2>Create New Session</h2>
|
||||||
@@ -586,7 +212,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>
|
||||||
@@ -605,21 +231,14 @@ 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
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="danger-button"
|
className="danger-button"
|
||||||
onClick={() =>
|
onClick={() => void handleForceDelete(dirtyDeleteSession)}
|
||||||
void handleDelete(
|
|
||||||
dirtyDeleteSession.id,
|
|
||||||
dirtyDeleteSession.project_id,
|
|
||||||
dirtyDeleteSession.repository_id,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Force Delete
|
Force Delete
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,7 +93,7 @@ export const SettingsPage = () => {
|
|||||||
<p className="eyebrow">Configuration</p>
|
<p className="eyebrow">Configuration</p>
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">General preferences and SSH keys.</p>
|
<p className="muted">General preferences, SSH keys, and config profiles.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="settings-tabs" aria-label="Settings sections">
|
<nav className="settings-tabs" aria-label="Settings sections">
|
||||||
|
|||||||
@@ -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, 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,5 +1,12 @@
|
|||||||
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 { extractErrorMessage } from "../utils/errors";
|
||||||
|
import { MobileListView } from "../components/mobile-list-view";
|
||||||
|
import { MobileDetailView } from "../components/mobile-detail-view";
|
||||||
|
import { MobileEditView } from "../components/mobile-edit-view";
|
||||||
|
import { MobileFAB } from "../components/mobile-fab";
|
||||||
import {
|
import {
|
||||||
createToolType,
|
createToolType,
|
||||||
deleteToolType,
|
deleteToolType,
|
||||||
@@ -31,7 +38,11 @@ import {
|
|||||||
type RightPanelTab = "details" | "configs" | "folders";
|
type RightPanelTab = "details" | "configs" | "folders";
|
||||||
type Status = "loading" | "ready" | "error";
|
type Status = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
type MobileView = "list" | "detail" | "edit";
|
||||||
|
|
||||||
export const ToolWorkshopPage = () => {
|
export const ToolWorkshopPage = () => {
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [mobileView, setMobileView] = useState<MobileView>("list");
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||||
@@ -58,6 +69,7 @@ export const ToolWorkshopPage = () => {
|
|||||||
readiness_timeout: "30",
|
readiness_timeout: "30",
|
||||||
readiness_interval: "2",
|
readiness_interval: "2",
|
||||||
required_variables: "",
|
required_variables: "",
|
||||||
|
startup_command: "",
|
||||||
});
|
});
|
||||||
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
||||||
const [toolTypeDirty, setToolTypeDirty] = useState(false);
|
const [toolTypeDirty, setToolTypeDirty] = useState(false);
|
||||||
@@ -131,6 +143,7 @@ export const ToolWorkshopPage = () => {
|
|||||||
readiness_timeout: "30",
|
readiness_timeout: "30",
|
||||||
readiness_interval: "2",
|
readiness_interval: "2",
|
||||||
required_variables: "",
|
required_variables: "",
|
||||||
|
startup_command: "",
|
||||||
});
|
});
|
||||||
setToolTypeError(null);
|
setToolTypeError(null);
|
||||||
setToolTypeDirty(false);
|
setToolTypeDirty(false);
|
||||||
@@ -152,6 +165,7 @@ export const ToolWorkshopPage = () => {
|
|||||||
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
|
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
|
||||||
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
|
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
|
||||||
required_variables: toolType.required_variables?.join(", ") || "",
|
required_variables: toolType.required_variables?.join(", ") || "",
|
||||||
|
startup_command: toolType.startup_command || "",
|
||||||
});
|
});
|
||||||
setToolTypeError(null);
|
setToolTypeError(null);
|
||||||
setToolTypeDirty(false);
|
setToolTypeDirty(false);
|
||||||
@@ -189,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);
|
||||||
@@ -250,6 +254,7 @@ export const ToolWorkshopPage = () => {
|
|||||||
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
||||||
readiness_probe: readinessProbe,
|
readiness_probe: readinessProbe,
|
||||||
required_variables: variables,
|
required_variables: variables,
|
||||||
|
startup_command: toolTypeForm.startup_command.trim() || undefined,
|
||||||
};
|
};
|
||||||
const newTool = await createToolType(input);
|
const newTool = await createToolType(input);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
@@ -268,6 +273,7 @@ export const ToolWorkshopPage = () => {
|
|||||||
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
||||||
readiness_probe: readinessProbe,
|
readiness_probe: readinessProbe,
|
||||||
required_variables: variables,
|
required_variables: variables,
|
||||||
|
startup_command: toolTypeForm.startup_command.trim() || undefined,
|
||||||
};
|
};
|
||||||
await updateToolType(selectedToolType.id, input);
|
await updateToolType(selectedToolType.id, input);
|
||||||
setToolTypeDirty(false);
|
setToolTypeDirty(false);
|
||||||
@@ -486,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -494,14 +500,264 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
if (mobileView === "list") {
|
||||||
|
return (
|
||||||
|
<div className="mobile-page">
|
||||||
|
<div className="mobile-page-header">
|
||||||
|
<h1>Tool Workshop</h1>
|
||||||
|
<span className="muted">{toolTypes.length} tool types</span>
|
||||||
|
</div>
|
||||||
|
<MobileListView
|
||||||
|
items={toolTypes.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.display_name,
|
||||||
|
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
|
||||||
|
}))}
|
||||||
|
onItemClick={(id) => {
|
||||||
|
setSelectedToolTypeId(id);
|
||||||
|
setIsCreating(false);
|
||||||
|
setMobileView("detail");
|
||||||
|
}}
|
||||||
|
emptyMessage="No tool types yet"
|
||||||
|
/>
|
||||||
|
<MobileFAB onClick={() => {
|
||||||
|
setSelectedToolTypeId(null);
|
||||||
|
setIsCreating(true);
|
||||||
|
resetToolTypeForm();
|
||||||
|
setMobileView("edit");
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileView === "detail" && selectedToolType) {
|
||||||
|
return (
|
||||||
|
<MobileDetailView
|
||||||
|
title={selectedToolType.display_name}
|
||||||
|
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type} · ${selectedToolType.interface_type === "web" ? `Port ${selectedToolType.default_port}` : "Terminal"}`}
|
||||||
|
fields={[
|
||||||
|
{ label: "Name", value: selectedToolType.name },
|
||||||
|
{ label: "Display Name", value: selectedToolType.display_name },
|
||||||
|
{ label: "Description", value: selectedToolType.description },
|
||||||
|
{ label: "Category", value: selectedToolType.category },
|
||||||
|
{ label: "Interface Type", value: selectedToolType.interface_type },
|
||||||
|
{ label: "Requires Port", value: selectedToolType.requires_port, type: "boolean" },
|
||||||
|
{ label: "Default Port", value: selectedToolType.default_port },
|
||||||
|
{ label: "Definition Type", value: selectedToolType.definition_type },
|
||||||
|
{ label: "Startup Command", value: selectedToolType.startup_command },
|
||||||
|
{ label: "Readiness Command", value: selectedToolType.readiness_probe?.command ?? null },
|
||||||
|
{ label: "Readiness Timeout", value: selectedToolType.readiness_probe?.timeout ?? null },
|
||||||
|
{ label: "Readiness Interval", value: selectedToolType.readiness_probe?.interval ?? null },
|
||||||
|
{ label: "Required Variables", value: selectedToolType.required_variables?.join(", ") ?? null },
|
||||||
|
{ label: "Compose Template", value: selectedToolType.compose_template, type: "code" },
|
||||||
|
{ label: "Dockerfile Template", value: selectedToolType.dockerfile_template, type: "code" },
|
||||||
|
]}
|
||||||
|
onEdit={() => {
|
||||||
|
populateToolTypeForm(selectedToolType);
|
||||||
|
setIsCreating(false);
|
||||||
|
setMobileView("edit");
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
handleDeleteToolType(selectedToolType.id);
|
||||||
|
setMobileView("list");
|
||||||
|
}}
|
||||||
|
onBack={() => {
|
||||||
|
setSelectedToolTypeId(null);
|
||||||
|
setMobileView("list");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileView === "edit") {
|
||||||
|
return (
|
||||||
|
<MobileEditView
|
||||||
|
title={isCreating ? "Create Tool Type" : "Edit Tool Type"}
|
||||||
|
onCancel={() => {
|
||||||
|
if (toolTypeDirty) {
|
||||||
|
if (!window.confirm("You have unsaved changes. Discard them?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMobileView(isCreating ? "list" : "detail");
|
||||||
|
}}
|
||||||
|
onSave={() => {
|
||||||
|
// Create a synthetic form event to call handleToolTypeSubmit
|
||||||
|
const syntheticEvent = { preventDefault: () => {} } as React.FormEvent;
|
||||||
|
void handleToolTypeSubmit(syntheticEvent);
|
||||||
|
if (!toolTypeError) {
|
||||||
|
setMobileView("list");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isSaving={false}
|
||||||
|
>
|
||||||
|
{/* Tool Type Form Fields */}
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.name}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, name: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="e.g., my-tool"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Display Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.display_name}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, display_name: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="e.g., My Tool"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={toolTypeForm.description}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, description: e.target.value })}
|
||||||
|
className="mobile-form-textarea"
|
||||||
|
placeholder="What does this tool do?"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Category</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.category}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, category: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="e.g., development"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Interface Type</label>
|
||||||
|
<select
|
||||||
|
value={toolTypeForm.interface_type}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, interface_type: e.target.value as "web" | "terminal" })}
|
||||||
|
className="mobile-form-select"
|
||||||
|
>
|
||||||
|
<option value="web">Web</option>
|
||||||
|
<option value="terminal">Terminal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Requires Port</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toolTypeForm.requires_port}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, requires_port: e.target.checked })}
|
||||||
|
className="mobile-form-checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Default Port</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.default_port}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, default_port: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="e.g., 8080"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Definition Type</label>
|
||||||
|
<select
|
||||||
|
value={toolTypeForm.definition_type}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, definition_type: e.target.value as "compose" | "dockerfile" })}
|
||||||
|
className="mobile-form-select"
|
||||||
|
>
|
||||||
|
<option value="compose">Compose</option>
|
||||||
|
<option value="dockerfile">Dockerfile</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Startup Command</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.startup_command}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, startup_command: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="Command to run on startup"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Readiness Command</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.readiness_command}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="e.g., curl -f http://localhost:8080/health"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Readiness Timeout</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.readiness_timeout}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Readiness Interval</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.readiness_interval}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Required Variables</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.required_variables}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, required_variables: e.target.value })}
|
||||||
|
className="mobile-form-input"
|
||||||
|
placeholder="VAR1, VAR2, VAR3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{toolTypeForm.definition_type === "compose" && (
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Compose Template</label>
|
||||||
|
<textarea
|
||||||
|
value={toolTypeForm.compose_template}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, compose_template: e.target.value })}
|
||||||
|
className="mobile-form-textarea mobile-form-code"
|
||||||
|
placeholder="version: '3'"
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolTypeForm.definition_type === "dockerfile" && (
|
||||||
|
<div className="mobile-form-group">
|
||||||
|
<label className="mobile-form-label">Dockerfile Template</label>
|
||||||
|
<textarea
|
||||||
|
value={toolTypeForm.dockerfile_template}
|
||||||
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, dockerfile_template: e.target.value })}
|
||||||
|
className="mobile-form-textarea mobile-form-code"
|
||||||
|
placeholder="FROM ubuntu:22.04"
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MobileEditView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ display: "flex", height: "calc(100vh - 4rem)", gap: 0, padding: 0 }}>
|
<div className="container" style={{ display: "flex", height: "calc(100vh - 4rem)", gap: 0, padding: 0 }}>
|
||||||
{/* Left Sidebar - Tool List */}
|
{/* Left Sidebar - Tool List */}
|
||||||
@@ -772,6 +1028,24 @@ export const ToolWorkshopPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{toolTypeForm.interface_type === "terminal" && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="tool-type-startup-command">Startup Command</label>
|
||||||
|
<input
|
||||||
|
id="tool-type-startup-command"
|
||||||
|
type="text"
|
||||||
|
value={toolTypeForm.startup_command}
|
||||||
|
onChange={(e) => {
|
||||||
|
setToolTypeForm({ ...toolTypeForm, startup_command: e.target.value });
|
||||||
|
setToolTypeDirty(true);
|
||||||
|
}}
|
||||||
|
placeholder="e.g., cd /workspace && ls"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<small className="form-help">Command to run before the interactive shell for each new terminal session.</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{toolTypeForm.requires_port && (
|
{toolTypeForm.requires_port && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="tool-type-default-port">Default Port *</label>
|
<label htmlFor="tool-type-default-port">Default Port *</label>
|
||||||
@@ -1029,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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
|||||||
import { TerminalPage } from "./pages/terminal";
|
import { TerminalPage } from "./pages/terminal";
|
||||||
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||||
|
import { ConfigProfilesPage } from "./pages/config-profiles";
|
||||||
import { SessionsPage } from "./pages/sessions";
|
import { SessionsPage } from "./pages/sessions";
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
@@ -36,6 +37,7 @@ export const AppRouter = () => {
|
|||||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
||||||
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />}>
|
<Route path="settings" element={<SettingsPage />}>
|
||||||
<Route index element={<Navigate to="general" replace />} />
|
<Route index element={<Navigate to="general" replace />} />
|
||||||
<Route path="general" element={<GeneralSettingsTab />} />
|
<Route path="general" element={<GeneralSettingsTab />} />
|
||||||
|
|||||||
+851
-30
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||||
|
};
|
||||||
@@ -33,6 +33,7 @@ All responses are JSON. Error responses follow this format:
|
|||||||
- [Auth](auth.md) - Authentication endpoints
|
- [Auth](auth.md) - Authentication endpoints
|
||||||
- [Projects](projects.md) - Project management
|
- [Projects](projects.md) - Project management
|
||||||
- [Repositories](repositories.md) - Git repositories and file operations
|
- [Repositories](repositories.md) - Git repositories and file operations
|
||||||
|
- [Config Profiles](config-profiles.md) - Config profile management with git mounts
|
||||||
- [Users](users.md) - User management and settings
|
- [Users](users.md) - User management and settings
|
||||||
- [Tool Types](tool-types.md) - Tool type management
|
- [Tool Types](tool-types.md) - Tool type management
|
||||||
- [SSH Keys](ssh-keys.md) - SSH key management
|
- [SSH Keys](ssh-keys.md) - SSH key management
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Config Profiles
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Config profiles allow users to define reusable configuration sets for tool instances. Profiles can include environment variables, files, mounts, and git repository mounts. They support profile includes for composition and can be scoped to specific projects or tool types.
|
||||||
|
|
||||||
|
## Git Mounts
|
||||||
|
|
||||||
|
Git mounts allow you to mount files or directories from git repositories into tool instances at startup.
|
||||||
|
|
||||||
|
### Git Mount Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app/config",
|
||||||
|
"branch": "main"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `repo_id` | string (UUID) | Yes | ID of the git repository to mount from |
|
||||||
|
| `source_path` | string | No | Path within the repository (default: "."). Supports glob patterns like "*.json" or "configs/**" |
|
||||||
|
| `target_path` | string | Yes | Absolute path inside the container where files will be mounted |
|
||||||
|
| `branch` | string | No | Branch or tag to checkout before mounting (default: current branch) |
|
||||||
|
|
||||||
|
### Path Validation
|
||||||
|
|
||||||
|
- `source_path`: Must be relative (no leading `/`). Cannot contain `..` (path traversal)
|
||||||
|
- `target_path`: Must be absolute (starts with `/`). Cannot contain `..`
|
||||||
|
|
||||||
|
### Glob Patterns
|
||||||
|
|
||||||
|
The `source_path` supports standard glob patterns:
|
||||||
|
|
||||||
|
- `*.json` - Match all JSON files in root
|
||||||
|
- `configs/**` - Match all files in configs directory recursively
|
||||||
|
- `src/*.py` - Match all Python files in src directory
|
||||||
|
- `.` - Mount entire repository (default)
|
||||||
|
|
||||||
|
**Limits:**
|
||||||
|
- Maximum 100 matches per glob pattern
|
||||||
|
- Only matches within the repository boundary
|
||||||
|
|
||||||
|
### Branch Behavior
|
||||||
|
|
||||||
|
When a `branch` is specified:
|
||||||
|
|
||||||
|
1. System attempts to checkout the branch in the existing clone
|
||||||
|
2. If branch doesn't exist locally, attempts to fetch from remote and checkout
|
||||||
|
3. If checkout fails, logs warning and continues with current branch
|
||||||
|
4. No branch specified: uses current checked-out branch
|
||||||
|
|
||||||
|
**Auto-clone:** If repository is not cloned locally, the system will automatically clone it using the repository's configured SSH key.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### List Config Profiles
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /config-profiles
|
||||||
|
```
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- `project_id` (optional): Filter by project compatibility
|
||||||
|
- `tool_type_id` (optional): Filter by tool type compatibility
|
||||||
|
|
||||||
|
Response includes `git_mounts` array in each profile.
|
||||||
|
|
||||||
|
### Create Config Profile
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /config-profiles
|
||||||
|
```
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "My Profile",
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"source_path": "configs/*.json",
|
||||||
|
"target_path": "/app/config",
|
||||||
|
"branch": "main"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- All referenced repositories must exist
|
||||||
|
- Repositories must belong to the same project (if profile has project_id)
|
||||||
|
- source_path and target_path must pass path validation
|
||||||
|
|
||||||
|
### Update Config Profile
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /config-profiles/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same request body as create. Partial updates supported (omit fields to keep current values).
|
||||||
|
|
||||||
|
### Preview Resolved Profile
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /config-profiles/{id}/preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the fully resolved profile with all includes merged. Git mounts from included profiles are merged with override rules (later profiles override earlier ones with same repo_id + target_path combo).
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profile_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"profile_name": "My Profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"runtime_hints": {},
|
||||||
|
"mounts": [],
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"source_path": "configs/*.json",
|
||||||
|
"target_path": "/app/config",
|
||||||
|
"branch": "main"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": {},
|
||||||
|
"overrides": {
|
||||||
|
"env_vars": {},
|
||||||
|
"runtime_hints": {},
|
||||||
|
"files": {},
|
||||||
|
"mounts": {}
|
||||||
|
},
|
||||||
|
"included_profiles": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Git mount errors during instance startup are non-blocking:
|
||||||
|
- Missing repository: Mount skipped, warning logged
|
||||||
|
- Clone failure: Mount skipped, warning logged
|
||||||
|
- Invalid paths: Mount skipped, warning logged
|
||||||
|
- Branch checkout failure: Falls back to current branch, warning logged
|
||||||
|
|
||||||
|
Instance startup continues normally even if some git mounts fail.
|
||||||
|
|
||||||
|
## Profile Resolution
|
||||||
|
|
||||||
|
When a profile includes other profiles, git mounts are merged:
|
||||||
|
- Same `repo_id` + `target_path` combo: later profile overrides
|
||||||
|
- Different combos: both are kept
|
||||||
|
- Branch conflicts: later profile wins
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
Base Profile: git_mounts = [{repo_a, /app, main}]
|
||||||
|
Included Profile: git_mounts = [{repo_a, /app, develop}, {repo_b, /data}]
|
||||||
|
Resolved: git_mounts = [{repo_a, /app, develop}, {repo_b, /data}]
|
||||||
|
```
|
||||||
@@ -8,6 +8,73 @@ All endpoints require authentication (session cookie).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GET /repositories
|
||||||
|
|
||||||
|
**Description:** List all repositories owned by the user, including external repositories not tied to any project.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "my-external-repo",
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"is_mirror": false,
|
||||||
|
"project_id": null,
|
||||||
|
"owner_id": "uuid",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /repositories
|
||||||
|
|
||||||
|
**Description:** Create a new external repository (not tied to any project). External repositories can be used across all projects for config profile git mounts.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-external-repo",
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"ssh_key_id": "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `name` | `string` | Yes | Repository name (unique per user for external repos) |
|
||||||
|
| `remote_url` | `string` | No | Remote URL to clone from |
|
||||||
|
| `ssh_key_id` | `string` | No | SSH key ID for authentication |
|
||||||
|
| `force_original_url` | `boolean` | No | Skip URL parsing (default: false) |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (201 Created)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "my-external-repo",
|
||||||
|
"path": "/data/repos/external/{user_id}/{repo_id}",
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"is_mirror": false,
|
||||||
|
"project_id": null,
|
||||||
|
"owner_id": "uuid",
|
||||||
|
"ssh_key_id": "uuid",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## GET /projects/{project_id}/repositories
|
## GET /projects/{project_id}/repositories
|
||||||
|
|
||||||
**Description:** List repositories in a project.
|
**Description:** List repositories in a project.
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Terminal API
|
||||||
|
|
||||||
|
The Terminal API provides WebSocket-based terminal access to running tool instances.
|
||||||
|
|
||||||
|
## WebSocket Endpoint
|
||||||
|
|
||||||
|
### Connect to Terminal
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /ws/tool-instances/{instance_id}/terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Establishes a WebSocket connection to an interactive terminal session inside a running tool instance container.
|
||||||
|
|
||||||
|
**Authentication:** Requires valid session cookie.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `instance_id` (string, UUID): The tool instance ID
|
||||||
|
|
||||||
|
**Connection Flow:**
|
||||||
|
1. Client connects to WebSocket endpoint
|
||||||
|
2. Server authenticates user and verifies instance ownership
|
||||||
|
3. Server creates or reattaches to existing terminal session
|
||||||
|
4. Server sends `{"type": "status", "status": "connected"}` message
|
||||||
|
5. Bidirectional communication begins
|
||||||
|
|
||||||
|
**Message Types:**
|
||||||
|
|
||||||
|
#### Client to Server
|
||||||
|
|
||||||
|
**Terminal Input (bytes or string)**
|
||||||
|
- Send raw bytes for terminal input (key presses)
|
||||||
|
- Send text for terminal input (will be encoded as UTF-8)
|
||||||
|
|
||||||
|
**Resize Command (JSON)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "resize",
|
||||||
|
"cols": 80,
|
||||||
|
"rows": 24
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reset Command (JSON)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "reset"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Kills the current terminal session and starts a fresh one.
|
||||||
|
|
||||||
|
**Pong Response (JSON)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "pong"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Sent automatically in response to server ping messages.
|
||||||
|
|
||||||
|
#### Server to Client
|
||||||
|
|
||||||
|
**Terminal Output (bytes)**
|
||||||
|
Raw terminal output as binary data (Blob in browser).
|
||||||
|
|
||||||
|
**Status Messages (JSON)**
|
||||||
|
```json
|
||||||
|
{"type": "status", "status": "connected"}
|
||||||
|
{"type": "status", "status": "resetting"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ping Messages (JSON)**
|
||||||
|
```json
|
||||||
|
{"type": "ping"}
|
||||||
|
```
|
||||||
|
Sent every 30 seconds to detect disconnections. Client should respond with `{"type": "pong"}`.
|
||||||
|
|
||||||
|
### Session Persistence
|
||||||
|
|
||||||
|
Terminal sessions persist across WebSocket disconnections:
|
||||||
|
- When a client disconnects, the terminal session remains active
|
||||||
|
- On reconnection, the client reattaches to the existing session
|
||||||
|
- Buffered output is replayed to the client on reconnection
|
||||||
|
- Sessions are cleaned up after 30 minutes of inactivity
|
||||||
|
|
||||||
|
### Concurrent Connections
|
||||||
|
|
||||||
|
Only one WebSocket connection is allowed per terminal session:
|
||||||
|
- New connections close existing connections with code 4000
|
||||||
|
- Previous client receives "New connection established" reason
|
||||||
|
|
||||||
|
## HTTP Endpoints
|
||||||
|
|
||||||
|
### Reset Terminal Session
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset
|
||||||
|
```
|
||||||
|
|
||||||
|
Resets the terminal session for a tool instance, killing the current shell and starting fresh.
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `project_id` (string, UUID): Project ID
|
||||||
|
- `repo_id` (string, UUID): Repository ID
|
||||||
|
- `instance_id` (string, UUID): Instance ID
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Terminal session reset successfully",
|
||||||
|
"instance_id": "...",
|
||||||
|
"session_id": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `404 Not Found`: Instance not found
|
||||||
|
- `400 Bad Request`: Instance is not running
|
||||||
|
- `500 Internal Server Error`: Failed to reset terminal session
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
WebSocket close codes:
|
||||||
|
- `1000`: Normal closure
|
||||||
|
- `4000`: Error/reset
|
||||||
|
- `4001`: Invalid instance ID
|
||||||
|
- `4003`: Unauthorized/Forbidden
|
||||||
|
- `4004`: Instance not found or not running
|
||||||
|
|
||||||
|
## Heartbeat
|
||||||
|
|
||||||
|
The server sends ping messages every 30 seconds. If no ping is received for 60 seconds, the client should assume the connection is dead and reconnect.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user