Compare commits
197 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 | |||
| ed15d53493 | |||
| ffb7ef0d21 | |||
| e265c86997 | |||
| 23c876b558 | |||
| 74c65068c8 | |||
| 48277369f2 | |||
| 5f39267781 | |||
| caf11cdb8a | |||
| 2f44306089 | |||
| 6173d42ddf | |||
| 9afd559394 | |||
| 25da0c149a | |||
| 457f0d29ae | |||
| dfbd3e60a8 | |||
| 075eb6a76b | |||
| 4571bebf8e | |||
| ec45283257 | |||
| cce3fa773a | |||
| 50474f7b13 | |||
| 9c7043bab1 | |||
| 2dd2f2ab06 | |||
| e555561a2d | |||
| 4ac3d593aa | |||
| 1a3860a4d8 | |||
| 3d9ff44d1a | |||
| 12378def4d | |||
| f6f7853aa4 | |||
| 802d8f1e8c |
@@ -1,2 +1,2 @@
|
||||
84102
|
||||
1779616938758
|
||||
262629
|
||||
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-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 alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0014_merge_heads"
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
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'")
|
||||
)
|
||||
@@ -10,14 +10,14 @@ import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_05_23_remove_is_builtin'
|
||||
down_revision = '2026_05_22_add_clone_mode'
|
||||
down_revision = 'f3d2dc90ba3a'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the is_builtin column from tool_types
|
||||
op.drop_column('tool_types', 'is_builtin')
|
||||
op.execute("ALTER TABLE tool_types DROP COLUMN IF EXISTS is_builtin")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,23 @@
|
||||
"""merge_single_interface_and_clone_mode
|
||||
|
||||
Revision ID: f3d2dc90ba3a
|
||||
Revises: 0015_single_interface, 2026_05_22_add_clone_mode
|
||||
Create Date: 2026-05-24 10:43:14.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f3d2dc90ba3a"
|
||||
down_revision: Union[str, Sequence[str], None] = ("0015_single_interface", "2026_05_22_add_clone_mode")
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
+10
-10
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
|
||||
redirect_uri=redirect_uri,
|
||||
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.set_cookie("auth_state", state, 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="/"),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> 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:
|
||||
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
||||
@@ -71,7 +71,7 @@ async def callback(
|
||||
|
||||
settings = Settings()
|
||||
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:
|
||||
try:
|
||||
@@ -92,7 +92,7 @@ async def callback(
|
||||
access_token=token_payload["access_token"],
|
||||
client=client,
|
||||
)
|
||||
logger.info("User info fetched successfully")
|
||||
logger.debug("User info fetched successfully")
|
||||
except Exception as 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")
|
||||
@@ -100,19 +100,19 @@ async def callback(
|
||||
authentik_id = str(user_info.get("sub", ""))
|
||||
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||
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:
|
||||
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||
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)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
logger.info("New user created: id=%s", user.id)
|
||||
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.name = name
|
||||
await session.commit()
|
||||
@@ -165,20 +165,20 @@ async def me(
|
||||
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> 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:
|
||||
logger.warning("Auth /me: missing session cookie")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||
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:
|
||||
logger.warning("Auth /me: invalid session: %s", 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.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.models.config_folder import ConfigFolder
|
||||
|
||||
@@ -15,9 +16,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
||||
|
||||
MAX_FOLDER_SIZE_MB = 10
|
||||
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||
|
||||
|
||||
class ConfigFolderCreate(BaseModel):
|
||||
name: str = Field(description="Folder name (unique per user)")
|
||||
@@ -28,24 +26,12 @@ class ConfigFolderCreate(BaseModel):
|
||||
@field_validator("mount_path")
|
||||
@classmethod
|
||||
def validate_mount_path(cls, v: str) -> str:
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount path must be absolute (start with /)")
|
||||
return v
|
||||
return _validate_mount_path(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict) -> dict:
|
||||
total_size = 0
|
||||
for path, content in v.items():
|
||||
# Check for path traversal
|
||||
if ".." in path or path.startswith("/"):
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
total_size += len(content.encode("utf-8"))
|
||||
|
||||
if total_size > MAX_FOLDER_SIZE_BYTES:
|
||||
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
||||
|
||||
return v
|
||||
return _validate_files(v)
|
||||
|
||||
|
||||
class ConfigFolderUpdate(BaseModel):
|
||||
@@ -58,29 +44,12 @@ class ConfigFolderUpdate(BaseModel):
|
||||
@field_validator("mount_path")
|
||||
@classmethod
|
||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount path must be absolute (start with /)")
|
||||
return v
|
||||
return _validate_mount_path(v)
|
||||
|
||||
@field_validator("files")
|
||||
@classmethod
|
||||
def validate_files(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
total_size = 0
|
||||
for path, content in v.items():
|
||||
# Check for path traversal
|
||||
if ".." in path or path.startswith("/"):
|
||||
raise ValueError(f"Invalid file path: {path}")
|
||||
total_size += len(content.encode("utf-8"))
|
||||
|
||||
if total_size > MAX_FOLDER_SIZE_BYTES:
|
||||
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
||||
|
||||
return v
|
||||
return _validate_files(v)
|
||||
|
||||
|
||||
class ProjectOverrideCreate(BaseModel):
|
||||
@@ -90,11 +59,7 @@ class ProjectOverrideCreate(BaseModel):
|
||||
@field_validator("mount_path")
|
||||
@classmethod
|
||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Mount path must be absolute (start with /)")
|
||||
return v
|
||||
return _validate_mount_path(v)
|
||||
|
||||
|
||||
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.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.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
from src.utils.git_files import (
|
||||
commit_file,
|
||||
get_file_content,
|
||||
@@ -42,40 +40,6 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||
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:
|
||||
"""Generate the filesystem path for a repository.
|
||||
|
||||
@@ -256,7 +220,7 @@ class GitRepositoryResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
path: str
|
||||
project_id: uuid.UUID
|
||||
project_id: uuid.UUID | None
|
||||
owner_id: uuid.UUID
|
||||
is_mirror: bool
|
||||
remote_url: str | None
|
||||
@@ -266,6 +230,156 @@ class GitRepositoryResponse(BaseModel):
|
||||
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(
|
||||
"/{project_id}/repositories",
|
||||
response_model=list[GitRepositoryResponse],
|
||||
@@ -335,25 +449,6 @@ async def delete_repository(
|
||||
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(
|
||||
"/{project_id}/repositories",
|
||||
response_model=GitRepositoryResponse,
|
||||
|
||||
@@ -4,11 +4,10 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
|
||||
@@ -7,23 +7,14 @@ from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
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.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
"""Fetch a user by ID or raise 401 if not found."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
return user
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
@@ -132,32 +123,6 @@ async def get_project(
|
||||
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(
|
||||
"/{project_id}",
|
||||
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.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.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
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:
|
||||
"""Generate a valid Fernet key from the session secret."""
|
||||
import base64
|
||||
|
||||
+212
-12
@@ -4,17 +4,25 @@ import asyncio
|
||||
import logging
|
||||
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 src.auth.dependencies import get_db_session
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.terminal_manager import terminal_manager
|
||||
|
||||
router = APIRouter()
|
||||
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(
|
||||
"/ws/tool-instances/{instance_id}/terminal",
|
||||
)
|
||||
@@ -26,6 +34,7 @@ async def terminal_websocket(
|
||||
"""WebSocket endpoint for terminal access to a tool instance.
|
||||
|
||||
Provides an interactive terminal session inside a running tool instance container.
|
||||
Sessions persist across WebSocket disconnections.
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection.
|
||||
@@ -35,8 +44,9 @@ async def terminal_websocket(
|
||||
Returns:
|
||||
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()
|
||||
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||
|
||||
try:
|
||||
# Parse instance_id
|
||||
@@ -70,32 +80,222 @@ async def terminal_websocket(
|
||||
await websocket.close(code=4004, reason="Instance not running")
|
||||
return
|
||||
|
||||
logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id)
|
||||
# Create terminal session
|
||||
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||
|
||||
# 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:
|
||||
session = await terminal_manager.create_session(
|
||||
session = await terminal_manager.get_or_create_session(
|
||||
instance_uuid,
|
||||
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
|
||||
await websocket.send_json({"type": "status", "status": "connected"})
|
||||
logger.debug("Sent connected status for instance %s", instance_id)
|
||||
|
||||
# Keep connection alive until session ends
|
||||
# The terminal_manager handles I/O loops, we just wait here
|
||||
while session.is_alive() and not session._closed:
|
||||
await asyncio.sleep(0.5)
|
||||
# Use mutable session reference so loops can survive reset
|
||||
session_ref = SessionRef(session)
|
||||
|
||||
# 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:
|
||||
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}")
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
websocket: WebSocket,
|
||||
db_session: AsyncSession,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tool configuration API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -8,12 +7,11 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
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.models.tool_config import ToolConfig
|
||||
from src.models.tool_type import ToolType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||
|
||||
|
||||
@@ -42,27 +40,12 @@ class ToolConfigCreate(BaseModel):
|
||||
@field_validator("environment_variables")
|
||||
@classmethod
|
||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("environment_variables must be a JSON object")
|
||||
return v
|
||||
return _validate_env_vars(v)
|
||||
|
||||
@field_validator("volumes")
|
||||
@classmethod
|
||||
def validate_volumes(cls, v: list | None) -> list | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("volumes must be a JSON array")
|
||||
for i, vol in enumerate(v):
|
||||
if not isinstance(vol, dict):
|
||||
raise ValueError(f"Volume at index {i} must be an object")
|
||||
if "source" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||
if "target" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||
return v
|
||||
return _validate_volumes(v)
|
||||
|
||||
|
||||
class ToolConfigUpdate(BaseModel):
|
||||
@@ -88,27 +71,12 @@ class ToolConfigUpdate(BaseModel):
|
||||
@field_validator("environment_variables")
|
||||
@classmethod
|
||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("environment_variables must be a JSON object")
|
||||
return v
|
||||
return _validate_env_vars(v)
|
||||
|
||||
@field_validator("volumes")
|
||||
@classmethod
|
||||
def validate_volumes(cls, v: list | None) -> list | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not isinstance(v, list):
|
||||
raise ValueError("volumes must be a JSON array")
|
||||
for i, vol in enumerate(v):
|
||||
if not isinstance(vol, dict):
|
||||
raise ValueError(f"Volume at index {i} must be an object")
|
||||
if "source" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||
if "target" not in vol:
|
||||
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||
return v
|
||||
return _validate_volumes(v)
|
||||
|
||||
|
||||
class ToolConfigResponse(BaseModel):
|
||||
|
||||
+838
-203
File diff suppressed because it is too large
Load Diff
+35
-131
@@ -1,33 +1,23 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
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)
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.api.tool_types_validation import (
|
||||
check_port_exposed,
|
||||
validate_compose_yaml,
|
||||
validate_required_variables,
|
||||
)
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
|
||||
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:
|
||||
"""Check if user has admin privileges.
|
||||
|
||||
@@ -49,6 +39,7 @@ class ToolTypeCreate(BaseModel):
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
startup_command: str | None = None
|
||||
required_variables: list[str] = []
|
||||
category: str = "other"
|
||||
interface_type: str = "web"
|
||||
@@ -71,24 +62,7 @@ class ToolTypeCreate(BaseModel):
|
||||
if v is None:
|
||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||
|
||||
# Replace template variables with dummy values before YAML validation
|
||||
# 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")
|
||||
|
||||
validate_compose_yaml(v)
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@@ -155,29 +129,11 @@ class ToolTypeCreate(BaseModel):
|
||||
# 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:
|
||||
try:
|
||||
sanitized = _sanitize_template_vars(self.compose_template)
|
||||
parsed = yaml.safe_load(sanitized)
|
||||
except yaml.YAMLError:
|
||||
parsed = validate_compose_yaml(self.compose_template)
|
||||
except ValueError:
|
||||
return self
|
||||
|
||||
port_str = str(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:
|
||||
if not check_port_exposed(parsed, self.default_port):
|
||||
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
|
||||
|
||||
return self
|
||||
@@ -192,6 +148,7 @@ class ToolTypeUpdate(BaseModel):
|
||||
dockerfile_template: str | None = None
|
||||
build_context: dict | None = None
|
||||
readiness_probe: dict | None = None
|
||||
startup_command: str | None = None
|
||||
required_variables: list[str] | None = None
|
||||
category: str | None = None
|
||||
interface_type: str | None = None
|
||||
@@ -220,29 +177,13 @@ class ToolTypeUpdate(BaseModel):
|
||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
|
||||
data = info.data
|
||||
definition_type = data.get("definition_type")
|
||||
if definition_type and definition_type != "compose":
|
||||
return v
|
||||
|
||||
# Replace template variables with dummy values before YAML validation
|
||||
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")
|
||||
|
||||
|
||||
validate_compose_yaml(v)
|
||||
return v
|
||||
|
||||
@field_validator("dockerfile_template")
|
||||
@@ -278,6 +219,7 @@ class ToolTypeResponse(BaseModel):
|
||||
dockerfile_template: str | None
|
||||
build_context: dict | None
|
||||
readiness_probe: dict | None
|
||||
startup_command: str | None
|
||||
required_variables: list[str]
|
||||
created_by_id: uuid.UUID | None
|
||||
created_at: datetime
|
||||
@@ -324,6 +266,7 @@ async def create_tool_type(
|
||||
dockerfile_template=data.dockerfile_template,
|
||||
build_context=data.build_context,
|
||||
readiness_probe=data.readiness_probe,
|
||||
startup_command=data.startup_command,
|
||||
required_variables=data.required_variables,
|
||||
category=data.category,
|
||||
interface_type=data.interface_type,
|
||||
@@ -438,54 +381,29 @@ async def update_tool_type(
|
||||
template = update_data.get("compose_template", tool_type.compose_template)
|
||||
if template:
|
||||
try:
|
||||
sanitized = _sanitize_template_vars(template)
|
||||
parsed = yaml.safe_load(sanitized)
|
||||
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:
|
||||
parsed = validate_compose_yaml(template)
|
||||
if not check_port_exposed(parsed, new_port):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
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
|
||||
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||
if definition_type == "compose":
|
||||
if "required_variables" in update_data and "compose_template" in update_data:
|
||||
template = update_data["compose_template"]
|
||||
for var in 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"
|
||||
)
|
||||
validate_required_variables(
|
||||
update_data["compose_template"], update_data["required_variables"]
|
||||
)
|
||||
elif "required_variables" in update_data:
|
||||
template = tool_type.compose_template
|
||||
if template:
|
||||
for var in 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"
|
||||
)
|
||||
validate_required_variables(template, update_data["required_variables"])
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(tool_type, field, value)
|
||||
@@ -530,16 +448,9 @@ async def validate_tool_type_template(
|
||||
errors.append("Compose template is required")
|
||||
else:
|
||||
try:
|
||||
sanitized = _sanitize_template_vars(data.compose_template)
|
||||
parsed = yaml.safe_load(sanitized)
|
||||
if not isinstance(parsed, dict):
|
||||
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}")
|
||||
validate_compose_yaml(data.compose_template)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
|
||||
elif data.definition_type == "dockerfile":
|
||||
if not data.dockerfile_template:
|
||||
@@ -588,16 +499,9 @@ async def validate_tool_type(
|
||||
errors.append("Compose template is empty")
|
||||
else:
|
||||
try:
|
||||
sanitized = _sanitize_template_vars(tool_type.compose_template)
|
||||
parsed = yaml.safe_load(sanitized)
|
||||
if not isinstance(parsed, dict):
|
||||
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}")
|
||||
validate_compose_yaml(tool_type.compose_template)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
|
||||
elif tool_type.definition_type == "dockerfile":
|
||||
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 uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.user import User
|
||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
"""Get or create user config record.
|
||||
|
||||
@@ -111,11 +102,11 @@ async def update_user_config(
|
||||
|
||||
# Merge updates
|
||||
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
|
||||
config.config = {**config.config, **update_data}
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
logger.info("Updated config: %s", config.config)
|
||||
logger.debug("Updated config: %s", 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 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
"""Fetch a user by ID or raise 401 if not found."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
return user
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.auth.session import decode_session_cookie
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.models.project import Project
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
@@ -47,3 +48,39 @@ async def get_current_user(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
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 json
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
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."""
|
||||
payload = {
|
||||
"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())
|
||||
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
|
||||
payload = json.loads(payload_bytes)
|
||||
|
||||
# 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")
|
||||
|
||||
return payload
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.api.auth import router as auth_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.instance_proxy import router as instance_proxy_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_instances import router as tool_instances_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(tool_types_router)
|
||||
app.include_router(config_folders_router)
|
||||
app.include_router(config_profiles_router)
|
||||
app.include_router(tool_instances_router)
|
||||
app.include_router(tool_configs_router)
|
||||
app.include_router(sessions_router)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from src.models.base import Base
|
||||
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.project import Project
|
||||
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_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))
|
||||
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)
|
||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
@@ -71,8 +72,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
branch: Mapped[str | None] = mapped_column(
|
||||
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()
|
||||
repository: Mapped["GitRepository"] = relationship()
|
||||
project: Mapped["Project"] = 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
|
||||
)
|
||||
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)
|
||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(),
|
||||
|
||||
@@ -42,7 +42,7 @@ def clone_repository(
|
||||
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(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
@@ -55,7 +55,7 @@ def clone_repository(
|
||||
logger.error("Git clone failed: %s", 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)
|
||||
|
||||
|
||||
@@ -94,4 +94,4 @@ def remove_clone_directory(instance_dir: str) -> None:
|
||||
if clone_path.exists():
|
||||
import shutil
|
||||
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."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
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:
|
||||
from src.config import Settings
|
||||
|
||||
base_path = Settings().instance_base_path
|
||||
instance_dir = Path(base_path) / instance_id
|
||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -87,64 +90,11 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
full_path.resolve().relative_to(instance_path.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
|
||||
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
||||
"""Write config folder files to the instance directory and return volume mounts.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
folders: List of ConfigFolder objects
|
||||
project_id: Optional project ID for applying overrides
|
||||
|
||||
Returns:
|
||||
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
volume_mounts = []
|
||||
|
||||
for folder in folders:
|
||||
# Determine mount path (with project override if applicable)
|
||||
mount_path = folder.mount_path
|
||||
files = folder.files.copy()
|
||||
|
||||
if project_id and folder.project_overrides:
|
||||
override = folder.project_overrides.get(str(project_id))
|
||||
if override:
|
||||
if override.get("mount_path"):
|
||||
mount_path = override["mount_path"]
|
||||
if override.get("files"):
|
||||
files.update(override["files"])
|
||||
|
||||
# Write files to instance directory
|
||||
folder_dir = instance_path / "volumes" / folder.name
|
||||
folder_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for file_path, content in files.items():
|
||||
# Security: ensure path doesn't escape folder_dir
|
||||
full_path = folder_dir / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(folder_dir.resolve())
|
||||
except ValueError:
|
||||
logger.warning("Config folder file path escapes directory: %s", file_path)
|
||||
continue
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
# Add volume mount
|
||||
volume_mounts.append({
|
||||
"source": str(folder_dir),
|
||||
"target": mount_path,
|
||||
"type": "bind",
|
||||
})
|
||||
|
||||
return volume_mounts
|
||||
|
||||
|
||||
def execute_compose_command(
|
||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
@@ -162,7 +112,7 @@ def execute_compose_command(
|
||||
instance_dir = Path(compose_path).parent
|
||||
|
||||
cmd = ["docker", "compose", "-f", compose_path]
|
||||
|
||||
|
||||
if env_file:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
|
||||
@@ -189,6 +139,8 @@ def execute_compose_command(
|
||||
def get_container_id(instance_name: str) -> str | None:
|
||||
"""Get the container ID for a compose service.
|
||||
|
||||
Searches all containers including stopped/exited ones.
|
||||
|
||||
Args:
|
||||
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
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
||||
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"],
|
||||
capture_output=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:
|
||||
"""Get the full container name for a compose service.
|
||||
|
||||
Searches all containers including stopped/exited ones.
|
||||
|
||||
Args:
|
||||
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
|
||||
"""
|
||||
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,
|
||||
text=True,
|
||||
)
|
||||
@@ -226,7 +188,9 @@ def get_container_name(instance_name: str) -> str | 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.
|
||||
|
||||
Args:
|
||||
@@ -251,12 +215,14 @@ def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
container_id: Docker container ID
|
||||
|
||||
Returns:
|
||||
Dict with 'status' (running, exited, restarting, not_found),
|
||||
Dict with 'status' (running, exited, restarting, not_found),
|
||||
'exit_code' (int or None), and 'health' (health status or None)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "inspect", "-f",
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||
container_id,
|
||||
],
|
||||
@@ -266,12 +232,12 @@ def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"status": "not_found", "exit_code": None, "health": None}
|
||||
|
||||
|
||||
parts = result.stdout.strip().split("|")
|
||||
status = parts[0] if parts else "unknown"
|
||||
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||
|
||||
|
||||
return {"status": status, "exit_code": exit_code, "health": health}
|
||||
|
||||
|
||||
@@ -291,13 +257,12 @@ def wait_for_container_running(
|
||||
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||
and 'waited_seconds' (float)
|
||||
"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
info = get_container_status(container_id)
|
||||
|
||||
|
||||
if info["status"] == "running":
|
||||
return {
|
||||
"success": True,
|
||||
@@ -305,7 +270,7 @@ def wait_for_container_running(
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
if info["status"] == "exited":
|
||||
return {
|
||||
"success": False,
|
||||
@@ -313,7 +278,7 @@ def wait_for_container_running(
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
if info["status"] == "not_found":
|
||||
return {
|
||||
"success": False,
|
||||
@@ -321,9 +286,9 @@ def wait_for_container_running(
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
# Timeout reached
|
||||
info = get_container_status(container_id)
|
||||
return {
|
||||
@@ -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}")
|
||||
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import re
|
||||
|
||||
|
||||
def start_cloudflared_tunnel(
|
||||
container_name: str, port: int, timeout: int = 30
|
||||
) -> dict[str, str]:
|
||||
@@ -397,8 +357,6 @@ def start_cloudflared_tunnel(
|
||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -407,18 +365,29 @@ def start_cloudflared_tunnel(
|
||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||
for attempt in range(10):
|
||||
check = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"http://{container_name}:{port}"],
|
||||
[
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
f"http://{container_name}:{port}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
||||
logger.info(
|
||||
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
|
||||
)
|
||||
if check.returncode == 0:
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
||||
logger.warning(
|
||||
"Container %s:%d not responding to curl checks", container_name, port
|
||||
)
|
||||
|
||||
# Run cloudflared in background, capture output
|
||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||
@@ -437,6 +406,7 @@ def start_cloudflared_tunnel(
|
||||
while time.time() - start_time < timeout:
|
||||
# Read available output
|
||||
import select
|
||||
|
||||
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||
if readable:
|
||||
line = proc.stdout.readline()
|
||||
@@ -463,7 +433,6 @@ def stop_cloudflared_tunnel(pid: str) -> None:
|
||||
Args:
|
||||
pid: Process ID of the cloudflared tunnel
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
|
||||
try:
|
||||
@@ -508,14 +477,23 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
|
||||
try:
|
||||
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,
|
||||
text=True,
|
||||
timeout=timeout + 5,
|
||||
)
|
||||
status_code = int(result.stdout.strip())
|
||||
|
||||
|
||||
if 200 <= status_code < 400:
|
||||
return {
|
||||
"tunnel_status": "healthy",
|
||||
@@ -548,7 +526,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
except (ValueError, Exception) as e:
|
||||
error_str = str(e).lower()
|
||||
# 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 {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
|
||||
@@ -18,13 +18,12 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Write Dockerfile
|
||||
dockerfile_path = Path(instance_dir) / "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
|
||||
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.write_text(content)
|
||||
logger.info("Wrote build context file: %s", full_path)
|
||||
logger.debug("Wrote build context file: %s", full_path)
|
||||
|
||||
# Build image
|
||||
logger.info("Building Docker image with tag: %s", tag)
|
||||
logger.debug("Building Docker image with tag: %s", tag)
|
||||
cmd = [
|
||||
"docker", "build",
|
||||
"-t", tag,
|
||||
@@ -57,7 +56,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
||||
text=True,
|
||||
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:
|
||||
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
@@ -1,88 +1,150 @@
|
||||
"""Terminal session manager for WebSocket connections."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from src.services.terminal_session import TerminalSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TerminalManager:
|
||||
"""Manages active terminal sessions."""
|
||||
"""Manages active terminal sessions with persistence support."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Track sessions by instance_id for persistence
|
||||
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,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
websocket: WebSocket,
|
||||
startup_command: str | None = None,
|
||||
) -> TerminalSession:
|
||||
"""Create a new terminal session."""
|
||||
"""Get existing session or create a new one."""
|
||||
# Ensure idle check is running (lazy start)
|
||||
self._start_idle_check()
|
||||
|
||||
instance_id_str = str(instance_id)
|
||||
|
||||
# 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)
|
||||
await session.start()
|
||||
self._sessions[session_id] = session
|
||||
|
||||
# Start background tasks for I/O streaming
|
||||
asyncio.create_task(self._read_loop(session, websocket))
|
||||
asyncio.create_task(self._write_loop(session, websocket))
|
||||
|
||||
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 _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
||||
"""Read output from the container and send to WebSocket."""
|
||||
try:
|
||||
while session.is_alive() and not session._closed:
|
||||
data = await session.read_output()
|
||||
if data:
|
||||
await websocket.send_bytes(data)
|
||||
else:
|
||||
await asyncio.sleep(0.01)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await self._cleanup_session(session)
|
||||
async def attach_websocket(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Attach a WebSocket to an existing session."""
|
||||
# Handle concurrent connections - close existing ones
|
||||
if session.has_websockets():
|
||||
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
|
||||
for ws in list(session._websockets):
|
||||
try:
|
||||
await ws.close(code=4000, reason="New connection established")
|
||||
except Exception:
|
||||
pass
|
||||
session._websockets.clear()
|
||||
|
||||
# Attach new WebSocket
|
||||
session.attach_websocket(websocket)
|
||||
|
||||
# Replay buffer
|
||||
buffer = session.get_buffer()
|
||||
if buffer:
|
||||
try:
|
||||
await websocket.send_bytes(buffer)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
||||
"""Read input from WebSocket and send to container."""
|
||||
try:
|
||||
while session.is_alive() and not session._closed:
|
||||
message = await websocket.receive()
|
||||
if message["type"] == "websocket.receive":
|
||||
if "bytes" in message:
|
||||
await session.write_input(message["bytes"])
|
||||
elif "text" in message:
|
||||
text = message["text"]
|
||||
if text.startswith("{"):
|
||||
# Control message (JSON)
|
||||
import json
|
||||
try:
|
||||
ctrl = json.loads(text)
|
||||
if ctrl.get("type") == "resize":
|
||||
await session.resize(
|
||||
ctrl.get("cols", 80),
|
||||
ctrl.get("rows", 24),
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
elif message["type"] == "websocket.disconnect":
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await self._cleanup_session(session)
|
||||
async def detach_websocket(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Detach a WebSocket from a session."""
|
||||
session.detach_websocket(websocket)
|
||||
|
||||
async def _cleanup_session(self, session: TerminalSession) -> None:
|
||||
"""Clean up a session."""
|
||||
if session.session_id in self._sessions:
|
||||
del self._sessions[session.session_id]
|
||||
await session.close()
|
||||
async def 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:
|
||||
"""Close all active sessions."""
|
||||
@@ -90,6 +152,9 @@ class TerminalManager:
|
||||
self._sessions.clear()
|
||||
for session in sessions:
|
||||
await session.close()
|
||||
|
||||
if self._idle_check_task and not self._idle_check_task.done():
|
||||
self._idle_check_task.cancel()
|
||||
|
||||
|
||||
# Global terminal manager instance
|
||||
|
||||
@@ -1,34 +1,73 @@
|
||||
"""Terminal session management for tool instances."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import fcntl
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TerminalSession:
|
||||
"""Manages a single terminal session connected to a docker container."""
|
||||
"""Manages a single terminal session connected to a docker container.
|
||||
|
||||
Supports persistent sessions that survive WebSocket disconnections.
|
||||
Multiple WebSocket connections can attach/detach from the same session.
|
||||
"""
|
||||
|
||||
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
|
||||
# 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.instance_id = instance_id
|
||||
self.container_id = container_id
|
||||
self.startup_command = startup_command
|
||||
self.process: asyncio.subprocess.Process | None = None
|
||||
self._closed = False
|
||||
self._master_fd: int | None = None
|
||||
self._slave_fd: int | None = 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) -> None:
|
||||
async def start(self, startup_command: str | None = None) -> None:
|
||||
"""Start the docker exec process with a shell using a PTY."""
|
||||
# Create a pseudo-terminal on the host
|
||||
self._master_fd, self._slave_fd = pty.openpty()
|
||||
|
||||
# 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
|
||||
# Using -it because the slave fd IS a TTY
|
||||
@@ -40,7 +79,8 @@ class TerminalSession:
|
||||
"TERM=xterm",
|
||||
self.container_id,
|
||||
"bash",
|
||||
"-il",
|
||||
"-c",
|
||||
shell_cmd,
|
||||
stdin=self._slave_fd,
|
||||
stdout=self._slave_fd,
|
||||
stderr=self._slave_fd,
|
||||
@@ -49,46 +89,103 @@ class TerminalSession:
|
||||
# Close slave fd in parent process
|
||||
os.close(self._slave_fd)
|
||||
self._slave_fd = None
|
||||
|
||||
self.last_activity = time.time()
|
||||
|
||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||
"""Set the terminal size using TIOCSWINSZ."""
|
||||
if self._master_fd is None:
|
||||
logger.warning("Cannot resize: master_fd is None (session not started)")
|
||||
return
|
||||
# TIOCSWINSZ = 0x5414 on Linux
|
||||
TIOCSWINSZ = 0x5414
|
||||
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
||||
except (OSError, IOError) as e:
|
||||
logger.error(f"Failed to resize PTY: {e}")
|
||||
|
||||
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:
|
||||
return b""
|
||||
try:
|
||||
# Use select to check if data is available
|
||||
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
||||
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""
|
||||
except (OSError, IOError, ValueError):
|
||||
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:
|
||||
"""Write input to the PTY master."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return
|
||||
try:
|
||||
os.write(self._master_fd, data)
|
||||
self.last_activity = time.time()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
async def resize(self, cols: int, rows: int) -> None:
|
||||
"""Resize the terminal."""
|
||||
if self._closed:
|
||||
logger.warning("Cannot resize: session is closed")
|
||||
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)
|
||||
|
||||
# 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:
|
||||
"""Close the session and cleanup."""
|
||||
@@ -115,3 +212,35 @@ class TerminalSession:
|
||||
if self.process is None:
|
||||
return False
|
||||
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
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _run_git_command(repo_path: str, *args: str) -> str:
|
||||
|
||||
@@ -289,19 +289,37 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
|
||||
branches: list[BranchInfo] = []
|
||||
default_branch = "main"
|
||||
|
||||
# Get list of remote names to properly filter remote tracking branches
|
||||
try:
|
||||
remote_output = _run_git_command(repo_path, "remote")
|
||||
remote_names = {r.strip() for r in remote_output.strip().split("\n") if r.strip()}
|
||||
except RuntimeError:
|
||||
remote_names = set()
|
||||
|
||||
for line in output.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
branch_name = line.strip()
|
||||
# Skip remote tracking branches (they start with remotes/)
|
||||
if branch_name.startswith("remotes/"):
|
||||
# Extract just the branch name part
|
||||
parts = branch_name.split("/", 2)
|
||||
if len(parts) >= 3:
|
||||
branch_name = parts[2]
|
||||
else:
|
||||
continue
|
||||
|
||||
# Skip detached HEAD pointer
|
||||
if branch_name == "HEAD":
|
||||
continue
|
||||
|
||||
# Skip remote tracking branches - they appear as "origin/branch-name"
|
||||
# Check if first part is a remote name
|
||||
if "/" in branch_name:
|
||||
first_part = branch_name.split("/", 1)[0]
|
||||
if first_part in remote_names:
|
||||
# Extract just the branch name part (after "origin/")
|
||||
branch_name = branch_name.split("/", 1)[1]
|
||||
elif branch_name.startswith("remotes/"):
|
||||
# Handle "remotes/origin/branch-name" format
|
||||
parts = branch_name.split("/", 2)
|
||||
if len(parts) >= 3:
|
||||
branch_name = parts[2]
|
||||
else:
|
||||
continue
|
||||
|
||||
# Skip duplicates
|
||||
if any(b.name == branch_name for b in branches):
|
||||
|
||||
@@ -8,16 +8,14 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Set test environment BEFORE importing app modules
|
||||
os.environ["APP_ENV"] = "testing"
|
||||
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||
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.main import app
|
||||
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
|
||||
|
||||
# Patch startup events to prevent PostgreSQL connection attempts
|
||||
with patch("src.main.init_database") as mock_init, \
|
||||
patch("src.main.seed_builtin_tool_types") as mock_seed:
|
||||
with patch("src.main.init_database") as mock_init:
|
||||
mock_init.return_value = True
|
||||
mock_seed.return_value = None
|
||||
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
@@ -61,6 +57,31 @@ def test_client() -> Generator[TestClient, None, None]:
|
||||
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
|
||||
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||
"""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
|
||||
|
||||
|
||||
@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
|
||||
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
||||
"""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
|
||||
|
||||
|
||||
@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.integration
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -221,5 +220,79 @@ class TestToolTypesAPIExtended:
|
||||
},
|
||||
)
|
||||
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()
|
||||
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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
import pytest
|
||||
|
||||
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."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.readiness_probe import execute_probe
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.tool_instances import CreateInstanceRequest
|
||||
|
||||
|
||||
Generated
+512
-69
@@ -896,6 +896,24 @@
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
@@ -913,6 +931,24 @@
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
@@ -930,6 +966,24 @@
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
@@ -1390,9 +1444,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1410,9 +1461,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1430,9 +1478,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1450,9 +1495,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1470,9 +1512,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1490,9 +1529,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1671,9 +1707,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1688,9 +1721,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1705,9 +1735,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1722,9 +1749,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1739,9 +1763,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1756,9 +1777,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1773,9 +1791,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1790,9 +1805,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1807,9 +1819,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1824,9 +1833,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1841,9 +1847,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1858,9 +1861,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1875,9 +1875,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4360,9 +4357,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4384,9 +4378,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4408,9 +4399,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4432,9 +4420,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"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": {
|
||||
"version": "4.1.6",
|
||||
"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": {
|
||||
"version": "4.0.4",
|
||||
"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";
|
||||
|
||||
@@ -14,13 +14,38 @@ export const shouldSkipAuthRedirect = (path: string): boolean => {
|
||||
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(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const status = error?.response?.status;
|
||||
if (status === 401 && !shouldSkipAuthRedirect(window.location.pathname)) {
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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> {
|
||||
const response = await apiClient.post("/projects/repositories/parse-url", { url });
|
||||
const response = await apiClient.post("/repositories/parse-url", { url });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
||||
export async function listRepositories(projectId?: string): Promise<GitRepository[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -48,6 +60,13 @@ export async function createRepository(
|
||||
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> {
|
||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ToolInstance {
|
||||
@@ -10,6 +11,7 @@ export interface ToolInstance {
|
||||
status: string;
|
||||
url: string | null;
|
||||
port: number | null;
|
||||
selected_config_profile_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -29,6 +31,7 @@ export interface Session {
|
||||
probe_status?: string;
|
||||
clone_mode?: string;
|
||||
branch?: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export async function listInstances(
|
||||
@@ -48,7 +51,8 @@ export async function createInstance(
|
||||
displayName?: string,
|
||||
cloneMode?: string,
|
||||
branch?: string,
|
||||
newBranch?: string
|
||||
newBranch?: string,
|
||||
configProfileId?: string
|
||||
): Promise<ToolInstance> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||
@@ -58,6 +62,7 @@ export async function createInstance(
|
||||
clone_mode: cloneMode || "mount",
|
||||
branch: branch || undefined,
|
||||
new_branch: newBranch || undefined,
|
||||
config_profile_id: configProfileId,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
@@ -66,12 +71,25 @@ export async function createInstance(
|
||||
export async function startInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
retries = 2
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
|
||||
);
|
||||
return response.data;
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||
{ 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(
|
||||
@@ -88,12 +106,25 @@ export async function stopInstance(
|
||||
export async function restartInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
retries = 2
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`
|
||||
);
|
||||
return response.data;
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||
{ 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(
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ToolType {
|
||||
dockerfile_template: string | null;
|
||||
build_context: Record<string, string> | null;
|
||||
readiness_probe: ReadinessProbe | null;
|
||||
startup_command: string | null;
|
||||
required_variables: string[];
|
||||
created_by_id: string | null;
|
||||
created_at: string;
|
||||
@@ -39,6 +40,7 @@ export interface CreateToolTypeRequest {
|
||||
dockerfile_template?: string;
|
||||
build_context?: Record<string, string>;
|
||||
readiness_probe?: ReadinessProbe;
|
||||
startup_command?: string;
|
||||
required_variables: string[];
|
||||
}
|
||||
|
||||
@@ -54,6 +56,7 @@ export interface UpdateToolTypeRequest {
|
||||
dockerfile_template?: string;
|
||||
build_context?: Record<string, string>;
|
||||
readiness_probe?: ReadinessProbe;
|
||||
startup_command?: 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 { getUserSessions } from "../api/sessions";
|
||||
@@ -8,6 +8,7 @@ import { useAuth } from "../state/auth";
|
||||
import { useSessions } from "../state/sessions";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { Icon } from "./icon";
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
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: "/projects", label: "Projects", icon: "projects" },
|
||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
|
||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||
];
|
||||
|
||||
@@ -42,8 +44,6 @@ export const AppShell = () => {
|
||||
const { sessions, setAllSessions } = useSessions();
|
||||
const location = useLocation();
|
||||
const isMobile = useMobileViewport();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
@@ -57,18 +57,13 @@ export const AppShell = () => {
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
void loadSessions();
|
||||
}, 10000);
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
if (isMobileTerminal) {
|
||||
return (
|
||||
<div className="shell mobile-terminal-shell">
|
||||
@@ -101,57 +96,46 @@ export const AppShell = () => {
|
||||
</header>
|
||||
|
||||
<div className="shell-body">
|
||||
<aside className={`shell-nav ${mobileMenuOpen ? "mobile-open" : ""}`} aria-label="Primary navigation">
|
||||
{isMobile && (
|
||||
<button
|
||||
className="mobile-menu-close"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{item.badge === "sessions" && activeCount > 0 && (
|
||||
<span className="nav-badge">{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<div className="nav-divider" />
|
||||
<div className="nav-section-title">Live sessions</div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{isMobile && mobileMenuOpen && (
|
||||
<div
|
||||
className="mobile-menu-overlay"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{item.badge === "sessions" && activeCount > 0 && (
|
||||
<span className="nav-badge">{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<div className="nav-divider" />
|
||||
<div className="nav-section-title">Live sessions</div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main className="shell-content">
|
||||
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<MobileNav sessionCount={sessions.filter((s) => s.status === "running").length} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Project } from "../types";
|
||||
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
||||
import type { ToolType } from "../api/tool_types";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||
|
||||
interface CreateSessionFormProps {
|
||||
projects: Project[];
|
||||
@@ -46,6 +47,8 @@ export const CreateSessionForm = ({
|
||||
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||
const [branch, setBranch] = useState("main");
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
||||
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
@@ -71,6 +74,30 @@ export const CreateSessionForm = ({
|
||||
void loadKeys();
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
const projectId = fixedProjectId || selectedProject;
|
||||
@@ -138,7 +165,8 @@ export const CreateSessionForm = ({
|
||||
: undefined,
|
||||
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||
? newBranchName
|
||||
: undefined
|
||||
: undefined,
|
||||
selectedConfigProfile || undefined
|
||||
);
|
||||
|
||||
setProgress("Starting container...");
|
||||
@@ -298,8 +326,26 @@ export const CreateSessionForm = ({
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Step 4: Clone Mode & Branch */}
|
||||
{showCloneMode && hasToolType && renderStep("Repository Access", 4, true, false,
|
||||
{/* Step 4: Config Profile */}
|
||||
{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">
|
||||
<label className="form-field">
|
||||
<div className="radio-group">
|
||||
@@ -422,8 +468,8 @@ export const CreateSessionForm = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5: Display Name */}
|
||||
{hasToolType && renderStep("Display Name (optional)", 5, true, !!displayName,
|
||||
{/* Step 6: Display Name */}
|
||||
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
|
||||
<label className="form-field">
|
||||
<input
|
||||
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,
|
||||
Terminal,
|
||||
ArrowLeft,
|
||||
DotsSixVertical,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
export type IconName =
|
||||
@@ -75,7 +76,8 @@ export type IconName =
|
||||
| "play"
|
||||
| "stop"
|
||||
| "terminal"
|
||||
| "arrow-left";
|
||||
| "arrow-left"
|
||||
| "drag";
|
||||
|
||||
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
||||
dashboard: House,
|
||||
@@ -117,6 +119,7 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
|
||||
stop: Stop,
|
||||
terminal: Terminal,
|
||||
"arrow-left": ArrowLeft,
|
||||
drag: DotsSixVertical,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
@@ -147,7 +150,6 @@ export const Icon: React.FC<IconProps> = ({
|
||||
const sizeValue = sizeMap[size];
|
||||
|
||||
if (!IconComponent) {
|
||||
console.warn(`Icon "${name}" not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../api/sessions";
|
||||
import type { ToolType } from "../api/tool_types";
|
||||
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";
|
||||
|
||||
@@ -30,13 +31,21 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
||||
// Stop confirmation
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
|
||||
|
||||
// Health check state
|
||||
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
|
||||
|
||||
// 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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -88,51 +97,79 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
await loadInstances();
|
||||
};
|
||||
|
||||
const handleStart = async (instanceId: string) => {
|
||||
const loadConfigProfiles = useCallback(async (toolTypeId: string) => {
|
||||
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();
|
||||
} catch {
|
||||
setError("Failed to start instance");
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (instanceId: string) => {
|
||||
setBusyInstanceId(instanceId);
|
||||
try {
|
||||
await stopInstance(projectId, repoId, instanceId);
|
||||
setStopConfirmId(null);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to stop instance");
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async (instanceId: string) => {
|
||||
const handleRestart = async (instanceId: string, configProfileId?: string) => {
|
||||
setBusyInstanceId(instanceId);
|
||||
try {
|
||||
await restartInstance(projectId, repoId, instanceId);
|
||||
await restartInstance(projectId, repoId, instanceId, configProfileId);
|
||||
setProfileSelectInstanceId(null);
|
||||
setSelectedProfileForAction("");
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to restart instance");
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (instanceId: string) => {
|
||||
if (!confirm("Are you sure you want to delete this instance?")) return;
|
||||
setBusyInstanceId(instanceId);
|
||||
try {
|
||||
await deleteInstance(projectId, repoId, instanceId);
|
||||
// Update state immediately instead of reloading
|
||||
setInstances(prev => prev.filter(i => i.id !== instanceId));
|
||||
} catch {
|
||||
setError("Failed to delete instance");
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (instanceId: string) => {
|
||||
setBusyInstanceId(instanceId);
|
||||
try {
|
||||
await recreateInstanceTunnel(projectId, repoId, instanceId);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to recreate tunnel");
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,7 +220,12 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
) : (
|
||||
<div className="instance-grid">
|
||||
{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-name">{instance.display_name}</div>
|
||||
<div className="instance-meta">
|
||||
@@ -199,6 +241,13 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
</span>
|
||||
)}
|
||||
</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 className="instance-actions">
|
||||
{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)}
|
||||
type="button"
|
||||
title="Recreate tunnel"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Fix Tunnel
|
||||
@@ -230,20 +280,67 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
className="secondary-button small"
|
||||
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
<Icon name="terminal" size="sm" />
|
||||
Terminal
|
||||
</button>
|
||||
)}
|
||||
{instance.status !== "running" && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => void handleStart(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
Start
|
||||
</button>
|
||||
<>
|
||||
{profileSelectInstanceId === instance.id ? (
|
||||
<div className="inline-profile-select">
|
||||
<select
|
||||
value={selectedProfileForAction}
|
||||
onChange={(e) => setSelectedProfileForAction(e.target.value)}
|
||||
>
|
||||
<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" && (
|
||||
<>
|
||||
@@ -254,6 +351,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
className="ghost-button small danger-text"
|
||||
onClick={() => void handleStop(instance.id)}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
@@ -261,6 +359,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(null)}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
@@ -270,23 +369,69 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(instance.id)}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => void handleRestart(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
{profileSelectInstanceId === instance.id ? (
|
||||
<div className="inline-profile-select">
|
||||
<select
|
||||
value={selectedProfileForAction}
|
||||
onChange={(e) => setSelectedProfileForAction(e.target.value)}
|
||||
>
|
||||
<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
|
||||
className="ghost-button small danger-text"
|
||||
onClick={() => void handleDelete(instance.id)}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,9 @@ interface MobileTerminalHeaderProps {
|
||||
onBack?: () => void;
|
||||
onMenuToggle?: () => void;
|
||||
onClose?: () => void;
|
||||
onFontSizeChange?: (delta: number) => void;
|
||||
isVisible: boolean;
|
||||
connectionStatus?: "connecting" | "connected" | "disconnected" | "error";
|
||||
connectionStatus?: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||
}
|
||||
|
||||
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
|
||||
@@ -15,6 +16,7 @@ export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
|
||||
onBack,
|
||||
onMenuToggle,
|
||||
onClose,
|
||||
onFontSizeChange,
|
||||
isVisible,
|
||||
connectionStatus = "connecting",
|
||||
}) => {
|
||||
@@ -56,6 +58,26 @@ export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="mobile-terminal-header-right">
|
||||
{onFontSizeChange && (
|
||||
<>
|
||||
<button
|
||||
className="mobile-terminal-header-button"
|
||||
onClick={() => onFontSizeChange(-1)}
|
||||
type="button"
|
||||
aria-label="Decrease font size"
|
||||
>
|
||||
<span style={{ fontSize: "0.75rem" }}>A-</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-terminal-header-button"
|
||||
onClick={() => onFontSizeChange(1)}
|
||||
type="button"
|
||||
aria-label="Increase font size"
|
||||
>
|
||||
<span style={{ fontSize: "1rem" }}>A+</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
className="mobile-terminal-header-button"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SpecialKeysPanel } from "./special-keys-panel";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
||||
import type { ModifierKey } from "../hooks/use-special-keys";
|
||||
|
||||
interface MobileTerminalWrapperProps {
|
||||
instanceId: string;
|
||||
@@ -26,10 +27,12 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||
useVirtualKeyboard();
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
||||
const [terminalRef, setTerminalRef] = useState<{
|
||||
sendData: (data: string) => void;
|
||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error";
|
||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||
focusInput: () => void;
|
||||
changeFontSize: (delta: number) => void;
|
||||
} | null>(null);
|
||||
|
||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||
@@ -39,8 +42,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
}, [headerAutoHide]);
|
||||
|
||||
const handleTerminalReady = useCallback(
|
||||
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error", focusInput: () => void) => {
|
||||
setTerminalRef({ sendData, connectionStatus, focusInput });
|
||||
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => {
|
||||
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -69,6 +72,7 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
onBack={onBack}
|
||||
onMenuToggle={onMenuToggle}
|
||||
onClose={onClose}
|
||||
onFontSizeChange={(delta) => terminalRef?.changeFontSize(delta)}
|
||||
isVisible={headerAutoHide.isVisible}
|
||||
connectionStatus={terminalRef?.connectionStatus}
|
||||
/>
|
||||
@@ -84,6 +88,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
instanceId={instanceId}
|
||||
onClose={onClose}
|
||||
isMobile={true}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
onTerminalReady={handleTerminalReady}
|
||||
/>
|
||||
</div>
|
||||
@@ -93,6 +99,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
isVisible={!showPanel}
|
||||
onMoreClick={() => setShowPanel(true)}
|
||||
onKeepFocus={() => terminalRef?.focusInput()}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
/>
|
||||
|
||||
<SpecialKeysPanel
|
||||
@@ -100,6 +108,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
isOpen={showPanel}
|
||||
onClose={() => setShowPanel(false)}
|
||||
onKeepFocus={() => terminalRef?.focusInput()}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,11 +1,13 @@
|
||||
import React from "react";
|
||||
import { useSpecialKeys, type SpecialKey } from "../hooks/use-special-keys";
|
||||
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
|
||||
|
||||
interface SpecialKeysPanelProps {
|
||||
onSend: (data: string) => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onKeepFocus?: () => void;
|
||||
activeModifier: ModifierKey | null;
|
||||
onModifierChange: (modifier: ModifierKey | null) => void;
|
||||
}
|
||||
|
||||
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
|
||||
@@ -38,23 +40,46 @@ export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onKeepFocus,
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
}) => {
|
||||
const { sendKey } = useSpecialKeys({ onSend });
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||
e.preventDefault();
|
||||
sendKey(key);
|
||||
|
||||
const result = getSequenceWithModifier(key, activeModifier);
|
||||
if (result) {
|
||||
onSend(result.sequence);
|
||||
if (result.clearModifier) {
|
||||
onModifierChange(null);
|
||||
}
|
||||
}
|
||||
|
||||
onClose();
|
||||
onKeepFocus?.();
|
||||
// Always refocus terminal after sending
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverlayPointerDown = (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
onModifierChange(null);
|
||||
onClose();
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="special-keys-panel-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="special-keys-panel-overlay"
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
>
|
||||
<div
|
||||
className="special-keys-panel"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="special-keys-panel-section">
|
||||
{EXPANDED_KEYS.map(({ key, label }) => (
|
||||
@@ -62,7 +87,6 @@ export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
|
||||
key={key}
|
||||
className="special-key-button"
|
||||
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||
onFocus={(e) => e.target.blur()}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
@@ -77,7 +101,6 @@ export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
|
||||
key={key}
|
||||
className="special-key-button"
|
||||
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||
onFocus={(e) => e.target.blur()}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import React from "react";
|
||||
import { useSpecialKeys, type SpecialKey } from "../hooks/use-special-keys";
|
||||
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
|
||||
|
||||
interface SpecialKeysStripProps {
|
||||
onSend: (data: string) => void;
|
||||
isVisible: boolean;
|
||||
onMoreClick?: () => void;
|
||||
onKeepFocus?: () => void;
|
||||
activeModifier: ModifierKey | null;
|
||||
onModifierChange: (modifier: ModifierKey | null) => void;
|
||||
}
|
||||
|
||||
const PRIMARY_KEYS: { key: SpecialKey; label: string }[] = [
|
||||
const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [
|
||||
{ key: "escape", label: "Esc" },
|
||||
{ key: "tab", label: "Tab" },
|
||||
{ key: "ctrl", label: "Ctrl" },
|
||||
{ key: "alt", label: "Alt" },
|
||||
{ key: "ctrl", label: "Ctrl", isModifier: true },
|
||||
{ key: "alt", label: "Alt", isModifier: true },
|
||||
{ key: "up", label: "↑" },
|
||||
{ key: "down", label: "↓" },
|
||||
{ key: "left", label: "←" },
|
||||
@@ -24,32 +26,56 @@ export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
|
||||
isVisible,
|
||||
onMoreClick,
|
||||
onKeepFocus,
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
}) => {
|
||||
const { sendKey } = useSpecialKeys({ onSend });
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||
e.preventDefault();
|
||||
sendKey(key);
|
||||
onKeepFocus?.();
|
||||
|
||||
// Handle modifier keys (one-shot)
|
||||
if (key === "ctrl" || key === "alt") {
|
||||
onModifierChange(activeModifier === key ? null : key);
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = getSequenceWithModifier(key, activeModifier);
|
||||
if (result) {
|
||||
onSend(result.sequence);
|
||||
if (result.clearModifier) {
|
||||
onModifierChange(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Always refocus terminal after sending
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
});
|
||||
};
|
||||
|
||||
const handleMorePointerDown = (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
onMoreClick?.();
|
||||
onKeepFocus?.();
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`special-keys-strip ${isVisible ? "visible" : "hidden"}`}>
|
||||
{PRIMARY_KEYS.map(({ key, label }) => (
|
||||
{PRIMARY_KEYS.map(({ key, label, isModifier }) => (
|
||||
<button
|
||||
key={key}
|
||||
className="special-key-button"
|
||||
className={`special-key-button ${
|
||||
isModifier && activeModifier === key ? "active-modifier" : ""
|
||||
}`}
|
||||
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||
onFocus={(e) => e.target.blur()}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Send ${label}`}
|
||||
aria-pressed={isModifier && activeModifier === key}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -58,7 +84,6 @@ export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
|
||||
<button
|
||||
className="special-key-button special-key-more"
|
||||
onPointerDown={handleMorePointerDown}
|
||||
onFocus={(e) => e.target.blur()}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="More special keys"
|
||||
|
||||
@@ -4,378 +4,637 @@ import { FitAddon } from "xterm-addon-fit";
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
import "xterm/css/xterm.css";
|
||||
|
||||
import {
|
||||
applyModifierToChar,
|
||||
type ModifierKey,
|
||||
} from "../hooks/use-special-keys";
|
||||
|
||||
interface TerminalProps {
|
||||
instanceId: string;
|
||||
onClose?: () => void;
|
||||
isMobile?: boolean;
|
||||
onTerminalReady?: (
|
||||
sendData: (data: string) => void,
|
||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error",
|
||||
focusInput: () => void
|
||||
) => void;
|
||||
instanceId: string;
|
||||
onClose?: () => void;
|
||||
isMobile?: boolean;
|
||||
activeModifier?: ModifierKey | null;
|
||||
onModifierChange?: (modifier: ModifierKey | null) => void;
|
||||
onTerminalReady?: (
|
||||
sendData: (data: string) => void,
|
||||
connectionStatus:
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error"
|
||||
| "resetting",
|
||||
focusInput: () => void,
|
||||
changeFontSize: (delta: number) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const FONT_SIZE_KEY = "terminal-font-size";
|
||||
const MIN_FONT_SIZE = 16;
|
||||
const MIN_FONT_SIZE = 4;
|
||||
const MAX_FONT_SIZE = 24;
|
||||
const RECONNECT_ATTEMPTS = 3;
|
||||
const RECONNECT_DELAY_BASE = 1000;
|
||||
|
||||
export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
instanceId,
|
||||
onClose,
|
||||
isMobile = false,
|
||||
onTerminalReady,
|
||||
instanceId,
|
||||
onClose,
|
||||
isMobile = false,
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
onTerminalReady,
|
||||
}) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const hiddenInputRef = useRef<HTMLInputElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const onTerminalReadyRef = useRef(onTerminalReady);
|
||||
onTerminalReadyRef.current = onTerminalReady;
|
||||
const [status, setStatus] = useState<
|
||||
"connecting" | "connected" | "disconnected" | "error"
|
||||
>("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
if (typeof window === "undefined") return isMobile ? 16 : 14;
|
||||
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
||||
return stored ? parseInt(stored, 10) : isMobile ? 16 : 14;
|
||||
});
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const hiddenInputRef = useRef<HTMLInputElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const onTerminalReadyRef = useRef(onTerminalReady);
|
||||
onTerminalReadyRef.current = onTerminalReady;
|
||||
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
|
||||
const [status, setStatus] = useState<
|
||||
"connecting" | "connected" | "disconnected" | "error" | "resetting"
|
||||
>("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const activeModifierRef = useRef(activeModifier);
|
||||
activeModifierRef.current = activeModifier;
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
if (typeof window === "undefined") return isMobile ? 8 : 8;
|
||||
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, parsed));
|
||||
}
|
||||
return isMobile ? 8 : 8;
|
||||
});
|
||||
const lastPingRef = useRef<number>(0);
|
||||
const heartbeatCheckRef = useRef<number | null>(null);
|
||||
const isUnmountingRef = useRef(false);
|
||||
const permanentErrorRef = useRef<string | null>(null);
|
||||
|
||||
const calculateFontSize = useCallback(() => {
|
||||
if (!isMobile) return fontSize;
|
||||
const vw = window.innerWidth;
|
||||
const calculated = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, vw / 25));
|
||||
return Math.round(calculated);
|
||||
}, [isMobile, fontSize]);
|
||||
const calculateFontSize = useCallback(() => {
|
||||
return fontSize;
|
||||
}, [fontSize]);
|
||||
|
||||
const connectWebSocket = useCallback(() => {
|
||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||
const connectWebSocket = useCallback(() => {
|
||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
// WebSocket connection established
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus("connected");
|
||||
setError(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
};
|
||||
ws.onopen = () => {
|
||||
setStatus("connected");
|
||||
setError(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
lastPingRef.current = Date.now();
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (!termRef.current) return;
|
||||
// Send current terminal size immediately on connect
|
||||
if (termRef.current) {
|
||||
const { cols, rows } = termRef.current;
|
||||
// Only send if we have valid dimensions
|
||||
if (cols > 0 && rows > 0) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
}
|
||||
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
const data = new Uint8Array(buffer);
|
||||
termRef.current?.write(data);
|
||||
});
|
||||
} else if (typeof event.data === "string") {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "status" && msg.status === "connected") {
|
||||
setStatus("connected");
|
||||
}
|
||||
} catch {
|
||||
termRef.current?.write(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Start heartbeat check
|
||||
if (heartbeatCheckRef.current) {
|
||||
window.clearInterval(heartbeatCheckRef.current);
|
||||
}
|
||||
heartbeatCheckRef.current = window.setInterval(() => {
|
||||
const elapsed = Date.now() - lastPingRef.current;
|
||||
if (elapsed > 60000) {
|
||||
// No ping for 60 seconds, connection may be dead
|
||||
ws.close(4000, "Heartbeat timeout");
|
||||
}
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setStatus("disconnected");
|
||||
if (event.code !== 1000) {
|
||||
setError(`Connection closed (code: ${event.code})`);
|
||||
ws.onmessage = (event) => {
|
||||
if (!termRef.current) return;
|
||||
|
||||
// Attempt reconnection
|
||||
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
||||
reconnectAttemptsRef.current++;
|
||||
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||
setTimeout(() => {
|
||||
if (document.visibilityState !== "hidden") {
|
||||
connectWebSocket();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
const data = new Uint8Array(buffer);
|
||||
termRef.current?.write(data);
|
||||
});
|
||||
} else if (typeof event.data === "string") {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "status") {
|
||||
if (msg.status === "connected") {
|
||||
setStatus("connected");
|
||||
setError(null);
|
||||
// Clear terminal and refit after reset/reconnect
|
||||
if (termRef.current) {
|
||||
termRef.current.clear();
|
||||
requestAnimationFrame(() => {
|
||||
if (fitAddonRef.current && termRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
const { cols, rows } = termRef.current;
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||
currentWs.send(
|
||||
JSON.stringify({ type: "resize", cols, rows }),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (msg.status === "resetting") {
|
||||
setStatus("resetting");
|
||||
}
|
||||
} else if (msg.type === "ping") {
|
||||
// Respond with pong and update last ping time
|
||||
lastPingRef.current = Date.now();
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
termRef.current?.write(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus("error");
|
||||
setError("WebSocket error");
|
||||
};
|
||||
ws.onclose = (event) => {
|
||||
// Clean up heartbeat check
|
||||
if (heartbeatCheckRef.current) {
|
||||
window.clearInterval(heartbeatCheckRef.current);
|
||||
heartbeatCheckRef.current = null;
|
||||
}
|
||||
|
||||
return ws;
|
||||
}, [instanceId]);
|
||||
// Permanent errors: do not retry
|
||||
if (event.code === 4001 || event.code === 4003 || event.code === 4004) {
|
||||
const reason = event.reason || `Instance error (code: ${event.code})`;
|
||||
setStatus("error");
|
||||
setError(reason);
|
||||
permanentErrorRef.current = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
if (event.code === 1000) {
|
||||
setStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize terminal
|
||||
const currentFontSize = calculateFontSize();
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: currentFontSize,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
selectionBackground: "#264f78",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#f14c4c",
|
||||
brightGreen: "#23d18b",
|
||||
brightYellow: "#f5f543",
|
||||
brightBlue: "#3b8eea",
|
||||
brightMagenta: "#d670d6",
|
||||
brightCyan: "#29b8db",
|
||||
brightWhite: "#e5e5e5",
|
||||
},
|
||||
});
|
||||
if (event.code === 4000) {
|
||||
// Server closed old connection for concurrent connection - don't reconnect
|
||||
// The new connection is already established
|
||||
return;
|
||||
}
|
||||
|
||||
termRef.current = term;
|
||||
// Transient errors: attempt reconnection
|
||||
setStatus("disconnected");
|
||||
setError(`Connection closed (code: ${event.code})`);
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
||||
reconnectAttemptsRef.current++;
|
||||
const delay =
|
||||
RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||
setTimeout(() => {
|
||||
if (isUnmountingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState !== "hidden") {
|
||||
connectWebSocket();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
term.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
ws.onerror = () => {
|
||||
setStatus("error");
|
||||
setError("WebSocket error");
|
||||
};
|
||||
|
||||
// Connect WebSocket
|
||||
const ws = connectWebSocket();
|
||||
return ws;
|
||||
}, [instanceId]);
|
||||
|
||||
// Handle terminal input
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
// Handle resize with debounce
|
||||
let resizeTimeout: ReturnType<typeof setTimeout>;
|
||||
const handleResize = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
const { cols, rows } = term;
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, 250);
|
||||
};
|
||||
// Initialize terminal
|
||||
const currentFontSize = calculateFontSize();
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: currentFontSize,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: 0,
|
||||
allowTransparency: false,
|
||||
theme: {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
selectionBackground: "#264f78",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#f14c4c",
|
||||
brightGreen: "#23d18b",
|
||||
brightYellow: "#f5f543",
|
||||
brightBlue: "#3b8eea",
|
||||
brightMagenta: "#d670d6",
|
||||
brightCyan: "#29b8db",
|
||||
brightWhite: "#e5e5e5",
|
||||
},
|
||||
});
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
termRef.current = term;
|
||||
|
||||
// Initial resize
|
||||
setTimeout(handleResize, 100);
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
// Notify parent about terminal readiness
|
||||
if (onTerminalReadyRef.current) {
|
||||
const sendData = (data: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
};
|
||||
const focusInput = () => {
|
||||
hiddenInputRef.current?.focus();
|
||||
};
|
||||
onTerminalReadyRef.current(sendData, status, focusInput);
|
||||
}
|
||||
const container = terminalRef.current;
|
||||
|
||||
// Visibility API for reconnection
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible" && ws.readyState !== WebSocket.OPEN) {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
connectWebSocket();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
// Define fitTerminal before connectWebSocket so it's available in onmessage
|
||||
const fitTerminal = () => {
|
||||
if (!fitAddonRef.current || !termRef.current) return;
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
} catch {
|
||||
// Ignore fit errors during initialization
|
||||
return;
|
||||
}
|
||||
const { cols, rows } = termRef.current;
|
||||
// Force refresh if dimensions are valid
|
||||
if (cols > 0 && rows > 0) {
|
||||
try {
|
||||
termRef.current.refresh(0, rows - 1);
|
||||
} catch {
|
||||
// Ignore refresh errors
|
||||
}
|
||||
}
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs?.readyState === WebSocket.OPEN && cols > 0 && rows > 0) {
|
||||
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
ws.close();
|
||||
term.dispose();
|
||||
};
|
||||
}, [instanceId, connectWebSocket, calculateFontSize]);
|
||||
// Open xterm first (must happen before fit)
|
||||
term.open(container);
|
||||
const ws = connectWebSocket();
|
||||
|
||||
// Update parent about status changes
|
||||
useEffect(() => {
|
||||
if (onTerminalReady && termRef.current) {
|
||||
const sendData = (data: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(data);
|
||||
}
|
||||
};
|
||||
const focusInput = () => {
|
||||
hiddenInputRef.current?.focus();
|
||||
};
|
||||
onTerminalReady(sendData, status, focusInput);
|
||||
}
|
||||
}, [status, onTerminalReady]);
|
||||
// Initial fit after layout settles (terminal must be opened first)
|
||||
let fitAttempts = 0;
|
||||
const doInitialFit = () => {
|
||||
if (!container.isConnected) return;
|
||||
fitAttempts++;
|
||||
// Ensure container has dimensions before fitting
|
||||
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
||||
fitTerminal();
|
||||
} else if (fitAttempts < 50) {
|
||||
// Container not ready yet, try again (max 50 attempts ~ 1s)
|
||||
requestAnimationFrame(doInitialFit);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(doInitialFit);
|
||||
|
||||
const handleFontSizeChange = (delta: number) => {
|
||||
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
|
||||
setFontSize(newSize);
|
||||
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
||||
if (termRef.current) {
|
||||
termRef.current.options.fontSize = newSize;
|
||||
fitAddonRef.current?.fit();
|
||||
}
|
||||
};
|
||||
// Refit after font load (metrics may change)
|
||||
document.fonts.ready.then(() => {
|
||||
requestAnimationFrame(() => fitTerminal());
|
||||
});
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!termRef.current) return;
|
||||
const selection = termRef.current.getSelection();
|
||||
if (selection) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selection);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = selection;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Handle terminal input
|
||||
term.onData((data) => {
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs?.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(text);
|
||||
}
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
};
|
||||
// Apply active modifier to single-character input
|
||||
const modifier = activeModifierRef.current;
|
||||
if (modifier && data.length === 1) {
|
||||
const modified = applyModifierToChar(data, modifier);
|
||||
if (modified) {
|
||||
currentWs.send(modified);
|
||||
onModifierChange?.(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus terminal on mobile to keep keyboard open
|
||||
const handleTerminalClick = () => {
|
||||
if (isMobile && termRef.current) {
|
||||
termRef.current.focus();
|
||||
}
|
||||
};
|
||||
currentWs.send(data);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-header-left">
|
||||
<div className="terminal-status">
|
||||
<span
|
||||
className={`status-dot ${status}`}
|
||||
aria-label={`Terminal status: ${status}`}
|
||||
/>
|
||||
<span className="status-text">
|
||||
{reconnectAttemptsRef.current > 0 && status !== "connected"
|
||||
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
|
||||
: status}
|
||||
</span>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
aria-label="Copy selection"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={handlePaste}
|
||||
type="button"
|
||||
aria-label="Paste from clipboard"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="terminal-header-right">
|
||||
{isMobile && (
|
||||
<>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={() => handleFontSizeChange(-1)}
|
||||
type="button"
|
||||
aria-label="Decrease font size"
|
||||
>
|
||||
A-
|
||||
</button>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={() => handleFontSizeChange(1)}
|
||||
type="button"
|
||||
aria-label="Increase font size"
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onClose && (
|
||||
<button className="terminal-close" onClick={onClose} type="button">
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="terminal-error">
|
||||
{error}
|
||||
{status === "error" && (
|
||||
<button
|
||||
className="terminal-reconnect"
|
||||
onClick={() => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
connectWebSocket();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="terminal-container"
|
||||
onClick={handleTerminalClick}
|
||||
/>
|
||||
{isMobile && (
|
||||
<input
|
||||
ref={hiddenInputRef}
|
||||
type="text"
|
||||
className="terminal-hidden-input"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// Handle container resize with ResizeObserver for accurate dimension tracking
|
||||
let resizeTimeout: ReturnType<typeof setTimeout>;
|
||||
let lastWidth = 0;
|
||||
let lastHeight = 0;
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
|
||||
const { width, height } = entry.contentRect;
|
||||
// Only trigger if dimensions actually changed
|
||||
if (width === lastWidth && height === lastHeight) return;
|
||||
lastWidth = width;
|
||||
lastHeight = height;
|
||||
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!container.isConnected) return;
|
||||
fitTerminal();
|
||||
});
|
||||
}, 50);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Window resize fallback (for viewport changes that don't affect container dimensions)
|
||||
let windowResizeTimeout: ReturnType<typeof setTimeout>;
|
||||
const handleWindowResize = () => {
|
||||
clearTimeout(windowResizeTimeout);
|
||||
windowResizeTimeout = setTimeout(() => {
|
||||
requestAnimationFrame(() => fitTerminal());
|
||||
}, 250);
|
||||
};
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
|
||||
// Refit after mobile header auto-hides (3s delay + 0.3s transition)
|
||||
const headerHideTimeout = setTimeout(() => {
|
||||
fitTerminal();
|
||||
}, 4000);
|
||||
|
||||
// Notify parent about terminal readiness
|
||||
if (onTerminalReadyRef.current) {
|
||||
const sendData = (data: string) => {
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||
currentWs.send(data);
|
||||
}
|
||||
};
|
||||
const focusInput = () => {
|
||||
termRef.current?.focus();
|
||||
};
|
||||
const changeFontSize = (delta: number) => {
|
||||
handleFontSizeChangeRef.current(delta);
|
||||
};
|
||||
onTerminalReadyRef.current(sendData, status, focusInput, changeFontSize);
|
||||
}
|
||||
|
||||
// Visibility API for reconnection
|
||||
const handleVisibilityChange = () => {
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
ws &&
|
||||
ws.readyState !== WebSocket.OPEN
|
||||
) {
|
||||
if (permanentErrorRef.current) {
|
||||
return;
|
||||
}
|
||||
reconnectAttemptsRef.current = 0;
|
||||
connectWebSocket();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
isUnmountingRef.current = true;
|
||||
clearTimeout(resizeTimeout);
|
||||
clearTimeout(windowResizeTimeout);
|
||||
clearTimeout(headerHideTimeout);
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", handleWindowResize);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
if (ws) {
|
||||
ws.close(1000, "Component unmounting");
|
||||
}
|
||||
if (heartbeatCheckRef.current) {
|
||||
window.clearInterval(heartbeatCheckRef.current);
|
||||
heartbeatCheckRef.current = null;
|
||||
}
|
||||
term.dispose();
|
||||
};
|
||||
}, [instanceId, connectWebSocket]);
|
||||
|
||||
// Update parent about status changes
|
||||
useEffect(() => {
|
||||
if (onTerminalReady && termRef.current) {
|
||||
const sendData = (data: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(data);
|
||||
}
|
||||
};
|
||||
const focusInput = () => {
|
||||
termRef.current?.focus();
|
||||
};
|
||||
const changeFontSize = (delta: number) => {
|
||||
handleFontSizeChangeRef.current(delta);
|
||||
};
|
||||
onTerminalReady(sendData, status, focusInput, changeFontSize);
|
||||
}
|
||||
}, [status, onTerminalReady]);
|
||||
|
||||
const handleFontSizeChange = (delta: number) => {
|
||||
const newSize = Math.max(
|
||||
MIN_FONT_SIZE,
|
||||
Math.min(MAX_FONT_SIZE, fontSize + delta),
|
||||
);
|
||||
setFontSize(newSize);
|
||||
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
||||
if (termRef.current && fitAddonRef.current) {
|
||||
termRef.current.options.fontSize = newSize;
|
||||
requestAnimationFrame(() => {
|
||||
if (termRef.current && fitAddonRef.current) {
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
const { cols, rows } = termRef.current;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols,
|
||||
rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore fit errors during re-initialization
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
handleFontSizeChangeRef.current = handleFontSizeChange;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!termRef.current) return;
|
||||
const selection = termRef.current.getSelection();
|
||||
if (selection) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selection);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = selection;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(text);
|
||||
}
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
};
|
||||
|
||||
// Focus terminal on mobile to keep keyboard open
|
||||
const handleTerminalClick = () => {
|
||||
if (isMobile && termRef.current) {
|
||||
termRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-header-left">
|
||||
<div className="terminal-status">
|
||||
<span
|
||||
className={`status-dot ${status}`}
|
||||
aria-label={`Terminal status: ${status}`}
|
||||
/>
|
||||
<span className="status-text">
|
||||
{status === "resetting"
|
||||
? "Resetting..."
|
||||
: reconnectAttemptsRef.current > 0 && status !== "connected"
|
||||
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
|
||||
: status}
|
||||
</span>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
aria-label="Copy selection"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={handlePaste}
|
||||
type="button"
|
||||
aria-label="Paste from clipboard"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="terminal-header-right">
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={() => handleFontSizeChange(-1)}
|
||||
type="button"
|
||||
aria-label="Decrease font size"
|
||||
>
|
||||
A-
|
||||
</button>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={() => handleFontSizeChange(1)}
|
||||
type="button"
|
||||
aria-label="Increase font size"
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
type="button"
|
||||
aria-label="Reset terminal"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
{onClose && (
|
||||
<button className="terminal-close" onClick={onClose} type="button">
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showResetConfirm && (
|
||||
<div className="terminal-reset-confirm">
|
||||
<div className="terminal-reset-confirm-content">
|
||||
<p>
|
||||
Reset terminal? This will kill the current shell session and start
|
||||
fresh.
|
||||
</p>
|
||||
<div className="terminal-reset-confirm-buttons">
|
||||
<button
|
||||
className="terminal-reset-confirm-button cancel"
|
||||
onClick={() => setShowResetConfirm(false)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="terminal-reset-confirm-button confirm"
|
||||
onClick={() => {
|
||||
setShowResetConfirm(false);
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "reset" }));
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="terminal-error">
|
||||
{error}
|
||||
{status === "error" && (
|
||||
<button
|
||||
className="terminal-reconnect"
|
||||
onClick={() => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
connectWebSocket();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="terminal-container"
|
||||
onClick={handleTerminalClick}
|
||||
/>
|
||||
{isMobile && (
|
||||
<input
|
||||
ref={hiddenInputRef}
|
||||
type="text"
|
||||
className="terminal-hidden-input"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
|
||||
export type SpecialKey =
|
||||
| "escape"
|
||||
@@ -29,6 +29,8 @@ export type SpecialKey =
|
||||
| "f11"
|
||||
| "f12";
|
||||
|
||||
export type ModifierKey = "ctrl" | "alt";
|
||||
|
||||
const KEY_SEQUENCES: Record<SpecialKey, string> = {
|
||||
escape: "\x1B",
|
||||
tab: "\t",
|
||||
@@ -59,22 +61,80 @@ const KEY_SEQUENCES: Record<SpecialKey, string> = {
|
||||
f12: "\x1B[24~",
|
||||
};
|
||||
|
||||
interface UseSpecialKeysOptions {
|
||||
onSend: (data: string) => void;
|
||||
// Single character sequences with modifier prefixes
|
||||
const MODIFIER_PREFIXES: Record<string, { ctrl: string; alt: string; ctrlAlt: string }> = {
|
||||
// Letters
|
||||
a: { ctrl: "\x01", alt: "\x1Ba", ctrlAlt: "\x1B\x01" },
|
||||
b: { ctrl: "\x02", alt: "\x1Bb", ctrlAlt: "\x1B\x02" },
|
||||
c: { ctrl: "\x03", alt: "\x1Bc", ctrlAlt: "\x1B\x03" },
|
||||
d: { ctrl: "\x04", alt: "\x1Bd", ctrlAlt: "\x1B\x04" },
|
||||
e: { ctrl: "\x05", alt: "\x1Be", ctrlAlt: "\x1B\x05" },
|
||||
f: { ctrl: "\x06", alt: "\x1Bf", ctrlAlt: "\x1B\x06" },
|
||||
g: { ctrl: "\x07", alt: "\x1Bg", ctrlAlt: "\x1B\x07" },
|
||||
h: { ctrl: "\x08", alt: "\x1Bh", ctrlAlt: "\x1B\x08" },
|
||||
i: { ctrl: "\x09", alt: "\x1Bi", ctrlAlt: "\x1B\x09" },
|
||||
j: { ctrl: "\x0A", alt: "\x1Bj", ctrlAlt: "\x1B\x0A" },
|
||||
k: { ctrl: "\x0B", alt: "\x1Bk", ctrlAlt: "\x1B\x0B" },
|
||||
l: { ctrl: "\x0C", alt: "\x1Bl", ctrlAlt: "\x1B\x0C" },
|
||||
m: { ctrl: "\x0D", alt: "\x1Bm", ctrlAlt: "\x1B\x0D" },
|
||||
n: { ctrl: "\x0E", alt: "\x1Bn", ctrlAlt: "\x1B\x0E" },
|
||||
o: { ctrl: "\x0F", alt: "\x1Bo", ctrlAlt: "\x1B\x0F" },
|
||||
p: { ctrl: "\x10", alt: "\x1Bp", ctrlAlt: "\x1B\x10" },
|
||||
q: { ctrl: "\x11", alt: "\x1Bq", ctrlAlt: "\x1B\x11" },
|
||||
r: { ctrl: "\x12", alt: "\x1Br", ctrlAlt: "\x1B\x12" },
|
||||
s: { ctrl: "\x13", alt: "\x1Bs", ctrlAlt: "\x1B\x13" },
|
||||
t: { ctrl: "\x14", alt: "\x1Bt", ctrlAlt: "\x1B\x14" },
|
||||
u: { ctrl: "\x15", alt: "\x1Bu", ctrlAlt: "\x1B\x15" },
|
||||
v: { ctrl: "\x16", alt: "\x1Bv", ctrlAlt: "\x1B\x16" },
|
||||
w: { ctrl: "\x17", alt: "\x1Bw", ctrlAlt: "\x1B\x17" },
|
||||
x: { ctrl: "\x18", alt: "\x1Bx", ctrlAlt: "\x1B\x18" },
|
||||
y: { ctrl: "\x19", alt: "\x1By", ctrlAlt: "\x1B\x19" },
|
||||
z: { ctrl: "\x1A", alt: "\x1Bz", ctrlAlt: "\x1B\x1A" },
|
||||
// Numbers
|
||||
"0": { ctrl: "0", alt: "\x1B0", ctrlAlt: "\x1B0" },
|
||||
"1": { ctrl: "1", alt: "\x1B1", ctrlAlt: "\x1B1" },
|
||||
"2": { ctrl: "\x00", alt: "\x1B2", ctrlAlt: "\x1B\x00" },
|
||||
"3": { ctrl: "\x1B", alt: "\x1B3", ctrlAlt: "\x1B\x1B" },
|
||||
"4": { ctrl: "\x1C", alt: "\x1B4", ctrlAlt: "\x1B\x1C" },
|
||||
"5": { ctrl: "\x1D", alt: "\x1B5", ctrlAlt: "\x1B\x1D" },
|
||||
"6": { ctrl: "\x1E", alt: "\x1B6", ctrlAlt: "\x1B\x1E" },
|
||||
"7": { ctrl: "\x1F", alt: "\x1B7", ctrlAlt: "\x1B\x1F" },
|
||||
"8": { ctrl: "\x7F", alt: "\x1B8", ctrlAlt: "\x1B\x7F" },
|
||||
"9": { ctrl: "9", alt: "\x1B9", ctrlAlt: "\x1B9" },
|
||||
};
|
||||
|
||||
export function getSequenceWithModifier(
|
||||
key: SpecialKey,
|
||||
activeModifier: ModifierKey | null
|
||||
): { sequence: string; clearModifier: boolean } | null {
|
||||
// Handle modifier keys (one-shot)
|
||||
if (key === "ctrl" || key === "alt") {
|
||||
return null; // Modifiers don't send anything themselves
|
||||
}
|
||||
|
||||
const sequence = KEY_SEQUENCES[key];
|
||||
if (!sequence) return null;
|
||||
|
||||
// Check if we have an active modifier and the key is a single character
|
||||
if (activeModifier && sequence.length === 1) {
|
||||
const char = sequence;
|
||||
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
|
||||
if (mapping) {
|
||||
return { sequence: mapping[activeModifier], clearModifier: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { sequence, clearModifier: !!activeModifier };
|
||||
}
|
||||
|
||||
export function useSpecialKeys({ onSend }: UseSpecialKeysOptions) {
|
||||
const sendKey = useCallback(
|
||||
(key: SpecialKey) => {
|
||||
const sequence = KEY_SEQUENCES[key];
|
||||
if (sequence) {
|
||||
onSend(sequence);
|
||||
}
|
||||
},
|
||||
[onSend]
|
||||
);
|
||||
|
||||
return { sendKey };
|
||||
export function applyModifierToChar(
|
||||
char: string,
|
||||
modifier: ModifierKey
|
||||
): string | null {
|
||||
if (char.length !== 1) return null;
|
||||
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
|
||||
if (!mapping) return null;
|
||||
return mapping[modifier];
|
||||
}
|
||||
|
||||
export { KEY_SEQUENCES };
|
||||
|
||||
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 { 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 { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
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 { SessionList } from "../components/session-list";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
@@ -30,9 +32,6 @@ export const HomePage = () => {
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
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 safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
@@ -59,6 +58,15 @@ export const HomePage = () => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
const {
|
||||
loadingSessionId: actionBusy,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleRecreateTunnel,
|
||||
} = useInstanceActions({ onRefresh: loadHome });
|
||||
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
@@ -125,71 +133,12 @@ export const HomePage = () => {
|
||||
[safeSessions]
|
||||
);
|
||||
|
||||
const recentSessions = useMemo(
|
||||
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
|
||||
[safeSessions]
|
||||
);
|
||||
|
||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSelectedProject("");
|
||||
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 (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
@@ -204,17 +153,9 @@ export const HomePage = () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading overview...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading overview..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<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 === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
|
||||
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
@@ -237,74 +178,20 @@ export const HomePage = () => {
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<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>
|
||||
{activeSessions.length === 0 ? (
|
||||
<p className="muted">No active sessions right now.</p>
|
||||
) : (
|
||||
<div className="home-session-grid">
|
||||
{activeSessions.map((session) => {
|
||||
const health = tunnelHealth[session.id];
|
||||
const isUnhealthy = health && !health.healthy;
|
||||
return (
|
||||
<article className="card session-card" key={session.id}>
|
||||
<div className="stack-sm">
|
||||
<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>
|
||||
)}
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={actionBusy}
|
||||
tunnelHealth={tunnelHealth}
|
||||
showGrouping={false}
|
||||
emptyMessage="No active sessions right now."
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<p className="muted">No projects yet.</p>
|
||||
<EmptyState message="No projects yet." />
|
||||
) : (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
@@ -350,25 +237,24 @@ export const HomePage = () => {
|
||||
/>
|
||||
</section>
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>{recentSessions.length}</h2>
|
||||
<h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-sessions-list">
|
||||
{recentSessions.map((session) => (
|
||||
<article 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.project_name} · {session.tool_type_name}</span>
|
||||
</div>
|
||||
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStart}
|
||||
onDelete={handleDelete}
|
||||
actionBusyId={actionBusy}
|
||||
showGrouping={false}
|
||||
maxRecent={5}
|
||||
emptyMessage="No recent sessions."
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
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 { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
export const GitHistoryPage = () => {
|
||||
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [commits, setCommits] = useState<CommitHistoryEntry[]>([]);
|
||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
|
||||
const [branches, setBranches] = 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 loadHistory = useCallback(async () => {
|
||||
if (!projectId || !repoId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
|
||||
setCommits(data.commits);
|
||||
setBranches(data.branches);
|
||||
if (data.branches.length > 0 && !selectedBranch) {
|
||||
setSelectedBranch(data.branches[0]);
|
||||
}
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId, repoId, selectedBranch]);
|
||||
const { data: historyData, status, reload } = useAsyncData<CommitHistoryResponse>(
|
||||
async () => {
|
||||
if (!projectId || !repoId) return { commits: [], branches: [], tags: [] };
|
||||
return await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
|
||||
},
|
||||
[projectId, repoId, selectedBranch]
|
||||
);
|
||||
|
||||
// Auto-select first branch when data loads
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
}, [loadHistory]);
|
||||
if (historyData?.branches.length && !selectedBranch) {
|
||||
setSelectedBranch(historyData.branches[0]);
|
||||
}
|
||||
}, [historyData?.branches, selectedBranch]);
|
||||
|
||||
const handleCommitClick = async (hash: string) => {
|
||||
if (!projectId || !repoId) return;
|
||||
@@ -61,7 +55,7 @@ export const GitHistoryPage = () => {
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p className="muted">Loading commit history...</p>
|
||||
<LoadingState message="Loading commit history..." />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -69,15 +63,14 @@ export const GitHistoryPage = () => {
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p>Failed to load commit history</p>
|
||||
<button className="secondary-button" onClick={() => void loadHistory()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
<ErrorState message="Failed to load commit history" onRetry={reload} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const commits = historyData?.commits ?? [];
|
||||
const branches = historyData?.branches ?? [];
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<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 {
|
||||
@@ -6,48 +6,38 @@ import {
|
||||
listRepositories,
|
||||
} 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 { RepositoryCreateDialog } from "../components/repository-create-dialog";
|
||||
|
||||
type RepoStatus = "loading" | "ready" | "error";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
export const GitRepositoriesPage = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<RepoStatus>("loading");
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
const { data: repositories, status, reload } = useAsyncData<GitRepository[]>(
|
||||
async () => {
|
||||
if (!projectId) return [];
|
||||
return await listRepositories(projectId);
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const handleDelete = async (repoId: string) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
await deleteRepository(projectId, repoId);
|
||||
setDeleteConfirmId(null);
|
||||
await loadRepositories();
|
||||
reload();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && repositories.length === 0;
|
||||
const safeRepositories = repositories ?? [];
|
||||
const isEmpty = status === "ready" && safeRepositories.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -59,23 +49,15 @@ export const GitRepositoriesPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading repositories...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading repositories..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<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>
|
||||
)}
|
||||
{status === "error" && <ErrorState message="Failed to load repositories" onRetry={reload} />}
|
||||
|
||||
{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">
|
||||
{repositories.map((repo) => (
|
||||
{safeRepositories.map((repo) => (
|
||||
<article className="card repository-card" key={repo.id}>
|
||||
<div className="repository-info">
|
||||
<h3>{repo.name}</h3>
|
||||
@@ -131,7 +113,7 @@ export const GitRepositoriesPage = () => {
|
||||
open={showCreate}
|
||||
title="Create Repository"
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadRepositories}
|
||||
onCreated={reload}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAuth } from "../state/auth";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { UserProfile } from "../api/profile";
|
||||
|
||||
type ProfileStatus = "loading" | "ready" | "error" | "saving";
|
||||
|
||||
export const ProfilePage = () => {
|
||||
const { refreshSession } = useAuth();
|
||||
const [status, setStatus] = useState<ProfileStatus>("loading");
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []);
|
||||
const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getProfile();
|
||||
setProfile(data);
|
||||
setName(data.name);
|
||||
setEmail(data.email);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setProfile(null);
|
||||
setStatus("error");
|
||||
// Sync loaded profile into form fields
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setName(profile.name);
|
||||
setEmail(profile.email);
|
||||
setDisplayStatus("ready");
|
||||
setError(null);
|
||||
}
|
||||
}, []);
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfile();
|
||||
}, [loadProfile]);
|
||||
if (loadStatus === "error") {
|
||||
setDisplayStatus("error");
|
||||
}
|
||||
}, [loadStatus]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) {
|
||||
@@ -45,16 +44,15 @@ export const ProfilePage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("saving");
|
||||
setDisplayStatus("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateProfile({ name: name.trim(), email: email.trim() });
|
||||
setProfile(updated);
|
||||
await updateProfile({ name: name.trim(), email: email.trim() });
|
||||
await refreshSession();
|
||||
setStatus("ready");
|
||||
setDisplayStatus("ready");
|
||||
} catch {
|
||||
setError("Failed to update profile");
|
||||
setStatus("ready");
|
||||
setDisplayStatus("ready");
|
||||
}
|
||||
}, [name, email, refreshSession]);
|
||||
|
||||
@@ -73,19 +71,19 @@ export const ProfilePage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("saving");
|
||||
setDisplayStatus("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await uploadAvatar(file);
|
||||
setProfile(updated);
|
||||
await uploadAvatar(file);
|
||||
await refreshSession();
|
||||
setStatus("ready");
|
||||
reload();
|
||||
setDisplayStatus("ready");
|
||||
} catch {
|
||||
setError("Failed to upload avatar");
|
||||
setStatus("ready");
|
||||
setDisplayStatus("ready");
|
||||
}
|
||||
},
|
||||
[refreshSession]
|
||||
[refreshSession, reload]
|
||||
);
|
||||
|
||||
const avatarUrl = profile?.avatar_url ?? null;
|
||||
@@ -94,19 +92,11 @@ export const ProfilePage = () => {
|
||||
<section className="stack">
|
||||
<h1>Profile</h1>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading profile...</p>}
|
||||
{displayStatus === "loading" && <LoadingState message="Loading profile..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<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>
|
||||
)}
|
||||
{displayStatus === "error" && <ErrorState message="Failed to load profile" onRetry={reload} />}
|
||||
|
||||
{(status === "ready" || status === "saving") && profile && (
|
||||
{(displayStatus === "ready" || displayStatus === "saving") && profile && (
|
||||
<div className="card stack">
|
||||
<div className="profile-avatar-section">
|
||||
<div className="avatar-preview">
|
||||
@@ -118,11 +108,11 @@ export const ProfilePage = () => {
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
{status === "saving" ? (
|
||||
{displayStatus === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Uploading...
|
||||
@@ -146,7 +136,7 @@ export const ProfilePage = () => {
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-name">Name</label>
|
||||
<input
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
id="profile-name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
type="text"
|
||||
@@ -157,7 +147,7 @@ export const ProfilePage = () => {
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-email">Email</label>
|
||||
<input
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
id="profile-email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="email"
|
||||
@@ -170,11 +160,11 @@ export const ProfilePage = () => {
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
onClick={() => void handleSave()}
|
||||
type="button"
|
||||
>
|
||||
{status === "saving" ? (
|
||||
{displayStatus === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Saving...
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -10,15 +10,15 @@ import {
|
||||
type ProjectCreateInput,
|
||||
type ProjectUpdateInput,
|
||||
} from "../api/projects";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { Project } from "../types";
|
||||
|
||||
type ProjectsStatus = "loading" | "ready" | "error";
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const [status, setStatus] = useState<ProjectsStatus>("loading");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const { data: projects, status, reload } = useAsyncData<Project[]>(listProjects, []);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [formName, setFormName] = useState("");
|
||||
@@ -26,21 +26,7 @@ export const ProjectsPage = () => {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setProjects([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects();
|
||||
}, [loadProjects]);
|
||||
const safeProjects = projects ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
@@ -88,7 +74,7 @@ export const ProjectsPage = () => {
|
||||
await updateProject(editingProject.id, input);
|
||||
}
|
||||
closeDialog();
|
||||
await loadProjects();
|
||||
reload();
|
||||
} catch {
|
||||
setFormError("Failed to save project");
|
||||
}
|
||||
@@ -98,13 +84,13 @@ export const ProjectsPage = () => {
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setDeleteConfirmId(null);
|
||||
await loadProjects();
|
||||
reload();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && projects.length === 0;
|
||||
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -116,23 +102,15 @@ export const ProjectsPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading projects...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading projects..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<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>
|
||||
)}
|
||||
{status === "error" && <ErrorState message="Failed to load projects" onRetry={reload} />}
|
||||
|
||||
{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">
|
||||
{projects.map((project) => (
|
||||
{safeProjects.map((project) => (
|
||||
<article className="card project-card" key={project.id}>
|
||||
<div className="project-info">
|
||||
<h3>{project.name}</h3>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
|
||||
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 type { ToolType } from "../api/tool_types";
|
||||
|
||||
type MobileTab = "files" | "editor" | "git" | "terminal";
|
||||
|
||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||
|
||||
interface FileTreeEntry {
|
||||
@@ -43,6 +47,8 @@ interface Project {
|
||||
export const RepoWorkspace = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isMobile = useMobileViewport();
|
||||
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
|
||||
|
||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
@@ -159,26 +165,16 @@ export const RepoWorkspace = () => {
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="muted">Loading repositories...</p>
|
||||
<LoadingState message="Loading repositories..." />
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<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>
|
||||
<ErrorState message="Failed to load repositories" onRetry={() => void loadRepositories()} />
|
||||
)}
|
||||
|
||||
{status === "empty" && (
|
||||
<div className="card stack">
|
||||
<p>No repositories in this project yet.</p>
|
||||
<EmptyState message="No repositories in this project yet." />
|
||||
<Link
|
||||
className="primary-button"
|
||||
to={`/projects/${projectId}/settings/repositories`}
|
||||
@@ -190,67 +186,70 @@ export const RepoWorkspace = () => {
|
||||
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<>
|
||||
{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
|
||||
{isMobile ? (
|
||||
// Mobile Layout
|
||||
<div className="mobile-workspace">
|
||||
<div className="mobile-workspace-header">
|
||||
<select
|
||||
value={selectedRepoId || ""}
|
||||
onChange={(e) => handleRepoChange(e.target.value)}
|
||||
className="mobile-repo-selector"
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedRepoId && (
|
||||
<select
|
||||
value={selectedRepoId || ""}
|
||||
onChange={(e) => handleRepoChange(e.target.value)}
|
||||
value={currentBranch}
|
||||
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) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
{branches.map((branch) => (
|
||||
<option key={branch} value={branch}>
|
||||
{branch}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRepoId && (
|
||||
<>
|
||||
<div className="mobile-workspace-content">
|
||||
{mobileTab === "files" && 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"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
@@ -259,17 +258,126 @@ export const RepoWorkspace = () => {
|
||||
toolTypes={toolTypes}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<main className="workspace-main">
|
||||
{selectedRepoId && (
|
||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
<div className="mobile-workspace-tabs">
|
||||
<button
|
||||
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
|
||||
onClick={() => setMobileTab("files")}
|
||||
type="button"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
@@ -369,7 +477,7 @@ const FileBrowser = ({
|
||||
</button>
|
||||
)}
|
||||
{entries.length === 0 && (
|
||||
<p className="muted">No files in this repository yet.</p>
|
||||
<EmptyState message="No files in this repository yet." />
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||
|
||||
+45
-413
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { listProjects } from "../api/projects";
|
||||
import type { Project } from "../types";
|
||||
@@ -7,20 +6,20 @@ import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import {
|
||||
getUserSessions,
|
||||
type Session,
|
||||
deleteInstance,
|
||||
stopInstance,
|
||||
checkInstanceHealth,
|
||||
recreateInstanceTunnel,
|
||||
} from "../api/sessions";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
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 { 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";
|
||||
|
||||
export const SessionsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||
@@ -30,25 +29,7 @@ export const SessionsPage = () => {
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||
|
||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||
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 [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
@@ -93,13 +74,25 @@ export const SessionsPage = () => {
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
loadingSessionId,
|
||||
dirtyDeleteSession,
|
||||
dirtyDeleteFiles,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
clearDirtyDelete,
|
||||
} = useInstanceActions({ onRefresh: loadSessions });
|
||||
|
||||
|
||||
// Poll health every 30 seconds for active instances
|
||||
// Poll health every 30 seconds for active web-enabled instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const activeSessions = sessions.filter(
|
||||
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
|
||||
&& s.tool_type_interfaces?.includes("web")
|
||||
);
|
||||
for (const session of activeSessions) {
|
||||
try {
|
||||
@@ -124,7 +117,7 @@ export const SessionsPage = () => {
|
||||
probe_status: "unknown",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
},
|
||||
} as InstanceHealth,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -152,16 +145,6 @@ export const SessionsPage = () => {
|
||||
void loadRepos();
|
||||
}, [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(
|
||||
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
||||
[sessions, lastSessionId]
|
||||
@@ -173,104 +156,15 @@ export const SessionsPage = () => {
|
||||
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 (
|
||||
<section className="stack sessions-page">
|
||||
<div className="page-header">
|
||||
<h1>Sessions</h1>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading sessions...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<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 === "error" && <ErrorState message="Failed to load sessions" onRetry={() => void loadSessions()} />}
|
||||
|
||||
{status === "ready" && (
|
||||
<>
|
||||
@@ -278,285 +172,30 @@ export const SessionsPage = () => {
|
||||
{lastSession && (
|
||||
<div className="last-session-section">
|
||||
<h2>Last Session</h2>
|
||||
<div className="card last-session-card">
|
||||
<div className="last-session-info">
|
||||
<h3>{lastSession.display_name}</h3>
|
||||
<p className="muted">
|
||||
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
|
||||
</p>
|
||||
{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>
|
||||
<SessionCard
|
||||
session={lastSession}
|
||||
onOpen={handleOpen}
|
||||
onDelete={handleDelete}
|
||||
isBusy={loadingSessionId === lastSession.id}
|
||||
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Sessions */}
|
||||
<div className={`active-sessions-section ${loadingSessionId ? "dimmed" : ""}`}>
|
||||
{loadingSessionId && (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-content">
|
||||
<Icon name="loading" size="lg" />
|
||||
<p>{loadingAction}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h2>
|
||||
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) => (
|
||||
<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.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>
|
||||
)}
|
||||
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
||||
<span className="status-badge error">tunnel error</span>
|
||||
)}
|
||||
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
|
||||
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{/* Session List */}
|
||||
<div className="sessions-list-wrapper">
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={loadingSessionId}
|
||||
tunnelHealth={tunnelHealth}
|
||||
/>
|
||||
</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 */}
|
||||
<div className="create-session-section">
|
||||
<h2>Create New Session</h2>
|
||||
@@ -573,7 +212,7 @@ export const SessionsPage = () => {
|
||||
|
||||
{/* Dirty Delete Confirmation Modal */}
|
||||
{dirtyDeleteSession && (
|
||||
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
|
||||
<div className="modal-overlay" onClick={clearDirtyDelete}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Uncommitted Changes</h3>
|
||||
<p>
|
||||
@@ -592,21 +231,14 @@ export const SessionsPage = () => {
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setDirtyDeleteSession(null)}
|
||||
onClick={clearDirtyDelete}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() =>
|
||||
void handleDelete(
|
||||
dirtyDeleteSession.id,
|
||||
dirtyDeleteSession.project_id,
|
||||
dirtyDeleteSession.repository_id,
|
||||
true
|
||||
)
|
||||
}
|
||||
onClick={() => void handleForceDelete(dirtyDeleteSession)}
|
||||
type="button"
|
||||
>
|
||||
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 { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
type SettingsStatus = "loading" | "ready" | "error";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
const TABS = [
|
||||
{ label: "General", path: "general" },
|
||||
@@ -26,7 +26,7 @@ type SettingsOutletContext = {
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
||||
const { data: loadedConfig, status, reload } = useAsyncData<UserConfig>(getUserConfig, []);
|
||||
const [config, setConfig] = useState<UserConfig>({
|
||||
theme: "system",
|
||||
default_editor: null,
|
||||
@@ -36,19 +36,12 @@ export const SettingsPage = () => {
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserConfig();
|
||||
setConfig(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync loaded config into local editable state
|
||||
useEffect(() => {
|
||||
void loadConfig();
|
||||
}, [loadConfig]);
|
||||
if (loadedConfig) {
|
||||
setConfig(loadedConfig);
|
||||
}
|
||||
}, [loadedConfig]);
|
||||
|
||||
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -79,17 +72,13 @@ export const SettingsPage = () => {
|
||||
};
|
||||
|
||||
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") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p>Failed to load settings</p>
|
||||
<button className="secondary-button" onClick={() => void loadConfig()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
<ErrorState message="Failed to load settings" onRetry={reload} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -104,7 +93,7 @@ export const SettingsPage = () => {
|
||||
<p className="eyebrow">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<p className="muted">General preferences and SSH keys.</p>
|
||||
<p className="muted">General preferences, SSH keys, and config profiles.</p>
|
||||
</header>
|
||||
|
||||
<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 { 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 { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
export const SSHKeysPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||
@@ -17,23 +17,9 @@ export const SSHKeysPage = () => {
|
||||
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
||||
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
||||
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
const safeKeys = keys ?? [];
|
||||
|
||||
async function handleGenerate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -45,7 +31,7 @@ export const SSHKeysPage = () => {
|
||||
setNewKeyName("");
|
||||
await loadKeys();
|
||||
} catch {
|
||||
setError("Failed to generate SSH key");
|
||||
setMutationError("Failed to generate SSH key");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
@@ -58,7 +44,7 @@ export const SSHKeysPage = () => {
|
||||
await deleteSSHKey(keyId);
|
||||
await loadKeys();
|
||||
} 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 }));
|
||||
const result = await signPayload(keyId, { payload: payload.trim() });
|
||||
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
||||
setError(null);
|
||||
setMutationError(null);
|
||||
} catch {
|
||||
setError("Failed to sign payload");
|
||||
setMutationError("Failed to sign payload");
|
||||
} finally {
|
||||
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
||||
}
|
||||
@@ -94,15 +80,15 @@ export const SSHKeysPage = () => {
|
||||
signature: signature.trim(),
|
||||
});
|
||||
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
||||
setError(null);
|
||||
setMutationError(null);
|
||||
} catch {
|
||||
setError("Failed to verify signature");
|
||||
setMutationError("Failed to verify signature");
|
||||
} finally {
|
||||
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -116,7 +102,7 @@ export const SSHKeysPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{mutationError && <div className="error">{mutationError}</div>}
|
||||
|
||||
<form onSubmit={handleGenerate} className="stack">
|
||||
<div className="form-group">
|
||||
@@ -145,11 +131,13 @@ export const SSHKeysPage = () => {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
|
||||
|
||||
<div className="keys-list">
|
||||
{keys.length === 0 ? (
|
||||
<p className="muted">No SSH keys yet. Generate one above.</p>
|
||||
{safeKeys.length === 0 ? (
|
||||
<EmptyState message="No SSH keys yet. Generate one above." />
|
||||
) : (
|
||||
keys.map((key) => (
|
||||
safeKeys.map((key) => (
|
||||
<div key={key.id} className="key-card">
|
||||
<div className="key-header">
|
||||
<h3>{key.name}</h3>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
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 {
|
||||
createToolType,
|
||||
deleteToolType,
|
||||
@@ -31,7 +38,11 @@ import {
|
||||
type RightPanelTab = "details" | "configs" | "folders";
|
||||
type Status = "loading" | "ready" | "error";
|
||||
|
||||
type MobileView = "list" | "detail" | "edit";
|
||||
|
||||
export const ToolWorkshopPage = () => {
|
||||
const isMobile = useMobileViewport();
|
||||
const [mobileView, setMobileView] = useState<MobileView>("list");
|
||||
const [status, setStatus] = useState<Status>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||
@@ -58,6 +69,7 @@ export const ToolWorkshopPage = () => {
|
||||
readiness_timeout: "30",
|
||||
readiness_interval: "2",
|
||||
required_variables: "",
|
||||
startup_command: "",
|
||||
});
|
||||
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
||||
const [toolTypeDirty, setToolTypeDirty] = useState(false);
|
||||
@@ -131,6 +143,7 @@ export const ToolWorkshopPage = () => {
|
||||
readiness_timeout: "30",
|
||||
readiness_interval: "2",
|
||||
required_variables: "",
|
||||
startup_command: "",
|
||||
});
|
||||
setToolTypeError(null);
|
||||
setToolTypeDirty(false);
|
||||
@@ -152,6 +165,7 @@ export const ToolWorkshopPage = () => {
|
||||
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
|
||||
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
|
||||
required_variables: toolType.required_variables?.join(", ") || "",
|
||||
startup_command: toolType.startup_command || "",
|
||||
});
|
||||
setToolTypeError(null);
|
||||
setToolTypeDirty(false);
|
||||
@@ -189,16 +203,6 @@ export const ToolWorkshopPage = () => {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setToolTypeError(null);
|
||||
@@ -250,6 +254,7 @@ export const ToolWorkshopPage = () => {
|
||||
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
||||
readiness_probe: readinessProbe,
|
||||
required_variables: variables,
|
||||
startup_command: toolTypeForm.startup_command.trim() || undefined,
|
||||
};
|
||||
const newTool = await createToolType(input);
|
||||
setIsCreating(false);
|
||||
@@ -268,6 +273,7 @@ export const ToolWorkshopPage = () => {
|
||||
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
||||
readiness_probe: readinessProbe,
|
||||
required_variables: variables,
|
||||
startup_command: toolTypeForm.startup_command.trim() || undefined,
|
||||
};
|
||||
await updateToolType(selectedToolType.id, input);
|
||||
setToolTypeDirty(false);
|
||||
@@ -486,7 +492,7 @@ export const ToolWorkshopPage = () => {
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p>Loading Tool Workshop...</p>
|
||||
<LoadingState message="Loading Tool Workshop..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -494,14 +500,264 @@ export const ToolWorkshopPage = () => {
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p className="text-error">Failed to load Tool Workshop.</p>
|
||||
<button onClick={loadData}>
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
<ErrorState message="Failed to load Tool Workshop." onRetry={loadData} />
|
||||
</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 (
|
||||
<div className="container" style={{ display: "flex", height: "calc(100vh - 4rem)", gap: 0, padding: 0 }}>
|
||||
{/* Left Sidebar - Tool List */}
|
||||
@@ -772,6 +1028,24 @@ export const ToolWorkshopPage = () => {
|
||||
</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 && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-default-port">Default Port *</label>
|
||||
@@ -1029,7 +1303,7 @@ export const ToolWorkshopPage = () => {
|
||||
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{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) => (
|
||||
<div
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
||||
import { TerminalPage } from "./pages/terminal";
|
||||
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||
import { ConfigProfilesPage } from "./pages/config-profiles";
|
||||
import { SessionsPage } from "./pages/sessions";
|
||||
|
||||
export const AppRouter = () => {
|
||||
@@ -36,6 +37,7 @@ export const AppRouter = () => {
|
||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
||||
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />}>
|
||||
<Route index element={<Navigate to="general" replace />} />
|
||||
<Route path="general" element={<GeneralSettingsTab />} />
|
||||
|
||||
+879
-25
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";
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user