Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e99e7f197 | |||
| e46b4f9249 | |||
| 5deee8c65c | |||
| 314ba3aee4 | |||
| 18e4a89573 | |||
| 29943ac239 | |||
| 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 | |||
| 9e88acaa36 |
@@ -49,3 +49,5 @@ apps/web/dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
/.stoneforge/.worktrees/
|
/.stoneforge/.worktrees/
|
||||||
|
# Local Pi runtime state
|
||||||
|
.atl/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+1
File diff suppressed because one or more lines are too long
@@ -7,8 +7,6 @@ Create Date: 2026-05-22 21:50:00.000000
|
|||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "0014_merge_heads"
|
revision: str = "0014_merge_heads"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "0015_single_interface"
|
revision: str = "0015_single_interface"
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""add pi agent tool type
|
||||||
|
|
||||||
|
Revision ID: 20260527_160017_add_pi_agent
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-27T16:00:17
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "20260527_160017_add_pi_agent"
|
||||||
|
down_revision: Union[str, None] = "2026_05_27_external_repos"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
PI_AGENT_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Check if pi-agent already exists
|
||||||
|
conn = op.get_bind()
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
INSERT INTO tool_types (
|
||||||
|
id, name, display_name, description, category,
|
||||||
|
interface_type, requires_port, default_port,
|
||||||
|
definition_type, compose_template, dockerfile_template, required_variables,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:id, :name, :display_name, :description, :category,
|
||||||
|
:interface_type, :requires_port, :default_port,
|
||||||
|
:definition_type, :compose_template, :dockerfile_template, :required_variables,
|
||||||
|
now(), now()
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": PI_AGENT_ID,
|
||||||
|
"name": "pi-agent",
|
||||||
|
"display_name": "Pi Agent",
|
||||||
|
"description": "Pi coding agent terminal environment with nvim, ranger, and tmux",
|
||||||
|
"category": "development",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"compose_template": """services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
volumes:
|
||||||
|
- ${REPO_PATH}:/workspace
|
||||||
|
working_dir: /workspace
|
||||||
|
command: /bin/bash""",
|
||||||
|
"dockerfile_template": """# Pi Coding Agent - Terminal-based coding harness
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \\
|
||||||
|
curl \\
|
||||||
|
wget \\
|
||||||
|
git \\
|
||||||
|
neovim \\
|
||||||
|
ranger \\
|
||||||
|
tmux \\
|
||||||
|
htop \\
|
||||||
|
tree \\
|
||||||
|
jq \\
|
||||||
|
ca-certificates \\
|
||||||
|
python3 \\
|
||||||
|
python3-pip \\
|
||||||
|
build-essential \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js (required for Pi)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
|
||||||
|
&& apt-get install -y nodejs \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Pi Coding Agent globally
|
||||||
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main \\
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \\
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
# Create default tmux config
|
||||||
|
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
|
# Create default ranger config
|
||||||
|
RUN mkdir -p /home/user/.config/ranger \\
|
||||||
|
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
|
# Set up Pi config directory
|
||||||
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
# Default to bash (Pi is invoked manually via `pi` command)
|
||||||
|
CMD ["/bin/bash"]""",
|
||||||
|
"required_variables": json.dumps(["REPO_PATH"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(
|
||||||
|
sa.text("DELETE FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add startup_command to tool_types
|
||||||
|
|
||||||
|
Revision ID: 2026_05_24_220141
|
||||||
|
Revises: 6fc7bfcf199f
|
||||||
|
Create Date: 2026-05-24 22:01:41.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_24_220141"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "6fc7bfcf199f"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_types",
|
||||||
|
sa.Column("startup_command", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_types", "startup_command")
|
||||||
@@ -0,0 +1,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,381 @@
|
|||||||
|
"""add tool definition manifests
|
||||||
|
|
||||||
|
Revision ID: 2026_05_28_add_tool_definition_manifests
|
||||||
|
Revises: 20260527_160017_add_pi_agent
|
||||||
|
Create Date: 2026-05-28T11:00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_28_add_tool_definition_manifests"
|
||||||
|
down_revision: Union[str, None] = "20260527_160017_add_pi_agent"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
BASE_UBUNTU_ID = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
||||||
|
PI_AGENT_MANIFEST_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# ── Create tool_definition_manifests table ───────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"tool_definition_manifests",
|
||||||
|
sa.Column("id", sa.UUID(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(64), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(128), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("category", sa.String(64), nullable=True),
|
||||||
|
sa.Column("interface_type", sa.String(16), nullable=False),
|
||||||
|
sa.Column("base_image", sa.String(256), nullable=True),
|
||||||
|
sa.Column("base_definition_id", sa.UUID(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"base_version", sa.String(32), nullable=False, server_default="latest"
|
||||||
|
),
|
||||||
|
sa.Column("manifest", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("dockerfile_cache", sa.Text(), nullable=True),
|
||||||
|
sa.Column("compose_cache", sa.Text(), nullable=True),
|
||||||
|
sa.Column("version", sa.String(32), nullable=False, server_default="v1"),
|
||||||
|
sa.Column("is_base", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("created_by_id", sa.UUID(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("name"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["base_definition_id"], ["tool_definition_manifests.id"]
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_id"], ["users.id"]),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"(base_image IS NOT NULL) OR (base_definition_id IS NOT NULL)",
|
||||||
|
name="ck_tool_definition_manifests_base_required",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Add columns to tool_types ────────────────────────────────────
|
||||||
|
# Check if manifest_id exists before adding
|
||||||
|
conn = op.get_bind()
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_types' AND column_name = 'manifest_id'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if not result.fetchone():
|
||||||
|
op.add_column("tool_types", sa.Column("manifest_id", sa.UUID(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_tool_types_manifest_id",
|
||||||
|
"tool_types",
|
||||||
|
"tool_definition_manifests",
|
||||||
|
["manifest_id"],
|
||||||
|
["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update definition_type to allow 'legacy' and 'manifest'
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT constraint_name FROM information_schema.check_constraints
|
||||||
|
WHERE constraint_name = 'chk_definition_type'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if result.fetchone():
|
||||||
|
op.drop_constraint("chk_definition_type", "tool_types", type_="check")
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE tool_types ALTER COLUMN definition_type TYPE VARCHAR(16)")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE tool_types ALTER COLUMN definition_type SET DEFAULT 'legacy'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Add columns to tool_instances ────────────────────────────────
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances' AND column_name = 'manifest_compiled_at'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if not result.fetchone():
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column(
|
||||||
|
"manifest_compiled_at", sa.TIMESTAMP(timezone=True), nullable=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances' AND column_name = 'image_tag'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if not result.fetchone():
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("image_tag", sa.String(256), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Data migration: create base definition + pi-agent manifest ───
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO tool_definition_manifests
|
||||||
|
(id, name, display_name, description, interface_type, base_image,
|
||||||
|
manifest, is_base, version, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(:base_id, 'ubuntu-24.04-dev', 'Ubuntu 24.04 Dev Base',
|
||||||
|
'Base development environment with build tools', 'terminal',
|
||||||
|
'ubuntu:24.04', :base_manifest, true, 'v1', now(), now())
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"base_id": BASE_UBUNTU_ID,
|
||||||
|
"base_manifest": json.dumps(
|
||||||
|
{
|
||||||
|
"name": "ubuntu-24.04-dev",
|
||||||
|
"display_name": "Ubuntu 24.04 Dev Base",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"packages": {
|
||||||
|
"apt": [
|
||||||
|
"curl",
|
||||||
|
"wget",
|
||||||
|
"git",
|
||||||
|
"build-essential",
|
||||||
|
"ca-certificates",
|
||||||
|
"python3",
|
||||||
|
"python3-pip",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"uid": 1000,
|
||||||
|
"gid": 1000,
|
||||||
|
"create_home": True,
|
||||||
|
"shell": "/bin/bash",
|
||||||
|
},
|
||||||
|
"env": {"DEBIAN_FRONTEND": "noninteractive"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO tool_definition_manifests
|
||||||
|
(id, name, display_name, description, category, interface_type,
|
||||||
|
base_definition_id, base_version, manifest, version, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(:manifest_id, 'pi-agent', 'Pi Agent',
|
||||||
|
'Terminal-based coding harness with nvim, ranger, tmux',
|
||||||
|
'development', 'terminal', :base_id, 'v1', :manifest, 'v1',
|
||||||
|
now(), now())
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"manifest_id": PI_AGENT_MANIFEST_ID,
|
||||||
|
"base_id": BASE_UBUNTU_ID,
|
||||||
|
"manifest": json.dumps(
|
||||||
|
{
|
||||||
|
"name": "pi-agent",
|
||||||
|
"display_name": "Pi Agent",
|
||||||
|
"description": "Terminal-based coding harness",
|
||||||
|
"category": "development",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"base_definition_id": str(BASE_UBUNTU_ID),
|
||||||
|
"base_version": "v1",
|
||||||
|
"packages": {
|
||||||
|
"apt": [
|
||||||
|
"neovim",
|
||||||
|
"ranger",
|
||||||
|
"tmux",
|
||||||
|
"htop",
|
||||||
|
"tree",
|
||||||
|
"jq",
|
||||||
|
],
|
||||||
|
"node": {"version": "20"},
|
||||||
|
"npm_global": ["@earendil-works/pi-coding-agent"],
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"uid": 1001,
|
||||||
|
"gid": 1001,
|
||||||
|
"create_home": True,
|
||||||
|
"shell": "/bin/bash",
|
||||||
|
},
|
||||||
|
"env": {"DEBIAN_FRONTEND": "noninteractive"},
|
||||||
|
"scripts": {
|
||||||
|
"build": [
|
||||||
|
"git config --global init.defaultBranch main && git config --global user.email 'dev@headquarter.local' && git config --global user.name 'Developer'",
|
||||||
|
"mkdir -p /home/user/.config/ranger && echo 'set preview_files true' > /home/user/.config/ranger/rc.conf",
|
||||||
|
],
|
||||||
|
"startup": [
|
||||||
|
"if [ -d /workspace ]; then sudo chown -R user:user /workspace 2>/dev/null || true; fi",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"name": "workspace",
|
||||||
|
"target": "/workspace",
|
||||||
|
"source_type": "repo",
|
||||||
|
"writable": True,
|
||||||
|
"owner": "user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ssh_keys",
|
||||||
|
"target": "/home/user/.ssh",
|
||||||
|
"source_type": "ssh_key",
|
||||||
|
"mode": "0700",
|
||||||
|
"file_mode": "0600",
|
||||||
|
"readonly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pi_state",
|
||||||
|
"target": "/tmp/.pi/agents",
|
||||||
|
"source_type": "instance",
|
||||||
|
"writable": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pi_config",
|
||||||
|
"target": "/home/user/.pi",
|
||||||
|
"source_type": "git_mount",
|
||||||
|
"git_mount_ref": "dotfiles",
|
||||||
|
"writable": True,
|
||||||
|
"owner": "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"runtime": {
|
||||||
|
"command": ["/bin/bash"],
|
||||||
|
"stdin_open": True,
|
||||||
|
"tty": True,
|
||||||
|
"working_dir": "/workspace",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Update existing pi-agent tool_type ───────────────────────────
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET manifest_id = :manifest_id,
|
||||||
|
definition_type = 'manifest',
|
||||||
|
dockerfile_template = NULL,
|
||||||
|
compose_template = NULL
|
||||||
|
WHERE name = 'pi-agent'
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"manifest_id": PI_AGENT_MANIFEST_ID},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Restore pi-agent templates if manifest_id column exists
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_types' AND column_name = 'manifest_id'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
has_manifest_id = result.fetchone() is not None
|
||||||
|
|
||||||
|
if has_manifest_id:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET manifest_id = NULL,
|
||||||
|
definition_type = 'dockerfile',
|
||||||
|
dockerfile_template = :dockerfile,
|
||||||
|
compose_template = :compose
|
||||||
|
WHERE name = 'pi-agent'
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"dockerfile": """# Pi Coding Agent - Terminal-based coding harness
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
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/*
|
||||||
|
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
|
||||||
|
&& apt-get install -y nodejs \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
RUN git config --global init.defaultBranch main \\
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \\
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
|
RUN mkdir -p /home/user/.config/ranger \\
|
||||||
|
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
CMD ["/bin/bash"]
|
||||||
|
""",
|
||||||
|
"compose": """services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
volumes:
|
||||||
|
- ${REPO_PATH}:/workspace
|
||||||
|
working_dir: /workspace
|
||||||
|
command: /bin/bash""",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop columns conditionally
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances' AND column_name = 'image_tag'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if result.fetchone():
|
||||||
|
op.drop_column("tool_instances", "image_tag")
|
||||||
|
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances' AND column_name = 'manifest_compiled_at'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
if result.fetchone():
|
||||||
|
op.drop_column("tool_instances", "manifest_compiled_at")
|
||||||
|
|
||||||
|
if has_manifest_id:
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_tool_types_manifest_id", "tool_types", type_="foreignkey"
|
||||||
|
)
|
||||||
|
op.drop_column("tool_types", "manifest_id")
|
||||||
|
|
||||||
|
op.drop_table("tool_definition_manifests")
|
||||||
@@ -5,8 +5,6 @@ Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
|
|||||||
Create Date: 2026-05-24 18:00:43.990361
|
Create Date: 2026-05-24 18:00:43.990361
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ Create Date: 2026-05-24 10:43:14.000000
|
|||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "f3d2dc90ba3a"
|
revision: str = "f3d2dc90ba3a"
|
||||||
|
|||||||
+10
-10
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
|
|||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
||||||
response = RedirectResponse(location)
|
response = RedirectResponse(location)
|
||||||
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
||||||
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
||||||
@@ -63,7 +63,7 @@ async def callback(
|
|||||||
auth_next: str | None = Cookie(default="/"),
|
auth_next: str | None = Cookie(default="/"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
||||||
|
|
||||||
if auth_state is None or auth_state != state:
|
if auth_state is None or auth_state != state:
|
||||||
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
||||||
@@ -71,7 +71,7 @@ async def callback(
|
|||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||||
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
@@ -92,7 +92,7 @@ async def callback(
|
|||||||
access_token=token_payload["access_token"],
|
access_token=token_payload["access_token"],
|
||||||
client=client,
|
client=client,
|
||||||
)
|
)
|
||||||
logger.info("User info fetched successfully")
|
logger.debug("User info fetched successfully")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("User info fetch failed: %s", exc)
|
logger.error("User info fetch failed: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
||||||
@@ -100,19 +100,19 @@ async def callback(
|
|||||||
authentik_id = str(user_info.get("sub", ""))
|
authentik_id = str(user_info.get("sub", ""))
|
||||||
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||||
name = str(user_info.get("name", email))
|
name = str(user_info.get("name", email))
|
||||||
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
logger.info("Creating new user: authentik_id=%s", authentik_id)
|
logger.debug("Creating new user: authentik_id=%s", authentik_id)
|
||||||
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
logger.info("New user created: id=%s", user.id)
|
logger.info("New user created: id=%s", user.id)
|
||||||
else:
|
else:
|
||||||
logger.info("Existing user found: id=%s, updating info", user.id)
|
logger.debug("Existing user found: id=%s, updating info", user.id)
|
||||||
user.email = email
|
user.email = email
|
||||||
user.name = name
|
user.name = name
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -165,20 +165,20 @@ async def me(
|
|||||||
session_cookie: str | None = Cookie(default=None, alias="session"),
|
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
|
logger.debug("Auth /me called, cookie present: %s", bool(session_cookie))
|
||||||
|
|
||||||
if not session_cookie:
|
if not session_cookie:
|
||||||
logger.warning("Auth /me: missing session cookie")
|
logger.warning("Auth /me: missing session cookie")
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
||||||
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
user_id = payload["user_id"]
|
user_id = payload["user_id"]
|
||||||
logger.info("Auth /me: decoded session for user_id=%s", user_id)
|
logger.debug("Auth /me: decoded session for user_id=%s", user_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("Auth /me: invalid session: %s", exc)
|
logger.warning("Auth /me: invalid session: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.shared_validators import validate_files as _validate_files, validate_mount_path as _validate_mount_path
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.config_folder import ConfigFolder
|
from src.models.config_folder import ConfigFolder
|
||||||
|
|
||||||
@@ -15,9 +16,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
||||||
|
|
||||||
MAX_FOLDER_SIZE_MB = 10
|
|
||||||
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderCreate(BaseModel):
|
class ConfigFolderCreate(BaseModel):
|
||||||
name: str = Field(description="Folder name (unique per user)")
|
name: str = Field(description="Folder name (unique per user)")
|
||||||
@@ -28,24 +26,12 @@ class ConfigFolderCreate(BaseModel):
|
|||||||
@field_validator("mount_path")
|
@field_validator("mount_path")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_mount_path(cls, v: str) -> str:
|
def validate_mount_path(cls, v: str) -> str:
|
||||||
if not v.startswith("/"):
|
return _validate_mount_path(v)
|
||||||
raise ValueError("Mount path must be absolute (start with /)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("files")
|
@field_validator("files")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_files(cls, v: dict) -> dict:
|
def validate_files(cls, v: dict) -> dict:
|
||||||
total_size = 0
|
return _validate_files(v)
|
||||||
for path, content in v.items():
|
|
||||||
# Check for path traversal
|
|
||||||
if ".." in path or path.startswith("/"):
|
|
||||||
raise ValueError(f"Invalid file path: {path}")
|
|
||||||
total_size += len(content.encode("utf-8"))
|
|
||||||
|
|
||||||
if total_size > MAX_FOLDER_SIZE_BYTES:
|
|
||||||
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderUpdate(BaseModel):
|
class ConfigFolderUpdate(BaseModel):
|
||||||
@@ -58,29 +44,12 @@ class ConfigFolderUpdate(BaseModel):
|
|||||||
@field_validator("mount_path")
|
@field_validator("mount_path")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
return _validate_mount_path(v)
|
||||||
return v
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Mount path must be absolute (start with /)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("files")
|
@field_validator("files")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_files(cls, v: dict | None) -> dict | None:
|
def validate_files(cls, v: dict | None) -> dict | None:
|
||||||
if v is None:
|
return _validate_files(v)
|
||||||
return v
|
|
||||||
|
|
||||||
total_size = 0
|
|
||||||
for path, content in v.items():
|
|
||||||
# Check for path traversal
|
|
||||||
if ".." in path or path.startswith("/"):
|
|
||||||
raise ValueError(f"Invalid file path: {path}")
|
|
||||||
total_size += len(content.encode("utf-8"))
|
|
||||||
|
|
||||||
if total_size > MAX_FOLDER_SIZE_BYTES:
|
|
||||||
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectOverrideCreate(BaseModel):
|
class ProjectOverrideCreate(BaseModel):
|
||||||
@@ -90,11 +59,7 @@ class ProjectOverrideCreate(BaseModel):
|
|||||||
@field_validator("mount_path")
|
@field_validator("mount_path")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_mount_path(cls, v: str | None) -> str | None:
|
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
return _validate_mount_path(v)
|
||||||
return v
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Mount path must be absolute (start with /)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderResponse(BaseModel):
|
class ConfigFolderResponse(BaseModel):
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.api.shared_validators import validate_env_vars as _validate_env_vars
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
@@ -55,6 +56,36 @@ def _calculate_profile_size(data: dict) -> int:
|
|||||||
return total
|
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):
|
class MountItem(BaseModel):
|
||||||
target: str = Field(description="Absolute mount target path")
|
target: str = Field(description="Absolute mount target path")
|
||||||
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||||
@@ -97,6 +128,7 @@ class ConfigProfileCreate(BaseModel):
|
|||||||
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
||||||
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
|
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
|
||||||
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
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")
|
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
|
||||||
|
|
||||||
@field_validator("project_id", "tool_type_id")
|
@field_validator("project_id", "tool_type_id")
|
||||||
@@ -120,9 +152,10 @@ class ConfigProfileCreate(BaseModel):
|
|||||||
@field_validator("env_vars")
|
@field_validator("env_vars")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_env_vars(cls, v: dict) -> dict:
|
def validate_env_vars(cls, v: dict) -> dict:
|
||||||
if not isinstance(v, dict):
|
result = _validate_env_vars(v)
|
||||||
|
if result is None:
|
||||||
raise ValueError("env_vars must be a JSON object")
|
raise ValueError("env_vars must be a JSON object")
|
||||||
return v
|
return result
|
||||||
|
|
||||||
@field_validator("runtime_hints")
|
@field_validator("runtime_hints")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -148,6 +181,7 @@ class ConfigProfileUpdate(BaseModel):
|
|||||||
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
||||||
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
|
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
|
||||||
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
|
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")
|
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
|
||||||
|
|
||||||
@field_validator("project_id", "tool_type_id")
|
@field_validator("project_id", "tool_type_id")
|
||||||
@@ -191,6 +225,7 @@ class ConfigProfileResponse(BaseModel):
|
|||||||
runtime_hints: dict
|
runtime_hints: dict
|
||||||
mounts: list
|
mounts: list
|
||||||
files: dict
|
files: dict
|
||||||
|
git_mounts: list
|
||||||
is_default: bool
|
is_default: bool
|
||||||
includes: list[dict]
|
includes: list[dict]
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -225,6 +260,32 @@ async def _check_access(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(profile.id),
|
"id": str(profile.id),
|
||||||
@@ -236,6 +297,7 @@ def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInc
|
|||||||
"env_vars": profile.env_vars or {},
|
"env_vars": profile.env_vars or {},
|
||||||
"runtime_hints": profile.runtime_hints or {},
|
"runtime_hints": profile.runtime_hints or {},
|
||||||
"mounts": profile.mounts or [],
|
"mounts": profile.mounts or [],
|
||||||
|
"git_mounts": profile.git_mounts or [],
|
||||||
"files": profile.files or {},
|
"files": profile.files or {},
|
||||||
"is_default": profile.is_default,
|
"is_default": profile.is_default,
|
||||||
"includes": [
|
"includes": [
|
||||||
@@ -319,6 +381,11 @@ async def create_config_profile(
|
|||||||
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
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
|
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)
|
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
|
# Check size
|
||||||
size = _calculate_profile_size(data.model_dump())
|
size = _calculate_profile_size(data.model_dump())
|
||||||
@@ -337,6 +404,7 @@ async def create_config_profile(
|
|||||||
env_vars=data.env_vars,
|
env_vars=data.env_vars,
|
||||||
runtime_hints=data.runtime_hints,
|
runtime_hints=data.runtime_hints,
|
||||||
mounts=[m.model_dump() for m in data.mounts],
|
mounts=[m.model_dump() for m in data.mounts],
|
||||||
|
git_mounts=[m.model_dump() for m in data.git_mounts],
|
||||||
files=data.files,
|
files=data.files,
|
||||||
is_default=data.is_default,
|
is_default=data.is_default,
|
||||||
)
|
)
|
||||||
@@ -351,7 +419,7 @@ async def create_config_profile(
|
|||||||
)
|
)
|
||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
logger.info("Created config profile %s for user %s", profile.id, user_uuid)
|
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
||||||
return _profile_to_response(profile)
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -413,6 +481,14 @@ async def update_config_profile(
|
|||||||
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
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)
|
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
|
# Check size
|
||||||
current_data = _profile_to_response(profile)
|
current_data = _profile_to_response(profile)
|
||||||
@@ -430,6 +506,8 @@ async def update_config_profile(
|
|||||||
value = uuid.UUID(value) if value else None
|
value = uuid.UUID(value) if value else None
|
||||||
elif field_name == "mounts" and value is not 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]
|
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)
|
setattr(profile, field_name, value)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -442,7 +520,7 @@ async def update_config_profile(
|
|||||||
)
|
)
|
||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
logger.info("Updated config profile %s", profile.id)
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
return _profile_to_response(profile)
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -462,7 +540,7 @@ async def delete_config_profile(
|
|||||||
await session.delete(profile)
|
await session.delete(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
logger.info("Deleted config profile %s", profile_id)
|
logger.debug("Deleted config profile %s", profile_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -547,7 +625,7 @@ async def update_profile_includes(
|
|||||||
)
|
)
|
||||||
direct_includes = inc_result.scalars().all()
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
logger.info("Updated includes for config profile %s", profile.id)
|
logger.debug("Updated includes for config profile %s", profile.id)
|
||||||
return _profile_to_response(profile, list(direct_includes))
|
return _profile_to_response(profile, list(direct_includes))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
from src.utils.git_files import (
|
from src.utils.git_files import (
|
||||||
commit_file,
|
commit_file,
|
||||||
get_file_content,
|
get_file_content,
|
||||||
@@ -42,40 +40,6 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
"""Fetch a user by ID or raise 401 if not found."""
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned_project(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> Project:
|
|
||||||
"""Fetch a project and verify ownership.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The project if found and owned by the user.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If project not found or user is not the owner.
|
|
||||||
"""
|
|
||||||
project = await session.get(Project, project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
|
||||||
if project.owner_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
|
||||||
return project
|
|
||||||
|
|
||||||
|
|
||||||
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
||||||
"""Generate the filesystem path for a repository.
|
"""Generate the filesystem path for a repository.
|
||||||
|
|
||||||
@@ -256,7 +220,7 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
path: str
|
path: str
|
||||||
project_id: uuid.UUID
|
project_id: uuid.UUID | None
|
||||||
owner_id: uuid.UUID
|
owner_id: uuid.UUID
|
||||||
is_mirror: bool
|
is_mirror: bool
|
||||||
remote_url: str | None
|
remote_url: str | None
|
||||||
@@ -266,6 +230,156 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/repositories",
|
||||||
|
response_model=list[GitRepositoryResponse],
|
||||||
|
summary="List all user repositories",
|
||||||
|
description="List all git repositories owned by the user, including external repositories not tied to any project.",
|
||||||
|
)
|
||||||
|
async def list_user_repositories(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[GitRepository]:
|
||||||
|
"""List all repositories owned by the user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all repositories owned by the user.
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.owner_id == user_id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/repositories/parse-url",
|
||||||
|
response_model=URLParseResponse,
|
||||||
|
summary="Parse a git URL",
|
||||||
|
description="Parse a git URL and detect if it's a browser URL that needs correction.",
|
||||||
|
)
|
||||||
|
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
||||||
|
"""Parse a git URL and detect if it's a browser URL that needs correction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Request containing the URL to parse.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed URL information including whether it needs parsing and suggested corrections.
|
||||||
|
"""
|
||||||
|
result = parse_git_url(data.url)
|
||||||
|
return URLParseResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/repositories",
|
||||||
|
response_model=GitRepositoryResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create an external repository",
|
||||||
|
description="Create a new external git repository (not tied to any project). Can clone from remote URL.",
|
||||||
|
)
|
||||||
|
async def create_external_repository(
|
||||||
|
data: GitRepositoryCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Create a new external git repository.
|
||||||
|
|
||||||
|
External repositories are not tied to any project and can be used
|
||||||
|
across all projects for config profile git mounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Repository creation data including name and optional remote URL.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created external repository.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
|
||||||
|
# Check for duplicate name (external repos only)
|
||||||
|
existing = await session.execute(
|
||||||
|
select(GitRepository).where(
|
||||||
|
GitRepository.project_id.is_(None),
|
||||||
|
GitRepository.owner_id == user_id,
|
||||||
|
GitRepository.name == data.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||||
|
|
||||||
|
# Validate and potentially correct the URL
|
||||||
|
remote_url = data.remote_url
|
||||||
|
if remote_url and not data.force_original_url:
|
||||||
|
parse_result = parse_git_url(remote_url)
|
||||||
|
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||||
|
"suggested_url": parse_result["base_url"],
|
||||||
|
"original_url": remote_url,
|
||||||
|
"error_code": "URL_NEEDS_PARSING",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if parse_result["base_url"]:
|
||||||
|
remote_url = parse_result["base_url"]
|
||||||
|
|
||||||
|
# Validate SSH key if provided
|
||||||
|
ssh_key_id = None
|
||||||
|
ssh_key = None
|
||||||
|
if data.ssh_key_id:
|
||||||
|
try:
|
||||||
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||||
|
if ssh_key is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
if ssh_key.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
_preflight_remote_repository(remote_url, ssh_key)
|
||||||
|
|
||||||
|
# Create external repo with no project
|
||||||
|
repo = GitRepository(
|
||||||
|
name=data.name,
|
||||||
|
path="", # Will be set after clone
|
||||||
|
project_id=None,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url=remote_url,
|
||||||
|
ssh_key_id=ssh_key_id,
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Set path and optionally clone
|
||||||
|
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
|
||||||
|
repo.path = repo_path
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
try:
|
||||||
|
_clone_working_repository(remote_url, repo_path, ssh_key)
|
||||||
|
repo.is_mirror = False
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
|
||||||
|
else:
|
||||||
|
# Initialize empty repo
|
||||||
|
os.makedirs(repo_path, exist_ok=True)
|
||||||
|
subprocess.run(["git", "init", repo_path], check=True, capture_output=True)
|
||||||
|
repo.is_mirror = False
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/repositories",
|
"/{project_id}/repositories",
|
||||||
response_model=list[GitRepositoryResponse],
|
response_model=list[GitRepositoryResponse],
|
||||||
@@ -335,25 +449,6 @@ async def delete_repository(
|
|||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/repositories/parse-url",
|
|
||||||
response_model=URLParseResponse,
|
|
||||||
summary="Parse a git URL",
|
|
||||||
description="Parse a git URL and detect if it's a browser URL that needs correction.",
|
|
||||||
)
|
|
||||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
|
||||||
"""Parse a git URL and detect if it's a browser URL that needs correction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Request containing the URL to parse.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed URL information including whether it needs parsing and suggested corrections.
|
|
||||||
"""
|
|
||||||
result = parse_git_url(data.url)
|
|
||||||
return URLParseResponse(**result)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{project_id}/repositories",
|
"/{project_id}/repositories",
|
||||||
response_model=GitRepositoryResponse,
|
response_model=GitRepositoryResponse,
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.config import Settings
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
|||||||
@@ -7,23 +7,14 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
"""Fetch a user by ID or raise 401 if not found."""
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreate(BaseModel):
|
class ProjectCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -132,32 +123,6 @@ async def get_project(
|
|||||||
return await _get_owned_project(project_id, user_id, session)
|
return await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned_project(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> Project:
|
|
||||||
"""Fetch a project and verify ownership.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The project if found and owned by the user.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If project not found or user is not the owner.
|
|
||||||
"""
|
|
||||||
project = await session.get(Project, project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
|
||||||
if project.owner_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
|
||||||
return project
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/{project_id}",
|
"/{project_id}",
|
||||||
response_model=ProjectResponse,
|
response_model=ProjectResponse,
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Shared Pydantic validators for API schemas."""
|
||||||
|
|
||||||
|
|
||||||
|
MAX_FOLDER_SIZE_MB = 10
|
||||||
|
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mount_path(v: str | None) -> str | None:
|
||||||
|
"""Validate that a mount path is absolute (starts with /).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Mount path string or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated path, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path is not absolute.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount path must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_files(v: dict | None, max_size_bytes: int = MAX_FOLDER_SIZE_BYTES) -> dict | None:
|
||||||
|
"""Validate file dict for path traversal and size limits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Dict of {path: content} or None.
|
||||||
|
max_size_bytes: Maximum total size in bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated dict, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path traversal detected or size limit exceeded.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
for path, content in v.items():
|
||||||
|
# Check for path traversal
|
||||||
|
if ".." in path or path.startswith("/"):
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
total_size += len(content.encode("utf-8"))
|
||||||
|
|
||||||
|
if total_size > max_size_bytes:
|
||||||
|
raise ValueError(f"Total folder size exceeds {max_size_bytes // (1024 * 1024)}MB limit")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_env_vars(v: dict | None) -> dict | None:
|
||||||
|
"""Validate that environment variables is a JSON object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Dict of env vars or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated dict, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If not a dict.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("environment_variables must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_volumes(v: list | None) -> list | None:
|
||||||
|
"""Validate volume mounts list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: List of volume dicts or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated list, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If not a list or missing required fields.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, list):
|
||||||
|
raise ValueError("volumes must be a JSON array")
|
||||||
|
for i, vol in enumerate(v):
|
||||||
|
if not isinstance(vol, dict):
|
||||||
|
raise ValueError(f"Volume at index {i} must be an object")
|
||||||
|
if "source" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||||
|
if "target" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||||
|
return v
|
||||||
@@ -10,22 +10,13 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
"""Fetch a user by ID or raise 401 if not found."""
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def _get_fernet() -> Fernet:
|
def _get_fernet() -> Fernet:
|
||||||
"""Generate a valid Fernet key from the session secret."""
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
import base64
|
import base64
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
from src.services.terminal_manager import terminal_manager
|
from src.services.terminal_manager import terminal_manager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -43,8 +44,9 @@ async def terminal_websocket(
|
|||||||
Returns:
|
Returns:
|
||||||
None. Communicates via WebSocket messages.
|
None. Communicates via WebSocket messages.
|
||||||
"""
|
"""
|
||||||
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
|
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse instance_id
|
# Parse instance_id
|
||||||
@@ -78,20 +80,30 @@ async def terminal_websocket(
|
|||||||
await websocket.close(code=4004, reason="Instance not running")
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.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
|
# Get or create terminal session
|
||||||
try:
|
try:
|
||||||
session = await terminal_manager.get_or_create_session(
|
session = await terminal_manager.get_or_create_session(
|
||||||
instance_uuid,
|
instance_uuid,
|
||||||
instance.container_id,
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
)
|
)
|
||||||
logger.info("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
||||||
|
|
||||||
# Attach WebSocket to session
|
# Attach WebSocket to session
|
||||||
await terminal_manager.attach_websocket(session, websocket)
|
await terminal_manager.attach_websocket(session, websocket)
|
||||||
logger.info("WebSocket attached to session for instance %s", instance_id)
|
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||||
|
|
||||||
# Send connected status
|
# Send connected status
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
logger.debug("Sent connected status for instance %s", instance_id)
|
||||||
|
|
||||||
# Use mutable session reference so loops can survive reset
|
# Use mutable session reference so loops can survive reset
|
||||||
session_ref = SessionRef(session)
|
session_ref = SessionRef(session)
|
||||||
@@ -100,6 +112,7 @@ async def terminal_websocket(
|
|||||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||||
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
||||||
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
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)
|
# Wait for either task to complete (indicating disconnect or error)
|
||||||
done, pending = await asyncio.wait(
|
done, pending = await asyncio.wait(
|
||||||
@@ -107,6 +120,8 @@ async def terminal_websocket(
|
|||||||
return_when=asyncio.FIRST_COMPLETED,
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
|
||||||
|
|
||||||
# Cancel remaining tasks
|
# Cancel remaining tasks
|
||||||
for task in pending:
|
for task in pending:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
@@ -119,7 +134,7 @@ async def terminal_websocket(
|
|||||||
try:
|
try:
|
||||||
if 'session' in locals():
|
if 'session' in locals():
|
||||||
await terminal_manager.detach_websocket(session, websocket)
|
await terminal_manager.detach_websocket(session, websocket)
|
||||||
logger.info("WebSocket detached from session for instance %s", instance_id)
|
logger.debug("WebSocket detached from session for instance %s", instance_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -168,17 +183,18 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
if msg_type == "resize":
|
if msg_type == "resize":
|
||||||
cols = ctrl.get("cols", 80)
|
cols = ctrl.get("cols", 80)
|
||||||
rows = ctrl.get("rows", 24)
|
rows = ctrl.get("rows", 24)
|
||||||
logger.info(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
||||||
await session.resize(cols, rows)
|
await session.resize(cols, rows)
|
||||||
elif msg_type == "reset":
|
elif msg_type == "reset":
|
||||||
# Reset terminal session
|
# Reset terminal session
|
||||||
logger.info("Resetting terminal session for instance %s", session.instance_id)
|
logger.debug("Resetting terminal session for instance %s", session.instance_id)
|
||||||
await websocket.send_json({"type": "status", "status": "resetting"})
|
await websocket.send_json({"type": "status", "status": "resetting"})
|
||||||
|
|
||||||
# Reset the session
|
# Reset the session
|
||||||
new_session = await terminal_manager.reset_session(
|
new_session = await terminal_manager.reset_session(
|
||||||
session.instance_id,
|
session.instance_id,
|
||||||
session.container_id,
|
session.container_id,
|
||||||
|
startup_command=session.startup_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update the mutable session reference so read_loop uses the new session
|
# Update the mutable session reference so read_loop uses the new session
|
||||||
@@ -252,11 +268,16 @@ async def reset_terminal_session(
|
|||||||
detail="Instance is not running"
|
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:
|
try:
|
||||||
# Reset the session
|
# Reset the session
|
||||||
new_session = await terminal_manager.reset_session(
|
new_session = await terminal_manager.reset_session(
|
||||||
instance_id,
|
instance_id,
|
||||||
instance.container_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)
|
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tool configuration API endpoints."""
|
"""Tool configuration API endpoints."""
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
@@ -8,12 +7,11 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.tool_config import ToolConfig
|
from src.models.tool_config import ToolConfig
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||||
|
|
||||||
|
|
||||||
@@ -42,27 +40,12 @@ class ToolConfigCreate(BaseModel):
|
|||||||
@field_validator("environment_variables")
|
@field_validator("environment_variables")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||||
if v is None:
|
return _validate_env_vars(v)
|
||||||
return v
|
|
||||||
if not isinstance(v, dict):
|
|
||||||
raise ValueError("environment_variables must be a JSON object")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("volumes")
|
@field_validator("volumes")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_volumes(cls, v: list | None) -> list | None:
|
def validate_volumes(cls, v: list | None) -> list | None:
|
||||||
if v is None:
|
return _validate_volumes(v)
|
||||||
return v
|
|
||||||
if not isinstance(v, list):
|
|
||||||
raise ValueError("volumes must be a JSON array")
|
|
||||||
for i, vol in enumerate(v):
|
|
||||||
if not isinstance(vol, dict):
|
|
||||||
raise ValueError(f"Volume at index {i} must be an object")
|
|
||||||
if "source" not in vol:
|
|
||||||
raise ValueError(f"Volume at index {i} must have 'source' field")
|
|
||||||
if "target" not in vol:
|
|
||||||
raise ValueError(f"Volume at index {i} must have 'target' field")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigUpdate(BaseModel):
|
class ToolConfigUpdate(BaseModel):
|
||||||
@@ -88,27 +71,12 @@ class ToolConfigUpdate(BaseModel):
|
|||||||
@field_validator("environment_variables")
|
@field_validator("environment_variables")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||||
if v is None:
|
return _validate_env_vars(v)
|
||||||
return v
|
|
||||||
if not isinstance(v, dict):
|
|
||||||
raise ValueError("environment_variables must be a JSON object")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("volumes")
|
@field_validator("volumes")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_volumes(cls, v: list | None) -> list | None:
|
def validate_volumes(cls, v: list | None) -> list | None:
|
||||||
if v is None:
|
return _validate_volumes(v)
|
||||||
return v
|
|
||||||
if not isinstance(v, list):
|
|
||||||
raise ValueError("volumes must be a JSON array")
|
|
||||||
for i, vol in enumerate(v):
|
|
||||||
if not isinstance(vol, dict):
|
|
||||||
raise ValueError(f"Volume at index {i} must be an object")
|
|
||||||
if "source" not in vol:
|
|
||||||
raise ValueError(f"Volume at index {i} must have 'source' field")
|
|
||||||
if "target" not in vol:
|
|
||||||
raise ValueError(f"Volume at index {i} must have 'target' field")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigResponse(BaseModel):
|
class ToolConfigResponse(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
"""Tool definition API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
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.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.services.manifest_compiler import (
|
||||||
|
compile_compose,
|
||||||
|
compile_dockerfile,
|
||||||
|
compile_entrypoint,
|
||||||
|
compute_image_tag,
|
||||||
|
deep_merge,
|
||||||
|
resolve_base,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tool-definitions", tags=["tool-definitions"])
|
||||||
|
|
||||||
|
|
||||||
|
class CreateToolDefinitionRequest(BaseModel):
|
||||||
|
"""Request body for creating a tool definition manifest."""
|
||||||
|
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
name: str = Field(description="Unique identifier (kebab-case)")
|
||||||
|
display_name: str = Field(description="Human-readable name")
|
||||||
|
description: str | None = Field(default=None)
|
||||||
|
category: str = Field(default="development")
|
||||||
|
interface_type: str = Field(default="terminal", description="web or terminal")
|
||||||
|
base_image: str | None = Field(default=None, description="Direct base image")
|
||||||
|
base_definition_id: str | None = Field(
|
||||||
|
default=None, description="Reference to a base definition"
|
||||||
|
)
|
||||||
|
base_version: str = Field(default="latest")
|
||||||
|
manifest: dict = Field(description="The full manifest JSON")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateToolDefinitionRequest(BaseModel):
|
||||||
|
"""Request body for updating a tool definition manifest."""
|
||||||
|
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
display_name: str | None = Field(default=None)
|
||||||
|
description: str | None = Field(default=None)
|
||||||
|
category: str | None = Field(default=None)
|
||||||
|
manifest: dict | None = Field(default=None)
|
||||||
|
base_version: str | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
summary="Create tool definition",
|
||||||
|
description="Create a new tool definition manifest.",
|
||||||
|
)
|
||||||
|
async def create_tool_definition(
|
||||||
|
data: CreateToolDefinitionRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new tool definition manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Manifest data.
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with created definition details.
|
||||||
|
"""
|
||||||
|
# Validate base reference
|
||||||
|
if not data.base_image and not data.base_definition_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Either base_image or base_definition_id is required",
|
||||||
|
)
|
||||||
|
|
||||||
|
base_def_id = None
|
||||||
|
if data.base_definition_id:
|
||||||
|
try:
|
||||||
|
base_def_id = uuid.UUID(data.base_definition_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid base_definition_id: {data.base_definition_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
base_def = await session.get(ToolDefinitionManifest, base_def_id)
|
||||||
|
if not base_def:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Base definition not found: {data.base_definition_id}",
|
||||||
|
)
|
||||||
|
if not base_def.is_base:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Referenced definition is not a base definition",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check name uniqueness
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ToolDefinitionManifest).where(ToolDefinitionManifest.name == data.name)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Tool definition '{data.name}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
definition = ToolDefinitionManifest(
|
||||||
|
name=data.name,
|
||||||
|
display_name=data.display_name,
|
||||||
|
description=data.description,
|
||||||
|
category=data.category,
|
||||||
|
interface_type=data.interface_type,
|
||||||
|
base_image=data.base_image,
|
||||||
|
base_definition_id=base_def_id,
|
||||||
|
base_version=data.base_version,
|
||||||
|
manifest=data.manifest,
|
||||||
|
created_by_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(definition)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(definition)
|
||||||
|
|
||||||
|
logger.info("Created tool definition %s (%s)", definition.id, definition.name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(definition.id),
|
||||||
|
"name": definition.name,
|
||||||
|
"display_name": definition.display_name,
|
||||||
|
"description": definition.description,
|
||||||
|
"category": definition.category,
|
||||||
|
"interface_type": definition.interface_type,
|
||||||
|
"base_image": definition.base_image,
|
||||||
|
"base_definition_id": str(definition.base_definition_id)
|
||||||
|
if definition.base_definition_id
|
||||||
|
else None,
|
||||||
|
"base_version": definition.base_version,
|
||||||
|
"manifest": definition.manifest,
|
||||||
|
"is_base": definition.is_base,
|
||||||
|
"created_at": definition.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
summary="List tool definitions",
|
||||||
|
description="List all tool definition manifests.",
|
||||||
|
)
|
||||||
|
async def list_tool_definitions(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
include_bases: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""List all tool definition manifests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
include_bases: Whether to include base definitions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing list of definitions.
|
||||||
|
"""
|
||||||
|
query = select(ToolDefinitionManifest)
|
||||||
|
if not include_bases:
|
||||||
|
query = query.where(ToolDefinitionManifest.is_base == False)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
query.order_by(ToolDefinitionManifest.created_at.desc())
|
||||||
|
)
|
||||||
|
definitions = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"definitions": [
|
||||||
|
{
|
||||||
|
"id": str(d.id),
|
||||||
|
"name": d.name,
|
||||||
|
"display_name": d.display_name,
|
||||||
|
"description": d.description,
|
||||||
|
"category": d.category,
|
||||||
|
"interface_type": d.interface_type,
|
||||||
|
"is_base": d.is_base,
|
||||||
|
"base_image": d.base_image,
|
||||||
|
"base_definition_id": str(d.base_definition_id)
|
||||||
|
if d.base_definition_id
|
||||||
|
else None,
|
||||||
|
"base_version": d.base_version,
|
||||||
|
"version": d.version,
|
||||||
|
"created_at": d.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for d in definitions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{definition_id}",
|
||||||
|
summary="Get tool definition",
|
||||||
|
description="Get a specific tool definition manifest.",
|
||||||
|
)
|
||||||
|
async def get_tool_definition(
|
||||||
|
definition_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get a specific tool definition manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
definition_id: UUID of the definition.
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with definition details.
|
||||||
|
"""
|
||||||
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||||
|
if not definition:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tool definition not found: {definition_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(definition.id),
|
||||||
|
"name": definition.name,
|
||||||
|
"display_name": definition.display_name,
|
||||||
|
"description": definition.description,
|
||||||
|
"category": definition.category,
|
||||||
|
"interface_type": definition.interface_type,
|
||||||
|
"base_image": definition.base_image,
|
||||||
|
"base_definition_id": str(definition.base_definition_id)
|
||||||
|
if definition.base_definition_id
|
||||||
|
else None,
|
||||||
|
"base_version": definition.base_version,
|
||||||
|
"manifest": definition.manifest,
|
||||||
|
"dockerfile_cache": definition.dockerfile_cache,
|
||||||
|
"compose_cache": definition.compose_cache,
|
||||||
|
"version": definition.version,
|
||||||
|
"is_base": definition.is_base,
|
||||||
|
"created_at": definition.created_at.isoformat(),
|
||||||
|
"updated_at": definition.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{definition_id}",
|
||||||
|
summary="Update tool definition",
|
||||||
|
description="Update a tool definition manifest.",
|
||||||
|
)
|
||||||
|
async def update_tool_definition(
|
||||||
|
definition_id: uuid.UUID,
|
||||||
|
data: UpdateToolDefinitionRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update a tool definition manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
definition_id: UUID of the definition.
|
||||||
|
data: Update data.
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with updated definition details.
|
||||||
|
"""
|
||||||
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||||
|
if not definition:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tool definition not found: {definition_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.display_name is not None:
|
||||||
|
definition.display_name = data.display_name
|
||||||
|
if data.description is not None:
|
||||||
|
definition.description = data.description
|
||||||
|
if data.category is not None:
|
||||||
|
definition.category = data.category
|
||||||
|
if data.manifest is not None:
|
||||||
|
definition.manifest = data.manifest
|
||||||
|
if data.base_version is not None:
|
||||||
|
definition.base_version = data.base_version
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(definition)
|
||||||
|
|
||||||
|
logger.info("Updated tool definition %s (%s)", definition.id, definition.name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(definition.id),
|
||||||
|
"name": definition.name,
|
||||||
|
"display_name": definition.display_name,
|
||||||
|
"manifest": definition.manifest,
|
||||||
|
"updated_at": definition.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{definition_id}",
|
||||||
|
summary="Delete tool definition",
|
||||||
|
description="Delete a tool definition manifest.",
|
||||||
|
)
|
||||||
|
async def delete_tool_definition(
|
||||||
|
definition_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Delete a tool definition manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
definition_id: UUID of the definition.
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with deletion status.
|
||||||
|
"""
|
||||||
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||||
|
if not definition:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tool definition not found: {definition_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if any tool types reference this manifest
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolType).where(ToolType.manifest_id == definition_id)
|
||||||
|
)
|
||||||
|
referencing = result.scalars().all()
|
||||||
|
if referencing:
|
||||||
|
tool_names = ", ".join(t.name for t in referencing)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Cannot delete: referenced by tool types: {tool_names}",
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.delete(definition)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info("Deleted tool definition %s (%s)", definition.id, definition.name)
|
||||||
|
|
||||||
|
return {"status": "deleted", "id": str(definition_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{definition_id}/compile",
|
||||||
|
summary="Compile tool definition",
|
||||||
|
description="Compile a manifest to Dockerfile + Compose preview without building.",
|
||||||
|
)
|
||||||
|
async def compile_tool_definition(
|
||||||
|
definition_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Compile a manifest to Dockerfile + Compose preview.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
definition_id: UUID of the definition.
|
||||||
|
user_id: Authenticated user ID.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with compiled Dockerfile, Compose, and image tag.
|
||||||
|
"""
|
||||||
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
||||||
|
if not definition:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tool definition not found: {definition_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = dict(definition.manifest)
|
||||||
|
|
||||||
|
# Resolve base if referenced
|
||||||
|
if definition.base_definition_id:
|
||||||
|
base_def = await session.get(
|
||||||
|
ToolDefinitionManifest, definition.base_definition_id
|
||||||
|
)
|
||||||
|
if base_def:
|
||||||
|
base_manifest = dict(base_def.manifest)
|
||||||
|
manifest = resolve_base(deep_merge(base_manifest, manifest))
|
||||||
|
|
||||||
|
# Compile
|
||||||
|
dockerfile = compile_dockerfile(manifest)
|
||||||
|
entrypoint = compile_entrypoint(manifest)
|
||||||
|
image_tag = compute_image_tag(definition.name, manifest)
|
||||||
|
|
||||||
|
# Dummy compose with placeholder variables
|
||||||
|
dummy_vars = {
|
||||||
|
"IMAGE_TAG": image_tag,
|
||||||
|
"INSTANCE_NAME": f"{definition.name}-preview",
|
||||||
|
"INSTANCE_DIR": "/data/instances/preview",
|
||||||
|
"REPO_PATH": "/data/repos/preview",
|
||||||
|
"SSH_PATH": "/data/instances/preview/.ssh",
|
||||||
|
"TOOL_PORT": "8080",
|
||||||
|
"EXTRA_ENV": {},
|
||||||
|
"EXTRA_VOLUMES": [],
|
||||||
|
}
|
||||||
|
compose = compile_compose(manifest, dummy_vars)
|
||||||
|
|
||||||
|
# Update cache
|
||||||
|
definition.dockerfile_cache = dockerfile
|
||||||
|
definition.compose_cache = compose
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(definition.id),
|
||||||
|
"name": definition.name,
|
||||||
|
"dockerfile": dockerfile,
|
||||||
|
"entrypoint": entrypoint,
|
||||||
|
"compose": compose,
|
||||||
|
"image_tag": image_tag,
|
||||||
|
}
|
||||||
+885
-225
File diff suppressed because it is too large
Load Diff
+35
-131
@@ -1,33 +1,23 @@
|
|||||||
import re
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import yaml
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.tool_types_validation import (
|
||||||
def _sanitize_template_vars(template: str) -> str:
|
check_port_exposed,
|
||||||
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
|
validate_compose_yaml,
|
||||||
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
|
validate_required_variables,
|
||||||
|
)
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
"""Fetch a user by ID or raise 401 if not found."""
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_admin(user: User) -> None:
|
async def _require_admin(user: User) -> None:
|
||||||
"""Check if user has admin privileges.
|
"""Check if user has admin privileges.
|
||||||
|
|
||||||
@@ -49,6 +39,7 @@ class ToolTypeCreate(BaseModel):
|
|||||||
dockerfile_template: str | None = None
|
dockerfile_template: str | None = None
|
||||||
build_context: dict | None = None
|
build_context: dict | None = None
|
||||||
readiness_probe: dict | None = None
|
readiness_probe: dict | None = None
|
||||||
|
startup_command: str | None = None
|
||||||
required_variables: list[str] = []
|
required_variables: list[str] = []
|
||||||
category: str = "other"
|
category: str = "other"
|
||||||
interface_type: str = "web"
|
interface_type: str = "web"
|
||||||
@@ -71,24 +62,7 @@ class ToolTypeCreate(BaseModel):
|
|||||||
if v is None:
|
if v is None:
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
|
||||||
# Replace template variables with dummy values before YAML validation
|
validate_compose_yaml(v)
|
||||||
# to avoid YAML parsing errors with {{VAR}} syntax
|
|
||||||
sanitized = _sanitize_template_vars(v)
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(sanitized)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
|
||||||
|
|
||||||
if "services" not in parsed:
|
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
|
||||||
|
|
||||||
if not parsed["services"]:
|
|
||||||
raise ValueError("Compose template must define at least one service")
|
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
@field_validator("dockerfile_template")
|
||||||
@@ -155,29 +129,11 @@ class ToolTypeCreate(BaseModel):
|
|||||||
# Validate that default_port is exposed in compose template (only if requires_port)
|
# Validate that default_port is exposed in compose template (only if requires_port)
|
||||||
if self.requires_port and self.definition_type == "compose" and self.compose_template:
|
if self.requires_port and self.definition_type == "compose" and self.compose_template:
|
||||||
try:
|
try:
|
||||||
sanitized = _sanitize_template_vars(self.compose_template)
|
parsed = validate_compose_yaml(self.compose_template)
|
||||||
parsed = yaml.safe_load(sanitized)
|
except ValueError:
|
||||||
except yaml.YAMLError:
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
port_str = str(self.default_port)
|
if not check_port_exposed(parsed, self.default_port):
|
||||||
port_exposed = False
|
|
||||||
|
|
||||||
if isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
for service_name, service_config in parsed["services"].items():
|
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if isinstance(port_mapping, str):
|
|
||||||
if port_str in port_mapping:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif isinstance(port_mapping, int) and port_mapping == self.default_port:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not port_exposed:
|
|
||||||
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
|
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
|
||||||
|
|
||||||
return self
|
return self
|
||||||
@@ -192,6 +148,7 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
dockerfile_template: str | None = None
|
dockerfile_template: str | None = None
|
||||||
build_context: dict | None = None
|
build_context: dict | None = None
|
||||||
readiness_probe: dict | None = None
|
readiness_probe: dict | None = None
|
||||||
|
startup_command: str | None = None
|
||||||
required_variables: list[str] | None = None
|
required_variables: list[str] | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
interface_type: str | None = None
|
interface_type: str | None = None
|
||||||
@@ -220,29 +177,13 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
if v is None:
|
if v is None:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
data = info.data
|
data = info.data
|
||||||
definition_type = data.get("definition_type")
|
definition_type = data.get("definition_type")
|
||||||
if definition_type and definition_type != "compose":
|
if definition_type and definition_type != "compose":
|
||||||
return v
|
return v
|
||||||
|
|
||||||
# Replace template variables with dummy values before YAML validation
|
validate_compose_yaml(v)
|
||||||
sanitized = _sanitize_template_vars(v)
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(sanitized)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
|
||||||
|
|
||||||
if "services" not in parsed:
|
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
|
||||||
|
|
||||||
if not parsed["services"]:
|
|
||||||
raise ValueError("Compose template must define at least one service")
|
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
@field_validator("dockerfile_template")
|
||||||
@@ -278,6 +219,7 @@ class ToolTypeResponse(BaseModel):
|
|||||||
dockerfile_template: str | None
|
dockerfile_template: str | None
|
||||||
build_context: dict | None
|
build_context: dict | None
|
||||||
readiness_probe: dict | None
|
readiness_probe: dict | None
|
||||||
|
startup_command: str | None
|
||||||
required_variables: list[str]
|
required_variables: list[str]
|
||||||
created_by_id: uuid.UUID | None
|
created_by_id: uuid.UUID | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -324,6 +266,7 @@ async def create_tool_type(
|
|||||||
dockerfile_template=data.dockerfile_template,
|
dockerfile_template=data.dockerfile_template,
|
||||||
build_context=data.build_context,
|
build_context=data.build_context,
|
||||||
readiness_probe=data.readiness_probe,
|
readiness_probe=data.readiness_probe,
|
||||||
|
startup_command=data.startup_command,
|
||||||
required_variables=data.required_variables,
|
required_variables=data.required_variables,
|
||||||
category=data.category,
|
category=data.category,
|
||||||
interface_type=data.interface_type,
|
interface_type=data.interface_type,
|
||||||
@@ -438,54 +381,29 @@ async def update_tool_type(
|
|||||||
template = update_data.get("compose_template", tool_type.compose_template)
|
template = update_data.get("compose_template", tool_type.compose_template)
|
||||||
if template:
|
if template:
|
||||||
try:
|
try:
|
||||||
sanitized = _sanitize_template_vars(template)
|
parsed = validate_compose_yaml(template)
|
||||||
parsed = yaml.safe_load(sanitized)
|
if not check_port_exposed(parsed, new_port):
|
||||||
except yaml.YAMLError:
|
|
||||||
parsed = None
|
|
||||||
|
|
||||||
if parsed and isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
port_str = str(new_port)
|
|
||||||
port_exposed = False
|
|
||||||
for service_config in parsed["services"].values():
|
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif isinstance(port_mapping, int) and port_mapping == new_port:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not port_exposed:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Port {new_port} is not exposed in the compose template"
|
detail=f"Port {new_port} is not exposed in the compose template"
|
||||||
)
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
# Validate required variables for compose definitions
|
# Validate required variables for compose definitions
|
||||||
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||||
if definition_type == "compose":
|
if definition_type == "compose":
|
||||||
if "required_variables" in update_data and "compose_template" in update_data:
|
if "required_variables" in update_data and "compose_template" in update_data:
|
||||||
template = update_data["compose_template"]
|
validate_required_variables(
|
||||||
for var in update_data["required_variables"]:
|
update_data["compose_template"], update_data["required_variables"]
|
||||||
placeholder = f"{{{{{var}}}}}"
|
)
|
||||||
if placeholder not in template:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
|
||||||
)
|
|
||||||
elif "required_variables" in update_data:
|
elif "required_variables" in update_data:
|
||||||
template = tool_type.compose_template
|
template = tool_type.compose_template
|
||||||
if template:
|
if template:
|
||||||
for var in update_data["required_variables"]:
|
validate_required_variables(template, update_data["required_variables"])
|
||||||
placeholder = f"{{{{{var}}}}}"
|
|
||||||
if placeholder not in template:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
|
||||||
)
|
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(tool_type, field, value)
|
setattr(tool_type, field, value)
|
||||||
@@ -530,16 +448,9 @@ async def validate_tool_type_template(
|
|||||||
errors.append("Compose template is required")
|
errors.append("Compose template is required")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
sanitized = _sanitize_template_vars(data.compose_template)
|
validate_compose_yaml(data.compose_template)
|
||||||
parsed = yaml.safe_load(sanitized)
|
except ValueError as e:
|
||||||
if not isinstance(parsed, dict):
|
errors.append(str(e))
|
||||||
errors.append("Compose template must be a YAML mapping")
|
|
||||||
elif "services" not in parsed:
|
|
||||||
errors.append("Compose template must contain 'services' key")
|
|
||||||
elif not parsed["services"]:
|
|
||||||
errors.append("Compose template must define at least one service")
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
errors.append(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
elif data.definition_type == "dockerfile":
|
elif data.definition_type == "dockerfile":
|
||||||
if not data.dockerfile_template:
|
if not data.dockerfile_template:
|
||||||
@@ -588,16 +499,9 @@ async def validate_tool_type(
|
|||||||
errors.append("Compose template is empty")
|
errors.append("Compose template is empty")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
sanitized = _sanitize_template_vars(tool_type.compose_template)
|
validate_compose_yaml(tool_type.compose_template)
|
||||||
parsed = yaml.safe_load(sanitized)
|
except ValueError as e:
|
||||||
if not isinstance(parsed, dict):
|
errors.append(str(e))
|
||||||
errors.append("Compose template must be a YAML mapping")
|
|
||||||
elif "services" not in parsed:
|
|
||||||
errors.append("Compose template must contain 'services' key")
|
|
||||||
elif not parsed["services"]:
|
|
||||||
errors.append("Compose template must define at least one service")
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
errors.append(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
elif tool_type.definition_type == "dockerfile":
|
elif tool_type.definition_type == "dockerfile":
|
||||||
if not tool_type.dockerfile_template:
|
if not tool_type.dockerfile_template:
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Shared validation utilities for tool types."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_template_vars(template: str) -> str:
|
||||||
|
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
|
||||||
|
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_compose_yaml(template: str) -> dict:
|
||||||
|
"""Validate and parse a compose template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Raw compose template string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed YAML dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If YAML is invalid or missing required keys.
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_template_vars(template)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise ValueError(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("Compose template must be a YAML mapping")
|
||||||
|
|
||||||
|
if "services" not in parsed:
|
||||||
|
raise ValueError("Compose template must contain 'services' key")
|
||||||
|
|
||||||
|
if not parsed["services"]:
|
||||||
|
raise ValueError("Compose template must define at least one service")
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def check_port_exposed(parsed: dict, port: int) -> bool:
|
||||||
|
"""Check if a port is exposed in a parsed compose template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parsed: Parsed compose YAML dict.
|
||||||
|
port: Port number to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if port is exposed in any service.
|
||||||
|
"""
|
||||||
|
port_str = str(port)
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict) or "services" not in parsed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for service_config in parsed["services"].values():
|
||||||
|
if isinstance(service_config, dict) and "ports" in service_config:
|
||||||
|
for port_mapping in service_config["ports"]:
|
||||||
|
if isinstance(port_mapping, str) and port_str in port_mapping:
|
||||||
|
return True
|
||||||
|
elif isinstance(port_mapping, int) and port_mapping == port:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_required_variables(template: str, variables: list[str]) -> None:
|
||||||
|
"""Validate that all required variables exist in the template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Compose template string.
|
||||||
|
variables: List of required variable names.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If any variable is not found in the template.
|
||||||
|
"""
|
||||||
|
for var in variables:
|
||||||
|
placeholder = f"{{{{{var}}}}}"
|
||||||
|
if placeholder not in template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Required variable '{var}' not found in compose template",
|
||||||
|
)
|
||||||
@@ -1,28 +1,19 @@
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.user import User
|
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
"""Fetch a user by ID or raise 401 if not found."""
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
||||||
"""Get or create user config record.
|
"""Get or create user config record.
|
||||||
|
|
||||||
@@ -111,11 +102,11 @@ async def update_user_config(
|
|||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
logger.info("Updating user config for user %s: %s", user_id, update_data)
|
logger.debug("Updating user config for user %s: %s", user_id, update_data)
|
||||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
config.config = {**config.config, **update_data}
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
logger.info("Updated config: %s", config.config)
|
logger.debug("Updated config: %s", config.config)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
@@ -16,14 +16,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
|||||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|
||||||
"""Fetch a user by ID or raise 401 if not found."""
|
|
||||||
user = await session.get(User, user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileResponse(BaseModel):
|
class UserProfileResponse(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.auth.session import decode_session_cookie
|
from src.auth.session import decode_session_cookie
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
from src.models.project import Project
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -47,3 +48,39 @@ async def get_current_user(
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_owned_project(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> "Project":
|
||||||
|
"""Fetch a project and verify ownership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The project if found and owned by the user.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if project not found, 403 if user is not the owner.
|
||||||
|
"""
|
||||||
|
from src.models.project import Project
|
||||||
|
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||||
|
if project.owner_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||||
|
return project
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
from src.api.dashboard import router as dashboard_router
|
||||||
@@ -20,6 +18,7 @@ from src.api.instance_proxy import router as instance_proxy_router
|
|||||||
from src.api.config_folders import router as config_folders_router
|
from src.api.config_folders import router as config_folders_router
|
||||||
from src.api.config_profiles import router as config_profiles_router
|
from src.api.config_profiles import router as config_profiles_router
|
||||||
from src.api.tool_configs import router as tool_configs_router
|
from src.api.tool_configs import router as tool_configs_router
|
||||||
|
from src.api.tool_definitions import router as tool_definitions_router
|
||||||
from src.api.tool_instances import router as tool_instances_router
|
from src.api.tool_instances import router as tool_instances_router
|
||||||
from src.api.tool_instances import sessions_router
|
from src.api.tool_instances import sessions_router
|
||||||
from src.api.tool_types import router as tool_types_router
|
from src.api.tool_types import router as tool_types_router
|
||||||
@@ -68,7 +67,9 @@ def _sanitize_validation_errors(errors):
|
|||||||
"type": error.get("type"),
|
"type": error.get("type"),
|
||||||
"loc": error.get("loc"),
|
"loc": error.get("loc"),
|
||||||
"msg": error.get("msg"),
|
"msg": error.get("msg"),
|
||||||
"input": str(error.get("input")) if error.get("input") is not None else None,
|
"input": str(error.get("input"))
|
||||||
|
if error.get("input") is not None
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
# Convert ctx to safe format
|
# Convert ctx to safe format
|
||||||
ctx = error.get("ctx")
|
ctx = error.get("ctx")
|
||||||
@@ -112,10 +113,12 @@ async def on_startup():
|
|||||||
if not db_ready:
|
if not db_ready:
|
||||||
logger.error("Database initialization failed. Shutting down.")
|
logger.error("Database initialization failed. Shutting down.")
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
@@ -125,6 +128,7 @@ app.include_router(ssh_keys_router)
|
|||||||
app.include_router(git_repositories_router)
|
app.include_router(git_repositories_router)
|
||||||
app.include_router(user_config_router)
|
app.include_router(user_config_router)
|
||||||
app.include_router(tool_types_router)
|
app.include_router(tool_types_router)
|
||||||
|
app.include_router(tool_definitions_router)
|
||||||
app.include_router(config_folders_router)
|
app.include_router(config_folders_router)
|
||||||
app.include_router(config_profiles_router)
|
app.include_router(config_profiles_router)
|
||||||
app.include_router(tool_instances_router)
|
app.include_router(tool_instances_router)
|
||||||
|
|||||||
@@ -4,9 +4,23 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
|||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
__all__ = ["Base", "ConfigFolder", "ConfigProfile", "ConfigProfileInclude", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"ConfigFolder",
|
||||||
|
"ConfigProfile",
|
||||||
|
"ConfigProfileInclude",
|
||||||
|
"GitRepository",
|
||||||
|
"Project",
|
||||||
|
"SSHKey",
|
||||||
|
"ToolDefinitionManifest",
|
||||||
|
"ToolInstance",
|
||||||
|
"ToolType",
|
||||||
|
"User",
|
||||||
|
"UserConfig",
|
||||||
|
]
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
files: Mapped[dict] = mapped_column(
|
files: Mapped[dict] = mapped_column(
|
||||||
JSON, default=dict, nullable=False
|
JSON, default=dict, nullable=False
|
||||||
) # {"rel/path": "content", ...}
|
) # {"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)
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
user: Mapped["User"] = relationship()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(255))
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
path: Mapped[str] = mapped_column(String(1024))
|
path: Mapped[str] = mapped_column(String(1024))
|
||||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False)
|
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True)
|
||||||
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
||||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tool Definition Manifest model."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
|
||||||
|
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.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ToolDefinitionManifest(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
"""A declarative manifest that compiles to Dockerfile + Compose.
|
||||||
|
|
||||||
|
Can be either:
|
||||||
|
- A base definition (is_base=True) with a FROM image and common packages
|
||||||
|
- A tool definition (is_base=False) that references a base + adds specifics
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tool_definition_manifests"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
category: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
interface_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
|
||||||
|
# Base: either a direct image or a reference to another manifest
|
||||||
|
base_image: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
base_definition_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(),
|
||||||
|
ForeignKey("tool_definition_manifests.id"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
base_version: Mapped[str] = mapped_column(
|
||||||
|
String(32), nullable=False, default="latest"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The full manifest JSON
|
||||||
|
manifest: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
|
||||||
|
# Caches for quick inspection
|
||||||
|
dockerfile_cache: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
compose_cache: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Versioning
|
||||||
|
version: Mapped[str] = mapped_column(String(32), nullable=False, default="v1")
|
||||||
|
is_base: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
created_by: Mapped["User | None"] = relationship(
|
||||||
|
foreign_keys=[created_by_id],
|
||||||
|
)
|
||||||
|
base_definition: Mapped["ToolDefinitionManifest | None"] = relationship(
|
||||||
|
remote_side="ToolDefinitionManifest.id",
|
||||||
|
foreign_keys=[base_definition_id],
|
||||||
|
)
|
||||||
@@ -33,42 +33,26 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(), ForeignKey("users.id"), nullable=False
|
UUID(), ForeignKey("users.id"), nullable=False
|
||||||
)
|
)
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
|
||||||
String(50), nullable=False, default="pending"
|
container_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
)
|
container_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
container_id: Mapped[str | None] = mapped_column(
|
compose_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
String(255), nullable=True
|
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
)
|
public_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
container_name: Mapped[str | None] = mapped_column(
|
tunnel_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
String(255), nullable=True
|
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
)
|
|
||||||
compose_path: Mapped[str | None] = mapped_column(
|
|
||||||
String(1024), nullable=True
|
|
||||||
)
|
|
||||||
url: Mapped[str | None] = mapped_column(
|
|
||||||
String(1024), nullable=True
|
|
||||||
)
|
|
||||||
public_url: Mapped[str | None] = mapped_column(
|
|
||||||
String(1024), nullable=True
|
|
||||||
)
|
|
||||||
tunnel_id: Mapped[str | None] = mapped_column(
|
|
||||||
String(255), nullable=True
|
|
||||||
)
|
|
||||||
port: Mapped[int | None] = mapped_column(
|
|
||||||
Integer, nullable=True
|
|
||||||
)
|
|
||||||
last_started_at: Mapped[datetime | None] = mapped_column(
|
last_started_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
probe_result: Mapped[dict | None] = mapped_column(
|
manifest_compiled_at: Mapped[datetime | None] = mapped_column(
|
||||||
JSON, nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
|
||||||
clone_mode: Mapped[str] = mapped_column(
|
|
||||||
String(20), nullable=False, default="mount"
|
|
||||||
)
|
)
|
||||||
|
image_tag: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
probe_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
clone_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="mount")
|
||||||
branch: Mapped[str | None] = mapped_column(
|
branch: Mapped[str | None] = mapped_column(
|
||||||
String(255), nullable=True, default="main"
|
String(255), nullable=True, default="main"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -18,23 +19,36 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||||
interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
|
interface_type: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="web"
|
||||||
|
)
|
||||||
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
default_port: Mapped[int] = mapped_column(nullable=False)
|
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||||
definition_type: Mapped[str] = mapped_column(
|
definition_type: Mapped[str] = mapped_column(
|
||||||
String(20), nullable=False, default="compose"
|
String(16), nullable=False, default="legacy"
|
||||||
) # "compose" or "dockerfile"
|
) # "legacy" | "manifest"
|
||||||
|
manifest_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(),
|
||||||
|
ForeignKey("tool_definition_manifests.id"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
compose_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
compose_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
dockerfile_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
dockerfile_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
build_context: Mapped[dict | None] = mapped_column(
|
build_context: Mapped[dict | None] = mapped_column(
|
||||||
JSON, default=dict, nullable=True
|
JSON, default=dict, nullable=True
|
||||||
)
|
)
|
||||||
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
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(
|
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(),
|
UUID(),
|
||||||
ForeignKey("users.id"),
|
ForeignKey("users.id"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
manifest: Mapped["ToolDefinitionManifest | None"] = relationship(
|
||||||
|
foreign_keys=[manifest_id],
|
||||||
|
)
|
||||||
created_by: Mapped["User | None"] = relationship()
|
created_by: Mapped["User | None"] = relationship()
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def clone_repository(
|
|||||||
str(clone_path),
|
str(clone_path),
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
logger.debug("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -55,7 +55,7 @@ def clone_repository(
|
|||||||
logger.error("Git clone failed: %s", result.stderr)
|
logger.error("Git clone failed: %s", result.stderr)
|
||||||
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
||||||
|
|
||||||
logger.info("Successfully cloned repository into %s", clone_path)
|
logger.debug("Successfully cloned repository into %s", clone_path)
|
||||||
return str(clone_path)
|
return str(clone_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -94,4 +94,4 @@ def remove_clone_directory(instance_dir: str) -> None:
|
|||||||
if clone_path.exists():
|
if clone_path.exists():
|
||||||
import shutil
|
import shutil
|
||||||
shutil.rmtree(clone_path)
|
shutil.rmtree(clone_path)
|
||||||
logger.info("Removed clone directory: %s", clone_path)
|
logger.debug("Removed clone directory: %s", clone_path)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class ResolvedProfile:
|
|||||||
env_vars: dict[str, str] = field(default_factory=dict)
|
env_vars: dict[str, str] = field(default_factory=dict)
|
||||||
runtime_hints: dict[str, Any] = field(default_factory=dict)
|
runtime_hints: dict[str, Any] = field(default_factory=dict)
|
||||||
mounts: dict[str, ResolvedMount] = 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)
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
env_overrides: 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)
|
hint_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
@@ -168,6 +169,28 @@ def _merge_mounts(
|
|||||||
return result
|
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(
|
async def _resolve_profile_recursive(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
@@ -244,6 +267,9 @@ async def _resolve_profile_recursive(
|
|||||||
result.mount_overrides,
|
result.mount_overrides,
|
||||||
included.profile_name,
|
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)
|
# Apply the profile's own settings (selected profile overrides includes)
|
||||||
result.env_vars = _merge_env_vars(
|
result.env_vars = _merge_env_vars(
|
||||||
@@ -270,7 +296,11 @@ async def _resolve_profile_recursive(
|
|||||||
result.mount_overrides,
|
result.mount_overrides,
|
||||||
profile.name,
|
profile.name,
|
||||||
)
|
)
|
||||||
|
result.git_mounts = _merge_git_mounts(
|
||||||
|
result.git_mounts,
|
||||||
|
profile.git_mounts or [],
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -449,5 +479,6 @@ def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
|||||||
"files": resolved.file_overrides,
|
"files": resolved.file_overrides,
|
||||||
"mounts": resolved.mount_overrides,
|
"mounts": resolved.mount_overrides,
|
||||||
},
|
},
|
||||||
|
"git_mounts": resolved.git_mounts,
|
||||||
"included_profiles": resolved.included_profiles,
|
"included_profiles": resolved.included_profiles,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Docker service for managing tool instances."""
|
"""Docker service for managing tool instances."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ def ensure_instance_directory(instance_id: str, base_path: str | None = None) ->
|
|||||||
"""
|
"""
|
||||||
if base_path is None:
|
if base_path is None:
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
|
||||||
base_path = Settings().instance_base_path
|
base_path = Settings().instance_base_path
|
||||||
instance_dir = Path(base_path) / instance_id
|
instance_dir = Path(base_path) / instance_id
|
||||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -87,7 +90,7 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
|||||||
full_path.resolve().relative_to(instance_path.resolve())
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
|
|
||||||
@@ -109,7 +112,7 @@ def execute_compose_command(
|
|||||||
instance_dir = Path(compose_path).parent
|
instance_dir = Path(compose_path).parent
|
||||||
|
|
||||||
cmd = ["docker", "compose", "-f", compose_path]
|
cmd = ["docker", "compose", "-f", compose_path]
|
||||||
|
|
||||||
if env_file:
|
if env_file:
|
||||||
cmd.extend(["--env-file", env_file])
|
cmd.extend(["--env-file", env_file])
|
||||||
|
|
||||||
@@ -136,14 +139,17 @@ def execute_compose_command(
|
|||||||
def get_container_id(instance_name: str) -> str | None:
|
def get_container_id(instance_name: str) -> str | None:
|
||||||
"""Get the container ID for a compose service.
|
"""Get the container ID for a compose service.
|
||||||
|
|
||||||
|
Searches all containers including stopped/exited ones.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
instance_name: The service name in compose
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Container ID or None if not found
|
Container ID or None if not found
|
||||||
"""
|
"""
|
||||||
|
# Docker container names are lowercase internally; normalize to ensure match
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name.lower()}"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
@@ -156,14 +162,25 @@ def get_container_id(instance_name: str) -> str | None:
|
|||||||
def get_container_name(instance_name: str) -> str | None:
|
def get_container_name(instance_name: str) -> str | None:
|
||||||
"""Get the full container name for a compose service.
|
"""Get the full container name for a compose service.
|
||||||
|
|
||||||
|
Searches all containers including stopped/exited ones.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
instance_name: The service name in compose
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Container name or None if not found
|
Container name or None if not found
|
||||||
"""
|
"""
|
||||||
|
# Docker container names are lowercase internally; normalize to ensure match
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
[
|
||||||
|
"docker",
|
||||||
|
"ps",
|
||||||
|
"-a",
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}",
|
||||||
|
"--filter",
|
||||||
|
f"name={instance_name.lower()}",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
@@ -173,7 +190,9 @@ def get_container_name(instance_name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
def connect_container_to_network(
|
||||||
|
container_name: str, network_name: str = "backend"
|
||||||
|
) -> bool:
|
||||||
"""Connect a Docker container to an existing network.
|
"""Connect a Docker container to an existing network.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -198,12 +217,14 @@ def get_container_status(container_id: str) -> dict[str, Any]:
|
|||||||
container_id: Docker container ID
|
container_id: Docker container ID
|
||||||
|
|
||||||
Returns:
|
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)
|
'exit_code' (int or None), and 'health' (health status or None)
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"docker", "inspect", "-f",
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||||
container_id,
|
container_id,
|
||||||
],
|
],
|
||||||
@@ -213,12 +234,12 @@ def get_container_status(container_id: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return {"status": "not_found", "exit_code": None, "health": None}
|
return {"status": "not_found", "exit_code": None, "health": None}
|
||||||
|
|
||||||
parts = result.stdout.strip().split("|")
|
parts = result.stdout.strip().split("|")
|
||||||
status = parts[0] if parts else "unknown"
|
status = parts[0] if parts else "unknown"
|
||||||
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
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
|
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||||
|
|
||||||
return {"status": status, "exit_code": exit_code, "health": health}
|
return {"status": status, "exit_code": exit_code, "health": health}
|
||||||
|
|
||||||
|
|
||||||
@@ -238,13 +259,12 @@ def wait_for_container_running(
|
|||||||
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||||
and 'waited_seconds' (float)
|
and 'waited_seconds' (float)
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
while time.time() - start_time < timeout:
|
while time.time() - start_time < timeout:
|
||||||
info = get_container_status(container_id)
|
info = get_container_status(container_id)
|
||||||
|
|
||||||
if info["status"] == "running":
|
if info["status"] == "running":
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -252,7 +272,7 @@ def wait_for_container_running(
|
|||||||
"exit_code": None,
|
"exit_code": None,
|
||||||
"waited_seconds": time.time() - start_time,
|
"waited_seconds": time.time() - start_time,
|
||||||
}
|
}
|
||||||
|
|
||||||
if info["status"] == "exited":
|
if info["status"] == "exited":
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -260,7 +280,7 @@ def wait_for_container_running(
|
|||||||
"exit_code": info["exit_code"],
|
"exit_code": info["exit_code"],
|
||||||
"waited_seconds": time.time() - start_time,
|
"waited_seconds": time.time() - start_time,
|
||||||
}
|
}
|
||||||
|
|
||||||
if info["status"] == "not_found":
|
if info["status"] == "not_found":
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -268,9 +288,9 @@ def wait_for_container_running(
|
|||||||
"exit_code": None,
|
"exit_code": None,
|
||||||
"waited_seconds": time.time() - start_time,
|
"waited_seconds": time.time() - start_time,
|
||||||
}
|
}
|
||||||
|
|
||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
|
|
||||||
# Timeout reached
|
# Timeout reached
|
||||||
info = get_container_status(container_id)
|
info = get_container_status(container_id)
|
||||||
return {
|
return {
|
||||||
@@ -322,11 +342,6 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
|||||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||||
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def start_cloudflared_tunnel(
|
def start_cloudflared_tunnel(
|
||||||
container_name: str, port: int, timeout: int = 30
|
container_name: str, port: int, timeout: int = 30
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -344,8 +359,6 @@ def start_cloudflared_tunnel(
|
|||||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
|
||||||
import re
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -354,18 +367,29 @@ def start_cloudflared_tunnel(
|
|||||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||||
for attempt in range(10):
|
for attempt in range(10):
|
||||||
check = subprocess.run(
|
check = subprocess.run(
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
[
|
||||||
f"http://{container_name}:{port}"],
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
f"http://{container_name}:{port}",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
logger.info(
|
||||||
|
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
|
||||||
|
)
|
||||||
if check.returncode == 0:
|
if check.returncode == 0:
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
else:
|
else:
|
||||||
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
logger.warning(
|
||||||
|
"Container %s:%d not responding to curl checks", container_name, port
|
||||||
|
)
|
||||||
|
|
||||||
# Run cloudflared in background, capture output
|
# Run cloudflared in background, capture output
|
||||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||||
@@ -381,9 +405,15 @@ def start_cloudflared_tunnel(
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
url = None
|
url = None
|
||||||
|
|
||||||
|
if proc.stdout is None:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
raise RuntimeError("Failed to capture cloudflared output")
|
||||||
|
|
||||||
while time.time() - start_time < timeout:
|
while time.time() - start_time < timeout:
|
||||||
# Read available output
|
# Read available output
|
||||||
import select
|
import select
|
||||||
|
|
||||||
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||||
if readable:
|
if readable:
|
||||||
line = proc.stdout.readline()
|
line = proc.stdout.readline()
|
||||||
@@ -410,7 +440,6 @@ def stop_cloudflared_tunnel(pid: str) -> None:
|
|||||||
Args:
|
Args:
|
||||||
pid: Process ID of the cloudflared tunnel
|
pid: Process ID of the cloudflared tunnel
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -455,14 +484,23 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
[
|
||||||
"--max-time", str(timeout), url],
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
str(timeout),
|
||||||
|
url,
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout + 5,
|
timeout=timeout + 5,
|
||||||
)
|
)
|
||||||
status_code = int(result.stdout.strip())
|
status_code = int(result.stdout.strip())
|
||||||
|
|
||||||
if 200 <= status_code < 400:
|
if 200 <= status_code < 400:
|
||||||
return {
|
return {
|
||||||
"tunnel_status": "healthy",
|
"tunnel_status": "healthy",
|
||||||
@@ -495,7 +533,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|||||||
except (ValueError, Exception) as e:
|
except (ValueError, Exception) as e:
|
||||||
error_str = str(e).lower()
|
error_str = str(e).lower()
|
||||||
# Classify connection errors
|
# Classify connection errors
|
||||||
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
|
if any(
|
||||||
|
err in error_str
|
||||||
|
for err in [
|
||||||
|
"connection refused",
|
||||||
|
"econnrefused",
|
||||||
|
"could not resolve",
|
||||||
|
"nodename",
|
||||||
|
]
|
||||||
|
):
|
||||||
return {
|
return {
|
||||||
"tunnel_status": "unreachable",
|
"tunnel_status": "unreachable",
|
||||||
"status_code": None,
|
"status_code": None,
|
||||||
|
|||||||
@@ -18,13 +18,12 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (returncode, stdout, stderr)
|
Tuple of (returncode, stdout, stderr)
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Write Dockerfile
|
# Write Dockerfile
|
||||||
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||||
dockerfile_path.write_text(dockerfile)
|
dockerfile_path.write_text(dockerfile)
|
||||||
logger.info("Wrote Dockerfile to %s", dockerfile_path)
|
logger.debug("Wrote Dockerfile to %s", dockerfile_path)
|
||||||
|
|
||||||
# Write build context files
|
# Write build context files
|
||||||
if build_context:
|
if build_context:
|
||||||
@@ -39,10 +38,10 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
logger.info("Wrote build context file: %s", full_path)
|
logger.debug("Wrote build context file: %s", full_path)
|
||||||
|
|
||||||
# Build image
|
# Build image
|
||||||
logger.info("Building Docker image with tag: %s", tag)
|
logger.debug("Building Docker image with tag: %s", tag)
|
||||||
cmd = [
|
cmd = [
|
||||||
"docker", "build",
|
"docker", "build",
|
||||||
"-t", tag,
|
"-t", tag,
|
||||||
@@ -57,7 +56,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=300, # 5 minute timeout for builds
|
timeout=300, # 5 minute timeout for builds
|
||||||
)
|
)
|
||||||
logger.info("Docker build completed: returncode=%d", result.returncode)
|
logger.debug("Docker build completed: returncode=%d", result.returncode)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("Docker build failed: %s", result.stderr[:1000])
|
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||||
return result.returncode, result.stdout, result.stderr
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"""Manifest compiler: transforms ToolDefinitionManifest into Dockerfile + Compose."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_base(manifest: dict) -> dict:
|
||||||
|
"""Merge a base definition into a tool manifest.
|
||||||
|
|
||||||
|
If the manifest has base_definition_id, the base manifest is loaded
|
||||||
|
and merged. Tool-specific values override base values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: The tool manifest JSON (may reference a base)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A fully resolved manifest with base values merged in.
|
||||||
|
"""
|
||||||
|
result = deepcopy(manifest)
|
||||||
|
|
||||||
|
base_definition_id = result.pop("base_definition_id", None)
|
||||||
|
base_version = result.pop("base_version", "latest")
|
||||||
|
|
||||||
|
if base_definition_id:
|
||||||
|
# This will be provided by the caller (they have the DB session)
|
||||||
|
# For now, we assume the manifest has been pre-resolved
|
||||||
|
# or the caller provides the base manifest separately.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def deep_merge(base: dict, override: dict) -> dict:
|
||||||
|
"""Deep merge two manifests. Arrays are concatenated; dicts are merged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base: The base manifest.
|
||||||
|
override: The tool-specific overrides.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merged manifest.
|
||||||
|
"""
|
||||||
|
merged = deepcopy(base)
|
||||||
|
|
||||||
|
for key, value in override.items():
|
||||||
|
if key == "mounts" and isinstance(value, list):
|
||||||
|
# Concatenate mount arrays
|
||||||
|
existing = merged.get("mounts", [])
|
||||||
|
merged["mounts"] = existing + deepcopy(value)
|
||||||
|
elif key == "scripts" and isinstance(value, dict):
|
||||||
|
# Merge script categories
|
||||||
|
if "scripts" not in merged:
|
||||||
|
merged["scripts"] = {}
|
||||||
|
for script_key, script_value in value.items():
|
||||||
|
existing = merged["scripts"].get(script_key, [])
|
||||||
|
merged["scripts"][script_key] = existing + deepcopy(script_value)
|
||||||
|
elif key == "packages" and isinstance(value, dict):
|
||||||
|
# Union package arrays
|
||||||
|
if "packages" not in merged:
|
||||||
|
merged["packages"] = {}
|
||||||
|
for pkg_key, pkg_value in value.items():
|
||||||
|
if (
|
||||||
|
pkg_key in merged["packages"]
|
||||||
|
and isinstance(merged["packages"][pkg_key], list)
|
||||||
|
and isinstance(pkg_value, list)
|
||||||
|
):
|
||||||
|
merged["packages"][pkg_key] = merged["packages"][
|
||||||
|
pkg_key
|
||||||
|
] + deepcopy(pkg_value)
|
||||||
|
else:
|
||||||
|
merged["packages"][pkg_key] = deepcopy(pkg_value)
|
||||||
|
elif key == "env" and isinstance(value, dict):
|
||||||
|
# Dict merge: override wins on key conflict
|
||||||
|
if "env" not in merged:
|
||||||
|
merged["env"] = {}
|
||||||
|
merged["env"].update(deepcopy(value))
|
||||||
|
elif (
|
||||||
|
isinstance(value, dict) and key in merged and isinstance(merged[key], dict)
|
||||||
|
):
|
||||||
|
# Generic dict merge
|
||||||
|
merged[key] = {**merged[key], **deepcopy(value)}
|
||||||
|
else:
|
||||||
|
# Override entirely
|
||||||
|
merged[key] = deepcopy(value)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def compile_dockerfile(manifest: dict) -> str:
|
||||||
|
"""Compile a resolved manifest into a Dockerfile string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dockerfile content.
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
# FROM
|
||||||
|
base_image = manifest.get("base_image", "ubuntu:24.04")
|
||||||
|
lines.append(f"FROM {base_image}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Build-time environment
|
||||||
|
env = manifest.get("env", {})
|
||||||
|
for key, value in env.items():
|
||||||
|
lines.append(f"ENV {key}={shlex.quote(value)}")
|
||||||
|
if env:
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# System packages (apt)
|
||||||
|
apt_packages = manifest.get("packages", {}).get("apt", [])
|
||||||
|
if apt_packages:
|
||||||
|
lines.append("RUN apt-get update && apt-get install -y \\\\")
|
||||||
|
for pkg in apt_packages[:-1]:
|
||||||
|
lines.append(f" {pkg} \\\\")
|
||||||
|
lines.append(f" {apt_packages[-1]} \\\\")
|
||||||
|
lines.append(" && rm -rf /var/lib/apt/lists/*")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node = manifest.get("packages", {}).get("node")
|
||||||
|
if node:
|
||||||
|
version = node.get("version", "20")
|
||||||
|
lines.append(
|
||||||
|
f"RUN curl -fsSL https://deb.nodesource.com/setup_{version}.x | bash - && \\\\"
|
||||||
|
)
|
||||||
|
lines.append(" apt-get install -y nodejs && \\\\")
|
||||||
|
lines.append(" rm -rf /var/lib/apt/lists/*")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# NPM global packages
|
||||||
|
npm_packages = manifest.get("packages", {}).get("npm_global", [])
|
||||||
|
if npm_packages:
|
||||||
|
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
|
||||||
|
lines.append(f"RUN npm install -g {pkg_list}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Pip packages
|
||||||
|
pip_packages = manifest.get("packages", {}).get("pip", [])
|
||||||
|
if pip_packages:
|
||||||
|
pkg_list = " ".join(shlex.quote(p) for p in pip_packages)
|
||||||
|
lines.append(f"RUN pip install {pkg_list}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# User creation
|
||||||
|
user = manifest.get("user")
|
||||||
|
if user:
|
||||||
|
name = user["name"]
|
||||||
|
uid = user["uid"]
|
||||||
|
gid = user["gid"]
|
||||||
|
create_home = "-m " if user.get("create_home", True) else ""
|
||||||
|
shell = user.get("shell", "/bin/bash")
|
||||||
|
lines.append(f"RUN groupadd -g {gid} {name} && \\\\")
|
||||||
|
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Build scripts
|
||||||
|
build_scripts = manifest.get("scripts", {}).get("build", [])
|
||||||
|
for script in build_scripts:
|
||||||
|
# Normalize multi-line scripts into single RUN command
|
||||||
|
stripped_lines = [
|
||||||
|
line.strip() for line in script.strip().split("\n") if line.strip()
|
||||||
|
]
|
||||||
|
if stripped_lines:
|
||||||
|
normalized = " && ".join(stripped_lines)
|
||||||
|
lines.append(f"RUN {normalized}")
|
||||||
|
if build_scripts:
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Create mount target directories
|
||||||
|
mounts = manifest.get("mounts", [])
|
||||||
|
if mounts:
|
||||||
|
dirs = [mount["target"] for mount in mounts]
|
||||||
|
dir_str = " ".join(dirs)
|
||||||
|
lines.append(f"RUN mkdir -p {dir_str}")
|
||||||
|
if user:
|
||||||
|
lines.append(f"RUN chown -R {user['name']}:{user['name']} {dir_str}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Entrypoint for startup scripts
|
||||||
|
startup_scripts = manifest.get("scripts", {}).get("startup", [])
|
||||||
|
if startup_scripts:
|
||||||
|
lines.append(
|
||||||
|
"COPY .headquarter/entrypoint.sh /usr/local/bin/headquarter-entrypoint"
|
||||||
|
)
|
||||||
|
lines.append("RUN chmod +x /usr/local/bin/headquarter-entrypoint")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Switch to runtime user
|
||||||
|
if user:
|
||||||
|
lines.append(f"USER {user['name']}")
|
||||||
|
lines.append(f"WORKDIR /home/{user['name']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Entrypoint and CMD
|
||||||
|
runtime = manifest.get("runtime", {})
|
||||||
|
if startup_scripts:
|
||||||
|
lines.append('ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]')
|
||||||
|
|
||||||
|
command = runtime.get("command", ["/bin/bash"])
|
||||||
|
cmd_json = json.dumps(command)
|
||||||
|
lines.append(f"CMD {cmd_json}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_entrypoint(manifest: dict) -> str:
|
||||||
|
"""Generate the startup entrypoint script from startup scripts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Shell script content.
|
||||||
|
"""
|
||||||
|
lines = ["#!/bin/bash", "set -e", ""]
|
||||||
|
|
||||||
|
startup_scripts = manifest.get("scripts", {}).get("startup", [])
|
||||||
|
for script in startup_scripts:
|
||||||
|
lines.append(script)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append('exec "$@"')
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
||||||
|
"""Compile a resolved manifest into a Docker Compose string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
variables: Resolved values: IMAGE_TAG, INSTANCE_NAME, REPO_PATH, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Docker Compose YAML content.
|
||||||
|
"""
|
||||||
|
runtime = manifest.get("runtime", {})
|
||||||
|
user = manifest.get("user")
|
||||||
|
interface_type = manifest["interface_type"]
|
||||||
|
|
||||||
|
service: dict[str, Any] = {
|
||||||
|
"image": variables["IMAGE_TAG"],
|
||||||
|
"container_name": variables["INSTANCE_NAME"],
|
||||||
|
"restart": "unless-stopped",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Terminal-specific fields
|
||||||
|
if runtime.get("stdin_open", False):
|
||||||
|
service["stdin_open"] = True
|
||||||
|
if runtime.get("tty", False):
|
||||||
|
service["tty"] = True
|
||||||
|
if runtime.get("working_dir"):
|
||||||
|
service["working_dir"] = runtime["working_dir"]
|
||||||
|
|
||||||
|
# User override
|
||||||
|
if user:
|
||||||
|
service["user"] = f"{user['uid']}:{user['gid']}"
|
||||||
|
|
||||||
|
# Ports for web tools
|
||||||
|
default_port = manifest.get("default_port")
|
||||||
|
if interface_type == "web" and default_port:
|
||||||
|
service["ports"] = [f"{variables['TOOL_PORT']}:{default_port}"]
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
env = manifest.get("env", {})
|
||||||
|
if env:
|
||||||
|
service["environment"] = dict(env)
|
||||||
|
|
||||||
|
# Merge extra env from config
|
||||||
|
extra_env = variables.get("EXTRA_ENV", {})
|
||||||
|
if extra_env:
|
||||||
|
if "environment" not in service:
|
||||||
|
service["environment"] = {}
|
||||||
|
service["environment"].update(extra_env)
|
||||||
|
|
||||||
|
# Volumes from mount schema
|
||||||
|
volumes = []
|
||||||
|
for mount in manifest.get("mounts", []):
|
||||||
|
source = resolve_mount_source(mount, variables)
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
target = mount["target"]
|
||||||
|
readonly = ":ro" if mount.get("readonly", False) else ""
|
||||||
|
volumes.append(f"{source}:{target}{readonly}")
|
||||||
|
|
||||||
|
# Append extra volumes from tool config / config profile
|
||||||
|
for vol in variables.get("EXTRA_VOLUMES", []):
|
||||||
|
vol_str = f"{vol['source']}:{vol['target']}"
|
||||||
|
if vol.get("readonly"):
|
||||||
|
vol_str += ":ro"
|
||||||
|
volumes.append(vol_str)
|
||||||
|
|
||||||
|
if volumes:
|
||||||
|
service["volumes"] = volumes
|
||||||
|
|
||||||
|
compose = {"services": {"app": service}}
|
||||||
|
return yaml.dump(compose, default_flow_style=False)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
|
||||||
|
"""Resolve a mount's source_type to an actual host path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mount: Mount definition from manifest.
|
||||||
|
variables: Resolved variables dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Host path string, or empty string if unresolved.
|
||||||
|
"""
|
||||||
|
source_type = mount.get("source_type", "host_path")
|
||||||
|
|
||||||
|
if source_type == "repo":
|
||||||
|
return variables.get("REPO_PATH", "")
|
||||||
|
elif source_type == "ssh_key":
|
||||||
|
return variables.get("SSH_PATH", "")
|
||||||
|
elif source_type == "instance":
|
||||||
|
instance_dir = variables.get("INSTANCE_DIR", "")
|
||||||
|
mount_name = mount.get("name", "unknown")
|
||||||
|
return f"{instance_dir}/mounts/{mount_name}"
|
||||||
|
elif source_type == "git_mount":
|
||||||
|
ref = mount.get("git_mount_ref", "default")
|
||||||
|
return variables.get(f"GIT_MOUNT_{ref}", "")
|
||||||
|
elif source_type == "host_path":
|
||||||
|
return mount.get("source", "")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
||||||
|
"""Compute a deterministic image tag from manifest content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Human-readable tool name.
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Docker image tag string.
|
||||||
|
"""
|
||||||
|
# Canonicalize: sort keys, stable JSON
|
||||||
|
canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":"))
|
||||||
|
hash_suffix = hashlib.sha256(canonical.encode()).hexdigest()[:8]
|
||||||
|
safe_name = tool_name.lower().replace(" ", "-").replace("_", "-")
|
||||||
|
return f"headquarter/{safe_name}-{hash_suffix}:latest"
|
||||||
|
|
||||||
|
|
||||||
|
def merge_with_config(
|
||||||
|
manifest: dict, tool_configs: list[dict], profile: dict | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Merge ToolConfig and ConfigProfile overrides into a manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Base manifest from tool definition.
|
||||||
|
tool_configs: List of ToolConfig records.
|
||||||
|
profile: Resolved ConfigProfile (optional).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Manifest with overrides applied.
|
||||||
|
"""
|
||||||
|
result = deepcopy(manifest)
|
||||||
|
|
||||||
|
# Apply ToolConfigs
|
||||||
|
extra_env: dict[str, str] = {}
|
||||||
|
extra_volumes: list[dict] = []
|
||||||
|
|
||||||
|
for config in tool_configs:
|
||||||
|
if config.get("config_type") == "env":
|
||||||
|
extra_env[config["key"]] = config["value"]
|
||||||
|
elif config.get("config_type") == "file" and config.get("file_path"):
|
||||||
|
# Files are handled outside the manifest (written to instance dir)
|
||||||
|
pass
|
||||||
|
if config.get("port_override"):
|
||||||
|
result["default_port"] = config["port_override"]
|
||||||
|
if config.get("start_command"):
|
||||||
|
result["runtime"] = result.get("runtime", {})
|
||||||
|
result["runtime"]["command"] = config["start_command"].split()
|
||||||
|
if config.get("working_directory"):
|
||||||
|
result["runtime"] = result.get("runtime", {})
|
||||||
|
result["runtime"]["working_dir"] = config["working_directory"]
|
||||||
|
if config.get("environment_variables"):
|
||||||
|
extra_env.update(config["environment_variables"])
|
||||||
|
if config.get("volumes"):
|
||||||
|
extra_volumes.extend(config["volumes"])
|
||||||
|
|
||||||
|
# Apply ConfigProfile
|
||||||
|
if profile:
|
||||||
|
if profile.get("environment_variables"):
|
||||||
|
extra_env.update(profile["environment_variables"])
|
||||||
|
if profile.get("mounts"):
|
||||||
|
extra_volumes.extend(profile["mounts"])
|
||||||
|
# Profile hints override everything
|
||||||
|
hints = profile.get("hints", {})
|
||||||
|
if hints.get("start_command"):
|
||||||
|
result["runtime"] = result.get("runtime", {})
|
||||||
|
result["runtime"]["command"] = hints["start_command"].split()
|
||||||
|
if hints.get("working_directory"):
|
||||||
|
result["runtime"] = result.get("runtime", {})
|
||||||
|
result["runtime"]["working_dir"] = hints["working_directory"]
|
||||||
|
if hints.get("port_override"):
|
||||||
|
result["default_port"] = hints["port_override"]
|
||||||
|
|
||||||
|
# Store merged extras for the compose compiler
|
||||||
|
result["_extra_env"] = extra_env
|
||||||
|
result["_extra_volumes"] = extra_volumes
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Permission fixer: applies mount permission policies post-start."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_mount_permissions(
|
||||||
|
container_id: str,
|
||||||
|
mounts: list[dict],
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Apply permission policies to mounted directories in a running container.
|
||||||
|
|
||||||
|
Runs `chown`, `chmod`, and file-mode fixes for each mount that declares
|
||||||
|
an owner, mode, or file_mode. Requires the container to have a root user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
mounts: List of mount definitions from the manifest.
|
||||||
|
timeout: Max seconds per docker exec command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of result dicts: [{mount_name, success, error}]
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for mount in mounts:
|
||||||
|
name = mount.get("name", "unknown")
|
||||||
|
target = mount["target"]
|
||||||
|
owner = mount.get("owner")
|
||||||
|
mode = mount.get("mode")
|
||||||
|
file_mode = mount.get("file_mode")
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"mount_name": name,
|
||||||
|
"success": True,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skip if no permission policy defined
|
||||||
|
if not owner and not mode and not file_mode:
|
||||||
|
results.append(result)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if owner:
|
||||||
|
_run_in_container(
|
||||||
|
container_id,
|
||||||
|
["chown", "-R", f"{owner}:{owner}", target],
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Applied owner %s to %s in container %s",
|
||||||
|
owner,
|
||||||
|
target,
|
||||||
|
container_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode and result["success"]:
|
||||||
|
_run_in_container(
|
||||||
|
container_id,
|
||||||
|
["chmod", mode, target],
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Applied mode %s to %s in container %s",
|
||||||
|
mode,
|
||||||
|
target,
|
||||||
|
container_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if file_mode and result["success"]:
|
||||||
|
_run_in_container(
|
||||||
|
container_id,
|
||||||
|
[
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
f"find {target} -type f -exec chmod {file_mode} {{}} +",
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Applied file_mode %s to files in %s in container %s",
|
||||||
|
file_mode,
|
||||||
|
target,
|
||||||
|
container_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except PermissionFixError as exc:
|
||||||
|
result["success"] = False
|
||||||
|
result["error"] = str(exc)
|
||||||
|
logger.warning(
|
||||||
|
"Permission fix failed for mount %s (target=%s): %s",
|
||||||
|
name,
|
||||||
|
target,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionFixError(Exception):
|
||||||
|
"""Raised when a permission fix command fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run_in_container(
|
||||||
|
container_id: str,
|
||||||
|
command: list[str],
|
||||||
|
timeout: int,
|
||||||
|
) -> None:
|
||||||
|
"""Run a command inside a container as root.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
command: Command + args to execute.
|
||||||
|
timeout: Max seconds to wait.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionFixError: If the command fails or times out.
|
||||||
|
"""
|
||||||
|
cmd = ["docker", "exec", "--user", "root", container_id] + command
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise PermissionFixError(
|
||||||
|
f"Command timed out after {timeout}s: {' '.join(command)}"
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise PermissionFixError(f"Docker command not found: {' '.join(command)}")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise PermissionFixError(
|
||||||
|
f"Command failed (rc={result.returncode}): {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_root_user_available(container_id: str, timeout: int = 5) -> bool:
|
||||||
|
"""Check if the container has a root user we can exec as.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
timeout: Max seconds to wait.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if root user exists and is usable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_run_in_container(container_id, ["id", "root"], timeout)
|
||||||
|
return True
|
||||||
|
except PermissionFixError:
|
||||||
|
return False
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
@@ -58,6 +57,7 @@ class TerminalManager:
|
|||||||
self,
|
self,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
container_id: str,
|
container_id: str,
|
||||||
|
startup_command: str | None = None,
|
||||||
) -> TerminalSession:
|
) -> TerminalSession:
|
||||||
"""Get existing session or create a new one."""
|
"""Get existing session or create a new one."""
|
||||||
# Ensure idle check is running (lazy start)
|
# Ensure idle check is running (lazy start)
|
||||||
@@ -71,19 +71,19 @@ class TerminalManager:
|
|||||||
|
|
||||||
# Check if session is still alive
|
# Check if session is still alive
|
||||||
if session.is_alive():
|
if session.is_alive():
|
||||||
logger.info("Reattaching to existing terminal session for instance %s", instance_id)
|
logger.debug("Reattaching to existing terminal session for instance %s", instance_id)
|
||||||
return session
|
return session
|
||||||
else:
|
else:
|
||||||
# Session died, clean it up
|
# Session died, clean it up
|
||||||
logger.info("Existing session for instance %s is dead, cleaning up", instance_id)
|
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id)
|
||||||
await session.close()
|
await session.close()
|
||||||
del self._sessions[instance_id_str]
|
del self._sessions[instance_id_str]
|
||||||
|
|
||||||
# Create new session
|
# Create new session
|
||||||
logger.info("Creating new terminal session for instance %s", instance_id)
|
logger.info("Creating new terminal session for instance %s", instance_id)
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
session = TerminalSession(session_id, instance_id, container_id)
|
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
|
||||||
await session.start()
|
await session.start(startup_command=startup_command)
|
||||||
self._sessions[instance_id_str] = session
|
self._sessions[instance_id_str] = session
|
||||||
|
|
||||||
return session
|
return session
|
||||||
@@ -96,7 +96,7 @@ class TerminalManager:
|
|||||||
"""Attach a WebSocket to an existing session."""
|
"""Attach a WebSocket to an existing session."""
|
||||||
# Handle concurrent connections - close existing ones
|
# Handle concurrent connections - close existing ones
|
||||||
if session.has_websockets():
|
if session.has_websockets():
|
||||||
logger.info("Closing existing WebSocket connections for instance %s", session.instance_id)
|
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
|
||||||
for ws in list(session._websockets):
|
for ws in list(session._websockets):
|
||||||
try:
|
try:
|
||||||
await ws.close(code=4000, reason="New connection established")
|
await ws.close(code=4000, reason="New connection established")
|
||||||
@@ -127,20 +127,21 @@ class TerminalManager:
|
|||||||
self,
|
self,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
container_id: str,
|
container_id: str,
|
||||||
|
startup_command: str | None = None,
|
||||||
) -> TerminalSession:
|
) -> TerminalSession:
|
||||||
"""Reset a session by killing it and creating a new one."""
|
"""Reset a session by killing it and creating a new one."""
|
||||||
instance_id_str = str(instance_id)
|
instance_id_str = str(instance_id)
|
||||||
|
|
||||||
# Close existing session if any
|
# Close existing session if any
|
||||||
if instance_id_str in self._sessions:
|
if instance_id_str in self._sessions:
|
||||||
logger.info("Resetting terminal session for instance %s", instance_id)
|
logger.debug("Resetting terminal session for instance %s", instance_id)
|
||||||
old_session = self._sessions.pop(instance_id_str)
|
old_session = self._sessions.pop(instance_id_str)
|
||||||
await old_session.close()
|
await old_session.close()
|
||||||
|
|
||||||
# Create new session
|
# Create new session
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
session = TerminalSession(session_id, instance_id, container_id)
|
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
|
||||||
await session.start()
|
await session.start(startup_command=startup_command)
|
||||||
self._sessions[instance_id_str] = session
|
self._sessions[instance_id_str] = session
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|||||||
@@ -29,10 +29,11 @@ class TerminalSession:
|
|||||||
# Idle timeout in seconds (30 minutes)
|
# Idle timeout in seconds (30 minutes)
|
||||||
IDLE_TIMEOUT = 30 * 60
|
IDLE_TIMEOUT = 30 * 60
|
||||||
|
|
||||||
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
|
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command: str | None = None) -> None:
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.instance_id = instance_id
|
self.instance_id = instance_id
|
||||||
self.container_id = container_id
|
self.container_id = container_id
|
||||||
|
self.startup_command = startup_command
|
||||||
self.process: asyncio.subprocess.Process | None = None
|
self.process: asyncio.subprocess.Process | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._master_fd: int | None = None
|
self._master_fd: int | None = None
|
||||||
@@ -52,14 +53,21 @@ class TerminalSession:
|
|||||||
self._cols = 80
|
self._cols = 80
|
||||||
self._rows = 24
|
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."""
|
"""Start the docker exec process with a shell using a PTY."""
|
||||||
# Create a pseudo-terminal on the host
|
# Create a pseudo-terminal on the host
|
||||||
self._master_fd, self._slave_fd = pty.openpty()
|
self._master_fd, self._slave_fd = pty.openpty()
|
||||||
|
|
||||||
# Set the terminal size initially
|
# Set the terminal size initially
|
||||||
self._set_terminal_size(self._cols, self._rows)
|
self._set_terminal_size(self._cols, self._rows)
|
||||||
logger.info(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
|
logger.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
|
||||||
|
|
||||||
|
# Build the shell command
|
||||||
|
if startup_command:
|
||||||
|
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
|
||||||
|
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
|
||||||
|
else:
|
||||||
|
shell_cmd = "bash -il"
|
||||||
|
|
||||||
# Start docker exec with the slave fd as stdin/stdout/stderr
|
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||||
# Using -it because the slave fd IS a TTY
|
# Using -it because the slave fd IS a TTY
|
||||||
@@ -71,7 +79,8 @@ class TerminalSession:
|
|||||||
"TERM=xterm",
|
"TERM=xterm",
|
||||||
self.container_id,
|
self.container_id,
|
||||||
"bash",
|
"bash",
|
||||||
"-il",
|
"-c",
|
||||||
|
shell_cmd,
|
||||||
stdin=self._slave_fd,
|
stdin=self._slave_fd,
|
||||||
stdout=self._slave_fd,
|
stdout=self._slave_fd,
|
||||||
stderr=self._slave_fd,
|
stderr=self._slave_fd,
|
||||||
@@ -93,7 +102,7 @@ class TerminalSession:
|
|||||||
size = struct.pack('HHHH', rows, cols, 0, 0)
|
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||||
try:
|
try:
|
||||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||||
logger.info(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
||||||
except (OSError, IOError) as e:
|
except (OSError, IOError) as e:
|
||||||
logger.error(f"Failed to resize PTY: {e}")
|
logger.error(f"Failed to resize PTY: {e}")
|
||||||
|
|
||||||
@@ -150,7 +159,7 @@ class TerminalSession:
|
|||||||
|
|
||||||
self._cols = cols
|
self._cols = cols
|
||||||
self._rows = rows
|
self._rows = rows
|
||||||
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
||||||
self._set_terminal_size(cols, rows)
|
self._set_terminal_size(cols, rows)
|
||||||
|
|
||||||
# Docker exec -it creates its own PTY inside the container,
|
# Docker exec -it creates its own PTY inside the container,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def _run_git_command(repo_path: str, *args: str) -> str:
|
def _run_git_command(repo_path: str, *args: str) -> str:
|
||||||
|
|||||||
@@ -8,16 +8,14 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
# Set test environment BEFORE importing app modules
|
# Set test environment BEFORE importing app modules
|
||||||
os.environ["APP_ENV"] = "testing"
|
os.environ["APP_ENV"] = "testing"
|
||||||
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings
|
||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.main import app
|
from src.main import app
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
@@ -133,6 +131,65 @@ def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
|||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_project_and_repo(authenticated_client) -> tuple[str, str]:
|
||||||
|
"""Create a project and repository directly in the database."""
|
||||||
|
import uuid
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
|
||||||
|
project_id = uuid.uuid4()
|
||||||
|
repo_id = uuid.uuid4()
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
# Get user ID from session
|
||||||
|
async def get_user_id():
|
||||||
|
nonlocal user_id
|
||||||
|
from src.auth.session import decode_session_cookie
|
||||||
|
settings = Settings()
|
||||||
|
session_cookie = authenticated_client.cookies.get("session")
|
||||||
|
if session_cookie:
|
||||||
|
session = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
|
if session:
|
||||||
|
user_id = uuid.UUID(session["user_id"])
|
||||||
|
|
||||||
|
asyncio.run(get_user_id())
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise RuntimeError("Could not get user ID from authenticated client")
|
||||||
|
|
||||||
|
async def create_project_and_repo():
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
project = Project(
|
||||||
|
id=project_id,
|
||||||
|
name="test-project",
|
||||||
|
description="Test project",
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(project)
|
||||||
|
|
||||||
|
repo = GitRepository(
|
||||||
|
id=repo_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/tmp/test-repo",
|
||||||
|
project_id=project_id,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url="https://github.com/test/repo.git",
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.commit()
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
|
||||||
|
asyncio.run(create_project_and_repo())
|
||||||
|
|
||||||
|
return str(project_id), str(repo_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
"""Provide an authenticated test client with an admin user."""
|
"""Provide an authenticated test client with an admin user."""
|
||||||
|
|||||||
@@ -320,3 +320,134 @@ class TestConfigProfilesAPI:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["profile_id"] is None
|
assert data["profile_id"] is None
|
||||||
|
|
||||||
|
def test_create_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test creating a config profile with git mounts."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "git-mount-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "git-mount-profile"
|
||||||
|
assert len(data["git_mounts"]) == 1
|
||||||
|
assert data["git_mounts"][0]["target_path"] == "/app"
|
||||||
|
assert data["git_mounts"][0]["branch"] == "main"
|
||||||
|
|
||||||
|
def test_update_config_profile_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test updating git mounts on a config profile."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
# Create profile first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "update-git-mounts",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update with git mounts
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{profile_id}",
|
||||||
|
json={
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": "config",
|
||||||
|
"target_path": "/config",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["git_mounts"]) == 1
|
||||||
|
assert data["git_mounts"][0]["source_path"] == "config"
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_git_mount_source_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test that invalid git mount source paths are rejected."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-git-mount",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": "/absolute/path",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test that git mount target paths with traversal are rejected."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-git-mount-target",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "../../../etc/passwd",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_preview_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test previewing a profile with git mounts."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
# Create profile with git mounts
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "preview-git-mounts",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Preview
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{profile_id}/preview")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["git_mounts"]) == 1
|
||||||
|
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
|
||||||
|
|||||||
@@ -82,28 +82,6 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
|
|||||||
assert UserConfig.user.property.mapper.class_ is User
|
assert UserConfig.user.property.mapper.class_ is User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
|
|
||||||
def test_refresh_token_table_has_required_columns_and_relationships() -> None:
|
|
||||||
columns = RefreshToken.__table__.columns
|
|
||||||
user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys))
|
|
||||||
|
|
||||||
assert set(columns.keys()) == {
|
|
||||||
"id",
|
|
||||||
"user_id",
|
|
||||||
"token_hash",
|
|
||||||
"expires_at",
|
|
||||||
"revoked_at",
|
|
||||||
"user_agent",
|
|
||||||
"ip_address",
|
|
||||||
"created_at",
|
|
||||||
}
|
|
||||||
assert columns["token_hash"].unique is True
|
|
||||||
assert columns["revoked_at"].nullable is True
|
|
||||||
assert user_fk.target_fullname == "users.id"
|
|
||||||
assert RefreshToken.user.property.mapper.class_ is User
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import uuid
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import uuid
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -221,5 +220,79 @@ class TestToolTypesAPIExtended:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
_ = response.json()
|
||||||
|
|
||||||
|
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a tool type with startup_command."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "startup-tool",
|
||||||
|
"display_name": "Startup Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||||
|
"startup_command": "cd /workspace && ls",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
assert data["startup_command"] == "cd /workspace && ls"
|
||||||
|
assert data["interface_type"] == "terminal"
|
||||||
|
|
||||||
|
def test_update_tool_type_startup_command(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a tool type's startup_command."""
|
||||||
|
# Create tool type first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "update-startup-tool",
|
||||||
|
"display_name": "Update Startup Tool",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update with startup_command
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/tool-types/{tool_id}",
|
||||||
|
json={
|
||||||
|
"startup_command": "source /etc/profile",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["startup_command"] == "source /etc/profile"
|
||||||
|
|
||||||
|
def test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that GET returns startup_command."""
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "get-startup-tool",
|
||||||
|
"display_name": "Get Startup Tool",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||||
|
"startup_command": "echo hello",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
response = authenticated_client.get(f"/tool-types/{tool_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["startup_command"] == "echo hello"
|
||||||
assert "Port 9999 is not exposed" in str(data)
|
assert "Port 9999 is not exposed" in str(data)
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
|||||||
from src.services.config_profile_resolver import (
|
from src.services.config_profile_resolver import (
|
||||||
ConfigProfileCycleError,
|
ConfigProfileCycleError,
|
||||||
ConfigProfileNotFoundError,
|
ConfigProfileNotFoundError,
|
||||||
ResolvedProfile,
|
|
||||||
check_include_cycle,
|
check_include_cycle,
|
||||||
resolve_profile,
|
resolve_profile,
|
||||||
_merge_env_vars,
|
_merge_env_vars,
|
||||||
_merge_files,
|
_merge_files,
|
||||||
_merge_mounts,
|
_merge_mounts,
|
||||||
_merge_runtime_hints,
|
_merge_runtime_hints,
|
||||||
|
_merge_git_mounts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -62,7 +62,6 @@ class TestMergeFunctions:
|
|||||||
|
|
||||||
def test_merge_mounts_basic(self) -> None:
|
def test_merge_mounts_basic(self) -> None:
|
||||||
"""Test basic mount merging."""
|
"""Test basic mount merging."""
|
||||||
from src.services.config_profile_resolver import ResolvedMount
|
|
||||||
result = _merge_mounts(
|
result = _merge_mounts(
|
||||||
{},
|
{},
|
||||||
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
|
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
|
||||||
@@ -97,6 +96,39 @@ class TestMergeFunctions:
|
|||||||
assert result["/app"].mode == "ro"
|
assert result["/app"].mode == "ro"
|
||||||
assert overrides == {"/app": "source"}
|
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:
|
class TestResolveProfile:
|
||||||
"""Unit tests for profile resolution."""
|
"""Unit tests for profile resolution."""
|
||||||
@@ -250,6 +282,76 @@ class TestResolveProfile:
|
|||||||
with pytest.raises(ConfigProfileCycleError):
|
with pytest.raises(ConfigProfileCycleError):
|
||||||
await resolve_profile(db_session, profile_a.id)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
|
||||||
"""Test resolving a non-existent profile."""
|
"""Test resolving a non-existent profile."""
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Unit tests for docker service utilities."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from src.services.docker import get_container_id, get_container_name
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetContainerId:
|
||||||
|
"""Tests for get_container_id."""
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_lowercases_name_for_filter(self, mock_run) -> None:
|
||||||
|
"""Docker ps name filter is case-sensitive; we must lowercase."""
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="abc123\n")
|
||||||
|
|
||||||
|
result = get_container_id("MyContainer-ABC")
|
||||||
|
|
||||||
|
assert result == "abc123"
|
||||||
|
call_args = mock_run.call_args[0][0]
|
||||||
|
# The filter must use lowercase
|
||||||
|
assert "name=mycontainer-abc" in call_args
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_returns_none_when_not_found(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="")
|
||||||
|
|
||||||
|
result = get_container_id("missing")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetContainerName:
|
||||||
|
"""Tests for get_container_name."""
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_lowercases_name_for_filter(self, mock_run) -> None:
|
||||||
|
"""Docker ps name filter is case-sensitive; we must lowercase."""
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="mycontainer-abc\n")
|
||||||
|
|
||||||
|
result = get_container_name("MyContainer-ABC")
|
||||||
|
|
||||||
|
assert result == "mycontainer-abc"
|
||||||
|
call_args = mock_run.call_args[0][0]
|
||||||
|
assert "name=mycontainer-abc" in call_args
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_returns_none_when_not_found(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="")
|
||||||
|
|
||||||
|
result = get_container_name("missing")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Unit tests for git mount resolution in tool instances."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.api.tool_instances import (
|
||||||
|
_checkout_branch,
|
||||||
|
_expand_glob_source,
|
||||||
|
_resolve_single_git_mount,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandGlobSource:
|
||||||
|
"""Unit tests for glob pattern expansion."""
|
||||||
|
|
||||||
|
def test_no_glob_single_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Test non-glob path returns single file."""
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(test_file), str(tmp_path))
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0] == str(test_file)
|
||||||
|
|
||||||
|
def test_no_glob_missing_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Test non-glob missing file returns empty list."""
|
||||||
|
missing_file = tmp_path / "missing.txt"
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(missing_file), str(tmp_path))
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
def test_glob_pattern(self, tmp_path: Path) -> None:
|
||||||
|
"""Test glob pattern matches files."""
|
||||||
|
(tmp_path / "file1.txt").write_text("content1")
|
||||||
|
(tmp_path / "file2.txt").write_text("content2")
|
||||||
|
(tmp_path / "other.py").write_text("code")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
|
||||||
|
assert len(result) == 2
|
||||||
|
assert all(f.endswith(".txt") for f in result)
|
||||||
|
|
||||||
|
def test_glob_recursive(self, tmp_path: Path) -> None:
|
||||||
|
"""Test recursive glob pattern."""
|
||||||
|
subdir = tmp_path / "subdir"
|
||||||
|
subdir.mkdir()
|
||||||
|
(subdir / "nested.txt").write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path / "**" / "*.txt"), str(tmp_path))
|
||||||
|
assert len(result) == 1
|
||||||
|
assert "nested.txt" in result[0]
|
||||||
|
|
||||||
|
def test_glob_limit_enforced(self, tmp_path: Path) -> None:
|
||||||
|
"""Test that glob matches are limited to prevent abuse."""
|
||||||
|
# Create more than 100 files
|
||||||
|
for i in range(105):
|
||||||
|
(tmp_path / f"file{i}.txt").write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
|
||||||
|
assert len(result) == 100 # MAX_GLOB_MATCHES limit
|
||||||
|
|
||||||
|
def test_glob_escapes_repo(self, tmp_path: Path) -> None:
|
||||||
|
"""Test that glob results outside repo are filtered."""
|
||||||
|
other_dir = tmp_path.parent / "other"
|
||||||
|
other_dir.mkdir(exist_ok=True)
|
||||||
|
(other_dir / "outside.txt").write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path.parent / "*" / "*.txt"), str(tmp_path))
|
||||||
|
# Should only include files within tmp_path, not other_dir
|
||||||
|
assert all(r.startswith(str(tmp_path)) for r in result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckoutBranch:
|
||||||
|
"""Unit tests for branch checkout."""
|
||||||
|
|
||||||
|
def test_checkout_existing_branch(self, tmp_path: Path) -> None:
|
||||||
|
"""Test checking out an existing branch."""
|
||||||
|
# Initialize git repo
|
||||||
|
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||||
|
(tmp_path / "file.txt").write_text("content")
|
||||||
|
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||||
|
os.system(f"cd {tmp_path} && git branch feature")
|
||||||
|
|
||||||
|
_checkout_branch(str(tmp_path), "feature")
|
||||||
|
|
||||||
|
# Verify we're on feature branch
|
||||||
|
result = os.popen(f"cd {tmp_path} && git branch --show-current").read().strip()
|
||||||
|
assert result == "feature"
|
||||||
|
|
||||||
|
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
|
||||||
|
"""Test checking out a non-existent branch returns False."""
|
||||||
|
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||||
|
(tmp_path / "file.txt").write_text("content")
|
||||||
|
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||||
|
|
||||||
|
result = _checkout_branch(str(tmp_path), "nonexistent")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveSingleGitMount:
|
||||||
|
"""Unit tests for resolving a single git mount."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_missing_remote_url(self, db_session) -> None:
|
||||||
|
"""Test that missing remote_url returns empty list."""
|
||||||
|
git_mount = {
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_missing_target_path(self, db_session) -> None:
|
||||||
|
"""Test that missing target path returns empty list."""
|
||||||
|
git_mount = {
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||||
|
assert result == []
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for git URL parsing utilities."""
|
"""Tests for git URL parsing utilities."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
|
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""Unit tests for the manifest compiler."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.manifest_compiler import (
|
||||||
|
compile_compose,
|
||||||
|
compile_dockerfile,
|
||||||
|
compile_entrypoint,
|
||||||
|
compute_image_tag,
|
||||||
|
deep_merge,
|
||||||
|
merge_with_config,
|
||||||
|
resolve_base,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveBase:
|
||||||
|
"""Tests for resolve_base."""
|
||||||
|
|
||||||
|
def test_returns_manifest_unchanged_when_no_base(self) -> None:
|
||||||
|
manifest = {"name": "test", "base_image": "ubuntu:24.04"}
|
||||||
|
result = resolve_base(manifest)
|
||||||
|
assert result["name"] == "test"
|
||||||
|
assert "base_definition_id" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeepMerge:
|
||||||
|
"""Tests for deep_merge."""
|
||||||
|
|
||||||
|
def test_packages_are_unioned(self) -> None:
|
||||||
|
base = {"packages": {"apt": ["curl", "git"]}}
|
||||||
|
override = {"packages": {"apt": ["neovim"]}}
|
||||||
|
result = deep_merge(base, override)
|
||||||
|
assert result["packages"]["apt"] == ["curl", "git", "neovim"]
|
||||||
|
|
||||||
|
def test_node_version_overrides(self) -> None:
|
||||||
|
base = {"packages": {"node": {"version": "18"}}}
|
||||||
|
override = {"packages": {"node": {"version": "20"}}}
|
||||||
|
result = deep_merge(base, override)
|
||||||
|
assert result["packages"]["node"]["version"] == "20"
|
||||||
|
|
||||||
|
def test_env_is_merged_with_override_winning(self) -> None:
|
||||||
|
base = {"env": {"FOO": "base", "BAR": "base"}}
|
||||||
|
override = {"env": {"FOO": "override"}}
|
||||||
|
result = deep_merge(base, override)
|
||||||
|
assert result["env"]["FOO"] == "override"
|
||||||
|
assert result["env"]["BAR"] == "base"
|
||||||
|
|
||||||
|
def test_build_scripts_are_concatenated(self) -> None:
|
||||||
|
base = {"scripts": {"build": ["echo base"]}}
|
||||||
|
override = {"scripts": {"build": ["echo override"]}}
|
||||||
|
result = deep_merge(base, override)
|
||||||
|
assert result["scripts"]["build"] == ["echo base", "echo override"]
|
||||||
|
|
||||||
|
def test_mounts_are_concatenated(self) -> None:
|
||||||
|
base = {"mounts": [{"name": "base-mount", "target": "/base"}]}
|
||||||
|
override = {"mounts": [{"name": "tool-mount", "target": "/tool"}]}
|
||||||
|
result = deep_merge(base, override)
|
||||||
|
assert len(result["mounts"]) == 2
|
||||||
|
|
||||||
|
def test_user_is_overridden_entirely(self) -> None:
|
||||||
|
base = {"user": {"name": "base", "uid": 1000}}
|
||||||
|
override = {"user": {"name": "tool", "uid": 1001}}
|
||||||
|
result = deep_merge(base, override)
|
||||||
|
assert result["user"]["name"] == "tool"
|
||||||
|
assert result["user"]["uid"] == 1001
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompileDockerfile:
|
||||||
|
"""Tests for compile_dockerfile."""
|
||||||
|
|
||||||
|
def test_includes_from(self) -> None:
|
||||||
|
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert "FROM ubuntu:24.04" in df
|
||||||
|
|
||||||
|
def test_installs_apt_packages(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"name": "test",
|
||||||
|
"packages": {"apt": ["curl", "git"]},
|
||||||
|
}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert "apt-get install -y" in df
|
||||||
|
assert "curl" in df
|
||||||
|
assert "git" in df
|
||||||
|
assert "rm -rf /var/lib/apt/lists/*" in df
|
||||||
|
|
||||||
|
def test_installs_node(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"name": "test",
|
||||||
|
"packages": {"node": {"version": "20"}},
|
||||||
|
}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert "nodesource.com/setup_20.x" in df
|
||||||
|
|
||||||
|
def test_installs_npm_global(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"name": "test",
|
||||||
|
"packages": {"npm_global": ["@scope/pkg"]},
|
||||||
|
}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert "npm install -g @scope/pkg" in df
|
||||||
|
|
||||||
|
def test_creates_user(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"name": "test",
|
||||||
|
"user": {"name": "dev", "uid": 1001, "gid": 1001},
|
||||||
|
}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert "groupadd -g 1001 dev" in df
|
||||||
|
assert "useradd -u 1001 -g 1001" in df
|
||||||
|
assert "USER dev" in df
|
||||||
|
|
||||||
|
def test_build_scripts_as_run_commands(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"name": "test",
|
||||||
|
"scripts": {"build": ["echo hello", "echo world"]},
|
||||||
|
}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert "RUN echo hello" in df
|
||||||
|
assert "RUN echo world" in df
|
||||||
|
|
||||||
|
def test_creates_mount_directories(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"name": "test",
|
||||||
|
"user": {"name": "dev", "uid": 1001, "gid": 1001},
|
||||||
|
"mounts": [
|
||||||
|
{"name": "ws", "target": "/workspace"},
|
||||||
|
{"name": "cfg", "target": "/config"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert "mkdir -p /workspace /config" in df
|
||||||
|
assert "chown -R dev:dev /workspace /config" in df
|
||||||
|
|
||||||
|
def test_entrypoint_for_startup_scripts(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"name": "test",
|
||||||
|
"scripts": {"startup": ["echo start"]},
|
||||||
|
}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert 'ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]' in df
|
||||||
|
|
||||||
|
def test_cmd_from_runtime(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"name": "test",
|
||||||
|
"runtime": {"command": ["/bin/bash", "-il"]},
|
||||||
|
}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert 'CMD ["/bin/bash", "-il"]' in df
|
||||||
|
|
||||||
|
def test_default_cmd_when_no_runtime(self) -> None:
|
||||||
|
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert 'CMD ["/bin/bash"]' in df
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompileEntrypoint:
|
||||||
|
"""Tests for compile_entrypoint."""
|
||||||
|
|
||||||
|
def test_includes_shebang_and_set_e(self) -> None:
|
||||||
|
manifest = {"scripts": {"startup": ["echo hello"]}}
|
||||||
|
ep = compile_entrypoint(manifest)
|
||||||
|
assert "#!/bin/bash" in ep
|
||||||
|
assert "set -e" in ep
|
||||||
|
|
||||||
|
def test_includes_startup_scripts(self) -> None:
|
||||||
|
manifest = {"scripts": {"startup": ["echo hello", "echo world"]}}
|
||||||
|
ep = compile_entrypoint(manifest)
|
||||||
|
assert "echo hello" in ep
|
||||||
|
assert "echo world" in ep
|
||||||
|
|
||||||
|
def test_ends_with_exec(self) -> None:
|
||||||
|
manifest: dict = {"scripts": {"startup": []}}
|
||||||
|
ep = compile_entrypoint(manifest)
|
||||||
|
assert 'exec "$@"' in ep
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompileCompose:
|
||||||
|
"""Tests for compile_compose."""
|
||||||
|
|
||||||
|
def test_includes_image_and_container_name(self) -> None:
|
||||||
|
manifest = {"name": "test", "interface_type": "terminal"}
|
||||||
|
vars_dict = {"IMAGE_TAG": "test:v1", "INSTANCE_NAME": "test-1"}
|
||||||
|
compose = compile_compose(manifest, vars_dict)
|
||||||
|
assert "image: test:v1" in compose
|
||||||
|
assert "container_name: test-1" in compose
|
||||||
|
|
||||||
|
def test_terminal_fields(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"name": "test",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"runtime": {"stdin_open": True, "tty": True, "working_dir": "/workspace"},
|
||||||
|
}
|
||||||
|
compose = compile_compose(manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n"})
|
||||||
|
assert "stdin_open: true" in compose
|
||||||
|
assert "tty: true" in compose
|
||||||
|
assert "working_dir: /workspace" in compose
|
||||||
|
|
||||||
|
def test_web_ports(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"name": "test",
|
||||||
|
"interface_type": "web",
|
||||||
|
"default_port": 8080,
|
||||||
|
}
|
||||||
|
compose = compile_compose(
|
||||||
|
manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n", "TOOL_PORT": "3000"}
|
||||||
|
)
|
||||||
|
assert "3000:8080" in compose
|
||||||
|
|
||||||
|
def test_user_override(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"name": "test",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"user": {"uid": 1001, "gid": 1001},
|
||||||
|
}
|
||||||
|
compose = compile_compose(manifest, {"IMAGE_TAG": "t", "INSTANCE_NAME": "n"})
|
||||||
|
assert "user: 1001:1001" in compose
|
||||||
|
|
||||||
|
def test_mounts_resolved(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"name": "test",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"mounts": [
|
||||||
|
{"name": "ws", "target": "/workspace", "source_type": "repo"},
|
||||||
|
{
|
||||||
|
"name": "ssh",
|
||||||
|
"target": "/home/user/.ssh",
|
||||||
|
"source_type": "ssh_key",
|
||||||
|
"readonly": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
compose = compile_compose(
|
||||||
|
manifest,
|
||||||
|
{
|
||||||
|
"IMAGE_TAG": "t",
|
||||||
|
"INSTANCE_NAME": "n",
|
||||||
|
"REPO_PATH": "/repos/myrepo",
|
||||||
|
"SSH_PATH": "/keys/ssh",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert "/repos/myrepo:/workspace" in compose
|
||||||
|
assert "/keys/ssh:/home/user/.ssh:ro" in compose
|
||||||
|
|
||||||
|
def test_extra_volumes_appended(self) -> None:
|
||||||
|
manifest = {"name": "test", "interface_type": "terminal"}
|
||||||
|
compose = compile_compose(
|
||||||
|
manifest,
|
||||||
|
{
|
||||||
|
"IMAGE_TAG": "t",
|
||||||
|
"INSTANCE_NAME": "n",
|
||||||
|
"EXTRA_VOLUMES": [{"source": "/host/x", "target": "/container/x"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert "/host/x:/container/x" in compose
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputeImageTag:
|
||||||
|
"""Tests for compute_image_tag."""
|
||||||
|
|
||||||
|
def test_is_deterministic(self) -> None:
|
||||||
|
manifest = {"name": "test", "packages": {"apt": ["curl"]}}
|
||||||
|
tag1 = compute_image_tag("My Tool", manifest)
|
||||||
|
tag2 = compute_image_tag("My Tool", manifest)
|
||||||
|
assert tag1 == tag2
|
||||||
|
|
||||||
|
def test_changes_with_content(self) -> None:
|
||||||
|
manifest1 = {"name": "test", "packages": {"apt": ["curl"]}}
|
||||||
|
manifest2 = {"name": "test", "packages": {"apt": ["wget"]}}
|
||||||
|
tag1 = compute_image_tag("test", manifest1)
|
||||||
|
tag2 = compute_image_tag("test", manifest2)
|
||||||
|
assert tag1 != tag2
|
||||||
|
|
||||||
|
def test_lowercases_name(self) -> None:
|
||||||
|
manifest = {"name": "test"}
|
||||||
|
tag = compute_image_tag("My Tool", manifest)
|
||||||
|
assert "my-tool" in tag
|
||||||
|
|
||||||
|
def test_valid_docker_reference(self) -> None:
|
||||||
|
manifest = {"name": "test"}
|
||||||
|
tag = compute_image_tag("test", manifest)
|
||||||
|
assert tag.startswith("headquarter/test-")
|
||||||
|
assert tag.endswith(":latest")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeWithConfig:
|
||||||
|
"""Tests for merge_with_config."""
|
||||||
|
|
||||||
|
def test_applies_tool_config_env(self) -> None:
|
||||||
|
manifest = {"name": "test"}
|
||||||
|
configs = [
|
||||||
|
{"config_type": "env", "key": "FOO", "value": "bar"},
|
||||||
|
]
|
||||||
|
result = merge_with_config(manifest, configs)
|
||||||
|
assert result["_extra_env"]["FOO"] == "bar"
|
||||||
|
|
||||||
|
def test_applies_port_override(self) -> None:
|
||||||
|
manifest = {"name": "test", "default_port": 8080}
|
||||||
|
configs = [{"port_override": 3000}]
|
||||||
|
result = merge_with_config(manifest, configs)
|
||||||
|
assert result["default_port"] == 3000
|
||||||
|
|
||||||
|
def test_applies_start_command(self) -> None:
|
||||||
|
manifest = {"name": "test", "runtime": {"command": ["/bin/bash"]}}
|
||||||
|
configs = [{"start_command": "/bin/sh"}]
|
||||||
|
result = merge_with_config(manifest, configs)
|
||||||
|
assert result["runtime"]["command"] == ["/bin/sh"]
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Unit tests for the permission fixer."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.permission_fixer import (
|
||||||
|
PermissionFixError,
|
||||||
|
apply_mount_permissions,
|
||||||
|
check_root_user_available,
|
||||||
|
_run_in_container,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyMountPermissions:
|
||||||
|
"""Tests for apply_mount_permissions."""
|
||||||
|
|
||||||
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
|
def test_applies_chown_when_owner_declared(self, mock_run) -> None:
|
||||||
|
mounts = [
|
||||||
|
{"name": "workspace", "target": "/workspace", "owner": "user"},
|
||||||
|
]
|
||||||
|
results = apply_mount_permissions("abc123", mounts)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["mount_name"] == "workspace"
|
||||||
|
assert results[0]["success"] is True
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
args = mock_run.call_args[0]
|
||||||
|
assert args[0] == "abc123"
|
||||||
|
assert args[1] == ["chown", "-R", "user:user", "/workspace"]
|
||||||
|
|
||||||
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
|
def test_applies_chmod_when_mode_declared(self, mock_run) -> None:
|
||||||
|
mounts = [
|
||||||
|
{"name": "ssh", "target": "/home/user/.ssh", "mode": "0700"},
|
||||||
|
]
|
||||||
|
results = apply_mount_permissions("abc123", mounts)
|
||||||
|
|
||||||
|
assert results[0]["success"] is True
|
||||||
|
# Only chmod called (no owner, so no chown)
|
||||||
|
assert mock_run.call_count == 1
|
||||||
|
chmod_call = mock_run.call_args_list[0]
|
||||||
|
assert chmod_call[0][1] == ["chmod", "0700", "/home/user/.ssh"]
|
||||||
|
|
||||||
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
|
def test_applies_file_mode_when_declared(self, mock_run) -> None:
|
||||||
|
mounts = [
|
||||||
|
{
|
||||||
|
"name": "ssh",
|
||||||
|
"target": "/home/user/.ssh",
|
||||||
|
"file_mode": "0600",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
results = apply_mount_permissions("abc123", mounts)
|
||||||
|
|
||||||
|
assert results[0]["success"] is True
|
||||||
|
# Only file_mode called (no owner, no mode)
|
||||||
|
assert mock_run.call_count == 1
|
||||||
|
file_mode_call = mock_run.call_args_list[0]
|
||||||
|
assert file_mode_call[0][1][0] == "sh"
|
||||||
|
assert (
|
||||||
|
"find /home/user/.ssh -type f -exec chmod 0600" in file_mode_call[0][1][2]
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
|
def test_skips_mount_with_no_policy(self, mock_run) -> None:
|
||||||
|
mounts = [
|
||||||
|
{"name": "workspace", "target": "/workspace", "writable": True},
|
||||||
|
]
|
||||||
|
results = apply_mount_permissions("abc123", mounts)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["success"] is True
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
|
def test_reports_failure_on_command_error(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = PermissionFixError("chown failed")
|
||||||
|
|
||||||
|
mounts = [
|
||||||
|
{"name": "workspace", "target": "/workspace", "owner": "user"},
|
||||||
|
]
|
||||||
|
results = apply_mount_permissions("abc123", mounts)
|
||||||
|
|
||||||
|
assert results[0]["success"] is False
|
||||||
|
assert "chown failed" in results[0]["error"]
|
||||||
|
|
||||||
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
|
def test_stops_on_first_failure(self, mock_run) -> None:
|
||||||
|
"""If chown fails, chmod and file_mode should not run."""
|
||||||
|
mock_run.side_effect = PermissionFixError("chown failed")
|
||||||
|
|
||||||
|
mounts = [
|
||||||
|
{
|
||||||
|
"name": "workspace",
|
||||||
|
"target": "/workspace",
|
||||||
|
"owner": "user",
|
||||||
|
"mode": "0755",
|
||||||
|
"file_mode": "0644",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
results = apply_mount_permissions("abc123", mounts)
|
||||||
|
|
||||||
|
assert results[0]["success"] is False
|
||||||
|
assert mock_run.call_count == 1 # Only chown attempted
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunInContainer:
|
||||||
|
"""Tests for _run_in_container."""
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_success(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||||
|
_run_in_container("abc123", ["echo", "hello"], 10)
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert cmd == ["docker", "exec", "--user", "root", "abc123", "echo", "hello"]
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_failure_raises(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stderr="permission denied")
|
||||||
|
with pytest.raises(PermissionFixError, match="permission denied"):
|
||||||
|
_run_in_container("abc123", ["chown", "x"], 10)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_timeout_raises(self, mock_run) -> None:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["docker"], timeout=10)
|
||||||
|
with pytest.raises(PermissionFixError, match="timed out"):
|
||||||
|
_run_in_container("abc123", ["chown", "x"], 10)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckRootUserAvailable:
|
||||||
|
"""Tests for check_root_user_available."""
|
||||||
|
|
||||||
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
|
def test_returns_true_when_root_exists(self, mock_run) -> None:
|
||||||
|
assert check_root_user_available("abc123") is True
|
||||||
|
|
||||||
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
|
def test_returns_false_when_root_missing(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = PermissionFixError("no such user")
|
||||||
|
assert check_root_user_available("abc123") is False
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
"""Unit tests for readiness probe service."""
|
"""Unit tests for readiness probe service."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.services.readiness_probe import execute_probe
|
from src.services.readiness_probe import execute_probe
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from src.api.tool_instances import CreateInstanceRequest
|
from src.api.tool_instances import CreateInstanceRequest
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,804 @@
|
|||||||
|
"""Unit tests for legacy tool instance fallback paths.
|
||||||
|
|
||||||
|
Verifies that tool types with definition_type "dockerfile", "compose",
|
||||||
|
and "legacy" continue to use the original startup flow after the
|
||||||
|
manifest-based flow was introduced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.api.tool_instances import create_instance, start_instance
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_user_id() -> uuid.UUID:
|
||||||
|
return uuid.uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_project_id() -> uuid.UUID:
|
||||||
|
return uuid.uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_repo_id() -> uuid.UUID:
|
||||||
|
return uuid.uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_tool_type_id() -> uuid.UUID:
|
||||||
|
return uuid.uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_instance_id() -> uuid.UUID:
|
||||||
|
return uuid.uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_session(fake_user_id, fake_project_id, fake_repo_id, fake_tool_type_id):
|
||||||
|
"""Return an async SQLAlchemy session with basic mocks."""
|
||||||
|
session = AsyncMock()
|
||||||
|
|
||||||
|
user = User(id=fake_user_id, email="test@example.com")
|
||||||
|
project = MagicMock()
|
||||||
|
project.id = fake_project_id
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return user
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _add(instance):
|
||||||
|
if getattr(instance, "created_at", None) is None:
|
||||||
|
instance.created_at = datetime.now()
|
||||||
|
if getattr(instance, "updated_at", None) is None:
|
||||||
|
instance.updated_at = datetime.now()
|
||||||
|
|
||||||
|
session.get.side_effect = _get
|
||||||
|
session.add = MagicMock(side_effect=_add)
|
||||||
|
session.execute.return_value = MagicMock(scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))))
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateInstanceDockerfileLegacy:
|
||||||
|
"""Legacy dockerfile definition type in create_instance."""
|
||||||
|
|
||||||
|
@patch("src.api.tool_instances.ensure_instance_directory")
|
||||||
|
@patch("src.api.tool_instances.find_free_port")
|
||||||
|
@patch("src.api.tool_instances.build_image")
|
||||||
|
@patch("src.api.tool_instances.write_compose_file")
|
||||||
|
@patch("src.api.tool_instances._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_builds_from_dockerfile_template(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_write_compose,
|
||||||
|
mock_build_image,
|
||||||
|
mock_find_port,
|
||||||
|
mock_ensure_dir,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""When definition_type is 'dockerfile', build_image is called with the template."""
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_find_port.return_value = 12345
|
||||||
|
mock_ensure_dir.return_value = "/data/instances/test-instance"
|
||||||
|
mock_build_image.return_value = (0, "built", "")
|
||||||
|
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="legacy-df-tool",
|
||||||
|
display_name="Legacy DF Tool",
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="dockerfile",
|
||||||
|
dockerfile_template="FROM python:3.11\nRUN echo hi",
|
||||||
|
compose_template=None,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
data = MagicMock()
|
||||||
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
|
data.display_name = None
|
||||||
|
data.clone_mode = "mount"
|
||||||
|
data.branch = None
|
||||||
|
data.new_branch = None
|
||||||
|
data.config_profile_id = None
|
||||||
|
|
||||||
|
result = await create_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
data=data,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "pending"
|
||||||
|
mock_build_image.assert_called_once()
|
||||||
|
call_kwargs = mock_build_image.call_args.kwargs
|
||||||
|
assert call_kwargs["dockerfile"] == "FROM python:3.11\nRUN echo hi"
|
||||||
|
mock_write_compose.assert_called_once()
|
||||||
|
|
||||||
|
@patch("src.api.tool_instances.ensure_instance_directory")
|
||||||
|
@patch("src.api.tool_instances.find_free_port")
|
||||||
|
@patch("src.api.tool_instances.build_image")
|
||||||
|
@patch("src.api.tool_instances.write_compose_file")
|
||||||
|
@patch("src.api.tool_instances._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_dockerfile_build_failure_raises_500(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_write_compose,
|
||||||
|
mock_build_image,
|
||||||
|
mock_find_port,
|
||||||
|
mock_ensure_dir,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""Failed dockerfile build should raise HTTP 500."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_find_port.return_value = 12345
|
||||||
|
mock_ensure_dir.return_value = "/data/instances/test-instance"
|
||||||
|
mock_build_image.return_value = (1, "", "build failed")
|
||||||
|
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="legacy-df-tool",
|
||||||
|
display_name="Legacy DF Tool",
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="dockerfile",
|
||||||
|
dockerfile_template="FROM invalid",
|
||||||
|
compose_template=None,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
data = MagicMock()
|
||||||
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
|
data.display_name = None
|
||||||
|
data.clone_mode = "mount"
|
||||||
|
data.branch = None
|
||||||
|
data.new_branch = None
|
||||||
|
data.config_profile_id = None
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await create_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
data=data,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
assert "Failed to build Docker image" in exc_info.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateInstanceComposeLegacy:
|
||||||
|
"""Legacy compose definition type in create_instance."""
|
||||||
|
|
||||||
|
@patch("src.api.tool_instances.ensure_instance_directory")
|
||||||
|
@patch("src.api.tool_instances.find_free_port")
|
||||||
|
@patch("src.api.tool_instances.render_compose_template")
|
||||||
|
@patch("src.api.tool_instances.write_compose_file")
|
||||||
|
@patch("src.api.tool_instances._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_renders_compose_template(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_write_compose,
|
||||||
|
mock_render_compose,
|
||||||
|
mock_find_port,
|
||||||
|
mock_ensure_dir,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""When definition_type is 'compose', render_compose_template is called."""
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_find_port.return_value = 12345
|
||||||
|
mock_ensure_dir.return_value = "/data/instances/test-instance"
|
||||||
|
mock_render_compose.return_value = "services:\n app:\n image: nginx"
|
||||||
|
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="legacy-compose-tool",
|
||||||
|
display_name="Legacy Compose Tool",
|
||||||
|
default_port=80,
|
||||||
|
definition_type="compose",
|
||||||
|
dockerfile_template=None,
|
||||||
|
compose_template="services:\n app:\n image: nginx\n ports:\n - ${TOOL_PORT}:80",
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
data = MagicMock()
|
||||||
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
|
data.display_name = None
|
||||||
|
data.clone_mode = "mount"
|
||||||
|
data.branch = None
|
||||||
|
data.new_branch = None
|
||||||
|
data.config_profile_id = None
|
||||||
|
|
||||||
|
result = await create_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
data=data,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "pending"
|
||||||
|
mock_render_compose.assert_called_once()
|
||||||
|
args = mock_render_compose.call_args[0]
|
||||||
|
assert "services:" in args[0]
|
||||||
|
mock_write_compose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateInstanceManifestNotCalledForLegacy:
|
||||||
|
"""Ensure manifest compiler is never invoked for legacy types."""
|
||||||
|
|
||||||
|
@patch("src.api.tool_instances.ensure_instance_directory")
|
||||||
|
@patch("src.api.tool_instances.find_free_port")
|
||||||
|
@patch("src.api.tool_instances.build_image")
|
||||||
|
@patch("src.api.tool_instances.write_compose_file")
|
||||||
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
|
@patch("src.api.tool_instances._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_dockerfile_does_not_call_manifest_compiler(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_prepare_manifest,
|
||||||
|
mock_write_compose,
|
||||||
|
mock_build_image,
|
||||||
|
mock_find_port,
|
||||||
|
mock_ensure_dir,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""Legacy dockerfile type must not trigger manifest compilation."""
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_find_port.return_value = 12345
|
||||||
|
mock_ensure_dir.return_value = "/data/instances/test-instance"
|
||||||
|
mock_build_image.return_value = (0, "built", "")
|
||||||
|
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="legacy-df",
|
||||||
|
display_name="Legacy",
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="dockerfile",
|
||||||
|
dockerfile_template="FROM alpine",
|
||||||
|
compose_template=None,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
data = MagicMock()
|
||||||
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
|
data.display_name = None
|
||||||
|
data.clone_mode = "mount"
|
||||||
|
data.branch = None
|
||||||
|
data.new_branch = None
|
||||||
|
data.config_profile_id = None
|
||||||
|
|
||||||
|
await create_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
data=data,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_prepare_manifest.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartInstanceLegacyFallback:
|
||||||
|
"""Legacy paths in start_instance must NOT call manifest compiler."""
|
||||||
|
|
||||||
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
|
@patch("src.api.tool_instances.get_container_name")
|
||||||
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
|
@patch("src.api.tool_instances._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_legacy_type_skips_manifest_flow(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_prepare_manifest,
|
||||||
|
mock_sanitize,
|
||||||
|
mock_connect_network,
|
||||||
|
mock_get_container_name,
|
||||||
|
mock_get_container_id,
|
||||||
|
mock_execute_compose,
|
||||||
|
mock_wait_container,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_instance_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""When definition_type is 'legacy', start_instance uses old flow."""
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
|
mock_get_container_id.return_value = "abc123"
|
||||||
|
mock_get_container_name.return_value = "test-container"
|
||||||
|
mock_connect_network.return_value = True
|
||||||
|
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=fake_instance_id,
|
||||||
|
name="legacy-instance",
|
||||||
|
repository_id=fake_repo_id,
|
||||||
|
tool_type_id=fake_tool_type_id,
|
||||||
|
compose_path="/data/instances/legacy-instance/docker-compose.yml",
|
||||||
|
status="stopped",
|
||||||
|
clone_mode="mount",
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now(),
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="legacy-tool",
|
||||||
|
display_name="Legacy Tool",
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="legacy",
|
||||||
|
manifest_id=None,
|
||||||
|
dockerfile_template=None,
|
||||||
|
compose_template="services:\n app:\n image: nginx",
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolInstance and pk == fake_instance_id:
|
||||||
|
return instance
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
# Ensure compose file exists so the check passes
|
||||||
|
with patch("os.path.exists", return_value=True):
|
||||||
|
result = await start_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
instance_id=fake_instance_id,
|
||||||
|
data=None,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "running"
|
||||||
|
mock_prepare_manifest.assert_not_called()
|
||||||
|
mock_execute_compose.assert_called_once()
|
||||||
|
|
||||||
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
|
@patch("src.api.tool_instances.get_container_name")
|
||||||
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
|
@patch("src.api.tool_instances._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_compose_type_skips_manifest_flow(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_prepare_manifest,
|
||||||
|
mock_sanitize,
|
||||||
|
mock_connect_network,
|
||||||
|
mock_get_container_name,
|
||||||
|
mock_get_container_id,
|
||||||
|
mock_execute_compose,
|
||||||
|
mock_wait_container,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_instance_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""When definition_type is 'compose', start_instance uses old flow."""
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
|
mock_get_container_id.return_value = "abc123"
|
||||||
|
mock_get_container_name.return_value = "test-container"
|
||||||
|
mock_connect_network.return_value = True
|
||||||
|
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=fake_instance_id,
|
||||||
|
name="compose-instance",
|
||||||
|
repository_id=fake_repo_id,
|
||||||
|
tool_type_id=fake_tool_type_id,
|
||||||
|
compose_path="/data/instances/compose-instance/docker-compose.yml",
|
||||||
|
status="stopped",
|
||||||
|
clone_mode="mount",
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now(),
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="compose-tool",
|
||||||
|
display_name="Compose Tool",
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="compose",
|
||||||
|
manifest_id=None,
|
||||||
|
dockerfile_template=None,
|
||||||
|
compose_template="services:\n app:\n image: nginx",
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolInstance and pk == fake_instance_id:
|
||||||
|
return instance
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
with patch("os.path.exists", return_value=True):
|
||||||
|
result = await start_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
instance_id=fake_instance_id,
|
||||||
|
data=None,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "running"
|
||||||
|
mock_prepare_manifest.assert_not_called()
|
||||||
|
mock_execute_compose.assert_called_once()
|
||||||
|
|
||||||
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
|
@patch("src.api.tool_instances.get_container_name")
|
||||||
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
|
@patch("src.api.tool_instances._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_dockerfile_type_skips_manifest_flow(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_prepare_manifest,
|
||||||
|
mock_sanitize,
|
||||||
|
mock_connect_network,
|
||||||
|
mock_get_container_name,
|
||||||
|
mock_get_container_id,
|
||||||
|
mock_execute_compose,
|
||||||
|
mock_wait_container,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_instance_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""When definition_type is 'dockerfile', start_instance uses old flow."""
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
|
mock_get_container_id.return_value = "abc123"
|
||||||
|
mock_get_container_name.return_value = "test-container"
|
||||||
|
mock_connect_network.return_value = True
|
||||||
|
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=fake_instance_id,
|
||||||
|
name="df-instance",
|
||||||
|
repository_id=fake_repo_id,
|
||||||
|
tool_type_id=fake_tool_type_id,
|
||||||
|
compose_path="/data/instances/df-instance/docker-compose.yml",
|
||||||
|
status="stopped",
|
||||||
|
clone_mode="mount",
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now(),
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="df-tool",
|
||||||
|
display_name="Dockerfile Tool",
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="dockerfile",
|
||||||
|
manifest_id=None,
|
||||||
|
dockerfile_template="FROM alpine",
|
||||||
|
compose_template=None,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolInstance and pk == fake_instance_id:
|
||||||
|
return instance
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
with patch("os.path.exists", return_value=True):
|
||||||
|
result = await start_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
instance_id=fake_instance_id,
|
||||||
|
data=None,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "running"
|
||||||
|
mock_prepare_manifest.assert_not_called()
|
||||||
|
mock_execute_compose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartInstanceManifestBranch:
|
||||||
|
"""Manifest branch is taken ONLY when definition_type == 'manifest'."""
|
||||||
|
|
||||||
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
|
@patch("src.api.tool_instances.get_container_name")
|
||||||
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
|
@patch("src.api.tool_instances.write_compose_file")
|
||||||
|
@patch("src.api.tool_instances._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_manifest_type_calls_compiler(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_write_compose,
|
||||||
|
mock_prepare_manifest,
|
||||||
|
mock_sanitize,
|
||||||
|
mock_connect_network,
|
||||||
|
mock_get_container_name,
|
||||||
|
mock_get_container_id,
|
||||||
|
mock_execute_compose,
|
||||||
|
mock_wait_container,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_instance_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""When definition_type is 'manifest' and manifest_id is set, compiler runs."""
|
||||||
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
|
||||||
|
manifest_id = uuid.uuid4()
|
||||||
|
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
|
mock_get_container_id.return_value = "abc123"
|
||||||
|
mock_get_container_name.return_value = "test-container"
|
||||||
|
mock_connect_network.return_value = True
|
||||||
|
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
||||||
|
mock_prepare_manifest.return_value = (
|
||||||
|
"headquarter/test:latest",
|
||||||
|
"services:\n app:\n image: test",
|
||||||
|
{"name": "test-manifest"},
|
||||||
|
)
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=fake_instance_id,
|
||||||
|
name="manifest-instance",
|
||||||
|
repository_id=fake_repo_id,
|
||||||
|
tool_type_id=fake_tool_type_id,
|
||||||
|
compose_path="/data/instances/manifest-instance/docker-compose.yml",
|
||||||
|
status="stopped",
|
||||||
|
clone_mode="mount",
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now(),
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="manifest-tool",
|
||||||
|
display_name="Manifest Tool",
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="manifest",
|
||||||
|
manifest_id=manifest_id,
|
||||||
|
dockerfile_template=None,
|
||||||
|
compose_template=None,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
manifest_def = ToolDefinitionManifest(
|
||||||
|
id=manifest_id,
|
||||||
|
name="test-manifest",
|
||||||
|
display_name="Test Manifest",
|
||||||
|
interface_type="web",
|
||||||
|
manifest={"base_image": "alpine"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolInstance and pk == fake_instance_id:
|
||||||
|
return instance
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
if model is ToolDefinitionManifest and pk == manifest_id:
|
||||||
|
return manifest_def
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
with patch("os.path.exists", return_value=True):
|
||||||
|
result = await start_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
instance_id=fake_instance_id,
|
||||||
|
data=None,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "running"
|
||||||
|
mock_prepare_manifest.assert_called_once()
|
||||||
|
mock_execute_compose.assert_called_once()
|
||||||
+1
File diff suppressed because one or more lines are too long
Generated
+512
-69
@@ -896,6 +896,24 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||||
@@ -913,6 +931,24 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||||
@@ -930,6 +966,24 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||||
@@ -1390,9 +1444,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1410,9 +1461,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1430,9 +1478,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1450,9 +1495,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1470,9 +1512,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1490,9 +1529,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1671,9 +1707,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1688,9 +1721,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1705,9 +1735,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1722,9 +1749,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1739,9 +1763,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1756,9 +1777,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1773,9 +1791,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1790,9 +1805,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1807,9 +1819,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1824,9 +1833,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1841,9 +1847,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1858,9 +1861,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1875,9 +1875,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4360,9 +4357,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4384,9 +4378,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4408,9 +4399,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4432,9 +4420,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6122,6 +6107,420 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitest/node_modules/@vitest/mocker": {
|
"node_modules/vitest/node_modules/@vitest/mocker": {
|
||||||
"version": "4.1.6",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||||
@@ -6149,6 +6548,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest/node_modules/esbuild": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.0",
|
||||||
|
"@esbuild/android-arm": "0.28.0",
|
||||||
|
"@esbuild/android-arm64": "0.28.0",
|
||||||
|
"@esbuild/android-x64": "0.28.0",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.0",
|
||||||
|
"@esbuild/darwin-x64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.0",
|
||||||
|
"@esbuild/linux-arm": "0.28.0",
|
||||||
|
"@esbuild/linux-arm64": "0.28.0",
|
||||||
|
"@esbuild/linux-ia32": "0.28.0",
|
||||||
|
"@esbuild/linux-loong64": "0.28.0",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.0",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.0",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.0",
|
||||||
|
"@esbuild/linux-s390x": "0.28.0",
|
||||||
|
"@esbuild/linux-x64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.0",
|
||||||
|
"@esbuild/sunos-x64": "0.28.0",
|
||||||
|
"@esbuild/win32-arm64": "0.28.0",
|
||||||
|
"@esbuild/win32-ia32": "0.28.0",
|
||||||
|
"@esbuild/win32-x64": "0.28.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitest/node_modules/picomatch": {
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import axios from "axios";
|
import axios, { type AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const MAX_RETRIES = 2;
|
|||||||
const RETRY_DELAY_MS = 1000;
|
const RETRY_DELAY_MS = 1000;
|
||||||
|
|
||||||
// Track retry count per request
|
// Track retry count per request
|
||||||
const retryCount = new WeakMap<any, number>();
|
const retryCount = new WeakMap<AxiosRequestConfig, number>();
|
||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ConfigProfile {
|
|||||||
env_vars: Record<string, string>;
|
env_vars: Record<string, string>;
|
||||||
runtime_hints: Record<string, unknown>;
|
runtime_hints: Record<string, unknown>;
|
||||||
mounts: ConfigProfileMount[];
|
mounts: ConfigProfileMount[];
|
||||||
|
git_mounts: GitMount[];
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
includes: ConfigProfileInclude[];
|
includes: ConfigProfileInclude[];
|
||||||
@@ -23,6 +24,13 @@ export interface ConfigProfileMount {
|
|||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitMount {
|
||||||
|
remote_url: string;
|
||||||
|
source_path: string;
|
||||||
|
target_path: string;
|
||||||
|
branch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigProfileInclude {
|
export interface ConfigProfileInclude {
|
||||||
id: string;
|
id: string;
|
||||||
included_profile_id: string;
|
included_profile_id: string;
|
||||||
@@ -35,6 +43,7 @@ export interface ResolvedProfile {
|
|||||||
env_vars: Record<string, string>;
|
env_vars: Record<string, string>;
|
||||||
runtime_hints: Record<string, unknown>;
|
runtime_hints: Record<string, unknown>;
|
||||||
mounts: ResolvedMount[];
|
mounts: ResolvedMount[];
|
||||||
|
git_mounts: GitMount[];
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
overrides: {
|
overrides: {
|
||||||
env_vars: Record<string, string>;
|
env_vars: Record<string, string>;
|
||||||
@@ -60,6 +69,7 @@ export interface CreateConfigProfileRequest {
|
|||||||
env_vars?: Record<string, string>;
|
env_vars?: Record<string, string>;
|
||||||
runtime_hints?: Record<string, unknown>;
|
runtime_hints?: Record<string, unknown>;
|
||||||
mounts?: ConfigProfileMount[];
|
mounts?: ConfigProfileMount[];
|
||||||
|
git_mounts?: GitMount[];
|
||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
@@ -72,6 +82,7 @@ export interface UpdateConfigProfileRequest {
|
|||||||
env_vars?: Record<string, string>;
|
env_vars?: Record<string, string>;
|
||||||
runtime_hints?: Record<string, unknown>;
|
runtime_hints?: Record<string, unknown>;
|
||||||
mounts?: ConfigProfileMount[];
|
mounts?: ConfigProfileMount[];
|
||||||
|
git_mounts?: GitMount[];
|
||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,24 @@ export interface URLParseResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
||||||
const response = await apiClient.post("/projects/repositories/parse-url", { url });
|
const response = await apiClient.post("/repositories/parse-url", { url });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
export async function listRepositories(projectId?: string): Promise<GitRepository[]> {
|
||||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
if (projectId) {
|
||||||
|
const response = await apiClient.get<GitRepository[]>(
|
||||||
|
`/projects/${projectId}/repositories`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
// List all user repositories (including external)
|
||||||
|
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAllUserRepositories(): Promise<GitRepository[]> {
|
||||||
|
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +60,13 @@ export async function createRepository(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createExternalRepository(
|
||||||
|
data: GitRepositoryCreate
|
||||||
|
): Promise<GitRepository> {
|
||||||
|
const response = await apiClient.post<GitRepository>("/repositories", data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AxiosError } from "axios";
|
||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
export interface ToolInstance {
|
export interface ToolInstance {
|
||||||
@@ -80,9 +81,10 @@ export async function startInstance(
|
|||||||
{ config_profile_id: configProfileId }
|
{ config_profile_id: configProfileId }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||||
if (retries > 0 && !error.response) {
|
const axiosError = error as AxiosError;
|
||||||
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
||||||
}
|
}
|
||||||
@@ -114,9 +116,10 @@ export async function restartInstance(
|
|||||||
{ config_profile_id: configProfileId }
|
{ config_profile_id: configProfileId }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||||
if (retries > 0 && !error.response) {
|
const axiosError = error as AxiosError;
|
||||||
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ToolDefinitionManifest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string | null;
|
||||||
|
category: string | null;
|
||||||
|
interface_type: string;
|
||||||
|
base_image: string | null;
|
||||||
|
base_definition_id: string | null;
|
||||||
|
base_version: string;
|
||||||
|
manifest: Record<string, unknown>;
|
||||||
|
dockerfile_cache: string | null;
|
||||||
|
compose_cache: string | null;
|
||||||
|
version: string;
|
||||||
|
is_base: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateToolDefinitionRequest {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
interface_type?: string;
|
||||||
|
base_image?: string;
|
||||||
|
base_definition_id?: string;
|
||||||
|
base_version?: string;
|
||||||
|
manifest: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateToolDefinitionRequest {
|
||||||
|
display_name?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
manifest?: Record<string, unknown>;
|
||||||
|
base_version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompileResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dockerfile: string;
|
||||||
|
entrypoint: string;
|
||||||
|
compose: string;
|
||||||
|
image_tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listToolDefinitions = async (
|
||||||
|
includeBases = true,
|
||||||
|
): Promise<ToolDefinitionManifest[]> => {
|
||||||
|
const response = await apiClient.get<{
|
||||||
|
definitions: ToolDefinitionManifest[];
|
||||||
|
}>("/tool-definitions", {
|
||||||
|
params: { include_bases: includeBases },
|
||||||
|
});
|
||||||
|
return response.data.definitions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getToolDefinition = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<ToolDefinitionManifest> => {
|
||||||
|
const response = await apiClient.get<ToolDefinitionManifest>(
|
||||||
|
`/tool-definitions/${id}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createToolDefinition = async (
|
||||||
|
data: CreateToolDefinitionRequest,
|
||||||
|
): Promise<ToolDefinitionManifest> => {
|
||||||
|
const response = await apiClient.post<ToolDefinitionManifest>(
|
||||||
|
"/tool-definitions",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateToolDefinition = async (
|
||||||
|
id: string,
|
||||||
|
data: UpdateToolDefinitionRequest,
|
||||||
|
): Promise<ToolDefinitionManifest> => {
|
||||||
|
const response = await apiClient.put<ToolDefinitionManifest>(
|
||||||
|
`/tool-definitions/${id}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteToolDefinition = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<{ status: string; id: string }> => {
|
||||||
|
const response = await apiClient.delete<{ status: string; id: string }>(
|
||||||
|
`/tool-definitions/${id}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compileToolDefinition = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<CompileResult> => {
|
||||||
|
const response = await apiClient.post<CompileResult>(
|
||||||
|
`/tool-definitions/${id}/compile`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
@@ -1,87 +1,102 @@
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
export interface ReadinessProbe {
|
export interface ReadinessProbe {
|
||||||
command: string;
|
command: string;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
interval: number;
|
interval: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolType {
|
export interface ToolType {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
interface_type: string;
|
interface_type: string;
|
||||||
requires_port: boolean;
|
requires_port: boolean;
|
||||||
default_port: number | null;
|
default_port: number | null;
|
||||||
definition_type: 'compose' | 'dockerfile';
|
definition_type: "compose" | "dockerfile" | "manifest";
|
||||||
compose_template: string | null;
|
manifest_id: string | null;
|
||||||
dockerfile_template: string | null;
|
compose_template: string | null;
|
||||||
build_context: Record<string, string> | null;
|
dockerfile_template: string | null;
|
||||||
readiness_probe: ReadinessProbe | null;
|
build_context: Record<string, string> | null;
|
||||||
required_variables: string[];
|
readiness_probe: ReadinessProbe | null;
|
||||||
created_by_id: string | null;
|
startup_command: string | null;
|
||||||
created_at: string;
|
required_variables: string[];
|
||||||
updated_at: string;
|
created_by_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateToolTypeRequest {
|
export interface CreateToolTypeRequest {
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
interface_type?: string;
|
interface_type?: string;
|
||||||
requires_port?: boolean;
|
requires_port?: boolean;
|
||||||
default_port: number;
|
default_port: number;
|
||||||
definition_type?: 'compose' | 'dockerfile';
|
definition_type?: "compose" | "dockerfile" | "manifest";
|
||||||
compose_template?: string;
|
manifest_id?: string;
|
||||||
dockerfile_template?: string;
|
compose_template?: string;
|
||||||
build_context?: Record<string, string>;
|
dockerfile_template?: string;
|
||||||
readiness_probe?: ReadinessProbe;
|
build_context?: Record<string, string>;
|
||||||
required_variables: string[];
|
readiness_probe?: ReadinessProbe;
|
||||||
|
startup_command?: string;
|
||||||
|
required_variables: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateToolTypeRequest {
|
export interface UpdateToolTypeRequest {
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
interface_type?: string;
|
interface_type?: string;
|
||||||
requires_port?: boolean;
|
requires_port?: boolean;
|
||||||
default_port?: number;
|
default_port?: number;
|
||||||
definition_type?: 'compose' | 'dockerfile';
|
definition_type?: "compose" | "dockerfile" | "manifest";
|
||||||
compose_template?: string;
|
manifest_id?: string;
|
||||||
dockerfile_template?: string;
|
compose_template?: string;
|
||||||
build_context?: Record<string, string>;
|
dockerfile_template?: string;
|
||||||
readiness_probe?: ReadinessProbe;
|
build_context?: Record<string, string>;
|
||||||
required_variables?: string[];
|
readiness_probe?: ReadinessProbe;
|
||||||
|
startup_command?: string;
|
||||||
|
required_variables?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listToolTypes = async (): Promise<ToolType[]> => {
|
export const listToolTypes = async (): Promise<ToolType[]> => {
|
||||||
const response = await apiClient.get<ToolType[]>("/tool-types");
|
const response = await apiClient.get<ToolType[]>("/tool-types");
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getToolType = async (id: string): Promise<ToolType> => {
|
export const getToolType = async (id: string): Promise<ToolType> => {
|
||||||
const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
|
const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createToolType = async (data: CreateToolTypeRequest): Promise<ToolType> => {
|
export const createToolType = async (
|
||||||
const response = await apiClient.post<ToolType>("/tool-types", data);
|
data: CreateToolTypeRequest,
|
||||||
return response.data;
|
): Promise<ToolType> => {
|
||||||
|
const response = await apiClient.post<ToolType>("/tool-types", data);
|
||||||
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateToolType = async (id: string, data: UpdateToolTypeRequest): Promise<ToolType> => {
|
export const updateToolType = async (
|
||||||
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
|
id: string,
|
||||||
return response.data;
|
data: UpdateToolTypeRequest,
|
||||||
|
): Promise<ToolType> => {
|
||||||
|
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
|
||||||
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteToolType = async (id: string): Promise<void> => {
|
export const deleteToolType = async (id: string): Promise<void> => {
|
||||||
await apiClient.delete(`/tool-types/${id}`);
|
await apiClient.delete(`/tool-types/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateToolType = async (id: string): Promise<{ valid: boolean; errors?: string[] }> => {
|
export const validateToolType = async (
|
||||||
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(`/tool-types/${id}/validate`);
|
id: string,
|
||||||
return response.data;
|
): Promise<{ valid: boolean; errors?: string[] }> => {
|
||||||
|
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(
|
||||||
|
`/tool-types/${id}/validate`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { getUserSessions } from "../api/sessions";
|
import { getUserSessions } from "../api/sessions";
|
||||||
@@ -8,6 +8,7 @@ import { useAuth } from "../state/auth";
|
|||||||
import { useSessions } from "../state/sessions";
|
import { useSessions } from "../state/sessions";
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
|
import { MobileNav } from "./mobile-nav";
|
||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
|
const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
|
||||||
@@ -43,8 +44,6 @@ export const AppShell = () => {
|
|||||||
const { sessions, setAllSessions } = useSessions();
|
const { sessions, setAllSessions } = useSessions();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isMobile = useMobileViewport();
|
const isMobile = useMobileViewport();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
|
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
@@ -65,11 +64,6 @@ export const AppShell = () => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
// Close mobile menu on route change
|
|
||||||
useEffect(() => {
|
|
||||||
setMobileMenuOpen(false);
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
if (isMobileTerminal) {
|
if (isMobileTerminal) {
|
||||||
return (
|
return (
|
||||||
<div className="shell mobile-terminal-shell">
|
<div className="shell mobile-terminal-shell">
|
||||||
@@ -102,57 +96,46 @@ export const AppShell = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="shell-body">
|
<div className="shell-body">
|
||||||
<aside className={`shell-nav ${mobileMenuOpen ? "mobile-open" : ""}`} aria-label="Primary navigation">
|
{!isMobile && (
|
||||||
{isMobile && (
|
<aside className="shell-nav" aria-label="Primary navigation">
|
||||||
<button
|
{NAV_ITEMS.map((item) => {
|
||||||
className="mobile-menu-close"
|
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
return (
|
||||||
type="button"
|
<NavLink
|
||||||
aria-label="Close menu"
|
key={item.to}
|
||||||
>
|
to={item.to}
|
||||||
<Icon name="close" size="sm" />
|
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
||||||
</button>
|
end={item.to === "/"}
|
||||||
)}
|
>
|
||||||
{NAV_ITEMS.map((item) => {
|
<Icon name={item.icon} size="sm" />
|
||||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
{item.label}
|
||||||
return (
|
{item.badge === "sessions" && activeCount > 0 && (
|
||||||
<NavLink
|
<span className="nav-badge">{activeCount}</span>
|
||||||
key={item.to}
|
)}
|
||||||
to={item.to}
|
</NavLink>
|
||||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
);
|
||||||
end={item.to === "/"}
|
})}
|
||||||
>
|
|
||||||
<Icon name={item.icon} size="sm" />
|
{sessions.length > 0 && (
|
||||||
{item.label}
|
<>
|
||||||
{item.badge === "sessions" && activeCount > 0 && (
|
<div className="nav-divider" />
|
||||||
<span className="nav-badge">{activeCount}</span>
|
<div className="nav-section-title">Live sessions</div>
|
||||||
)}
|
{sessions.map((session) => (
|
||||||
</NavLink>
|
<SessionItem key={session.id} session={session} />
|
||||||
);
|
))}
|
||||||
})}
|
</>
|
||||||
|
)}
|
||||||
{sessions.length > 0 && (
|
</aside>
|
||||||
<>
|
|
||||||
<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)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="shell-content">
|
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<MobileNav sessionCount={sessions.filter((s) => s.status === "running").length} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -150,7 +150,6 @@ export const Icon: React.FC<IconProps> = ({
|
|||||||
const sizeValue = sizeMap[size];
|
const sizeValue = sizeMap[size];
|
||||||
|
|
||||||
if (!IconComponent) {
|
if (!IconComponent) {
|
||||||
console.warn(`Icon "${name}" not found`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,849 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import { extractErrorMessage } from "../utils/errors";
|
||||||
|
import {
|
||||||
|
compileToolDefinition,
|
||||||
|
type ToolDefinitionManifest,
|
||||||
|
} from "../api/tool_definitions";
|
||||||
|
|
||||||
|
interface PackageEntry {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MountEntry {
|
||||||
|
name: string;
|
||||||
|
target: string;
|
||||||
|
source_type: string;
|
||||||
|
writable: boolean;
|
||||||
|
owner: string;
|
||||||
|
mode: string;
|
||||||
|
file_mode: string;
|
||||||
|
readonly: boolean;
|
||||||
|
git_mount_ref: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManifestEditorProps {
|
||||||
|
manifest: Record<string, unknown> | null;
|
||||||
|
baseDefinitions: ToolDefinitionManifest[];
|
||||||
|
onChange: (manifest: Record<string, unknown>) => void;
|
||||||
|
definitionId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManifestEditor = ({
|
||||||
|
manifest,
|
||||||
|
baseDefinitions,
|
||||||
|
onChange,
|
||||||
|
definitionId,
|
||||||
|
}: ManifestEditorProps) => {
|
||||||
|
const [baseImage, setBaseImage] = useState("");
|
||||||
|
const [baseDefinitionId, setBaseDefinitionId] = useState("");
|
||||||
|
const [aptPackages, setAptPackages] = useState<PackageEntry[]>([]);
|
||||||
|
const [npmPackages, setNpmPackages] = useState<PackageEntry[]>([]);
|
||||||
|
const [pipPackages, setPipPackages] = useState<PackageEntry[]>([]);
|
||||||
|
const [nodeVersion, setNodeVersion] = useState("");
|
||||||
|
const [userName, setUserName] = useState("user");
|
||||||
|
const [userUid, setUserUid] = useState("1000");
|
||||||
|
const [userGid, setUserGid] = useState("1000");
|
||||||
|
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]);
|
||||||
|
const [buildScripts, setBuildScripts] = useState<string[]>([""]);
|
||||||
|
const [startupScripts, setStartupScripts] = useState<string[]>([""]);
|
||||||
|
const [mounts, setMounts] = useState<MountEntry[]>([]);
|
||||||
|
const [command, setCommand] = useState<string[]>([""]);
|
||||||
|
const [workingDir, setWorkingDir] = useState("/workspace");
|
||||||
|
const [stdinOpen, setStdinOpen] = useState(true);
|
||||||
|
const [tty, setTty] = useState(true);
|
||||||
|
|
||||||
|
const [preview, setPreview] = useState<{
|
||||||
|
dockerfile: string;
|
||||||
|
compose: string;
|
||||||
|
entrypoint: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load manifest into form
|
||||||
|
useEffect(() => {
|
||||||
|
if (!manifest) return;
|
||||||
|
|
||||||
|
const pkgs = (manifest.packages as Record<string, unknown>) || {};
|
||||||
|
setBaseImage((manifest.base_image as string) || "");
|
||||||
|
setBaseDefinitionId((manifest.base_definition_id as string) || "");
|
||||||
|
setAptPackages(((pkgs.apt as string[]) || []).map((p) => ({ name: p })));
|
||||||
|
setNpmPackages(
|
||||||
|
((pkgs.npm_global as string[]) || []).map((p) => ({ name: p })),
|
||||||
|
);
|
||||||
|
setPipPackages(((pkgs.pip as string[]) || []).map((p) => ({ name: p })));
|
||||||
|
setNodeVersion((pkgs.node as Record<string, string>)?.version || "");
|
||||||
|
|
||||||
|
const user = (manifest.user as Record<string, unknown>) || {};
|
||||||
|
setUserName((user.name as string) || "user");
|
||||||
|
setUserUid(String(user.uid || "1000"));
|
||||||
|
setUserGid(String(user.gid || "1000"));
|
||||||
|
|
||||||
|
const env = (manifest.env as Record<string, string>) || {};
|
||||||
|
setEnvVars(Object.entries(env).map(([key, value]) => ({ key, value })));
|
||||||
|
|
||||||
|
const scripts = (manifest.scripts as Record<string, string[]>) || {};
|
||||||
|
setBuildScripts((scripts.build || []).length > 0 ? scripts.build : [""]);
|
||||||
|
setStartupScripts(
|
||||||
|
(scripts.startup || []).length > 0 ? scripts.startup : [""],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mts = (manifest.mounts as MountEntry[]) || [];
|
||||||
|
setMounts(mts);
|
||||||
|
|
||||||
|
const runtime = (manifest.runtime as Record<string, unknown>) || {};
|
||||||
|
setCommand((runtime.command as string[]) || [""]);
|
||||||
|
setWorkingDir((runtime.working_dir as string) || "/workspace");
|
||||||
|
setStdinOpen((runtime.stdin_open as boolean) ?? true);
|
||||||
|
setTty((runtime.tty as boolean) ?? true);
|
||||||
|
}, [manifest]);
|
||||||
|
|
||||||
|
// Build manifest from form state
|
||||||
|
const buildManifest = useCallback((): Record<string, unknown> => {
|
||||||
|
const packages: Record<string, unknown> = {};
|
||||||
|
const apt = aptPackages.map((p) => p.name).filter(Boolean);
|
||||||
|
if (apt.length) packages.apt = apt;
|
||||||
|
const npm = npmPackages.map((p) => p.name).filter(Boolean);
|
||||||
|
if (npm.length) packages.npm_global = npm;
|
||||||
|
const pip = pipPackages.map((p) => p.name).filter(Boolean);
|
||||||
|
if (pip.length) packages.pip = pip;
|
||||||
|
if (nodeVersion) packages.node = { version: nodeVersion };
|
||||||
|
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
envVars.forEach(({ key, value }) => {
|
||||||
|
if (key) env[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scripts: Record<string, string[]> = {};
|
||||||
|
const build = buildScripts.filter(Boolean);
|
||||||
|
if (build.length) scripts.build = build;
|
||||||
|
const startup = startupScripts.filter(Boolean);
|
||||||
|
if (startup.length) scripts.startup = startup;
|
||||||
|
|
||||||
|
const mts = mounts.filter((m) => m.name && m.target);
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
packages,
|
||||||
|
user: {
|
||||||
|
name: userName,
|
||||||
|
uid: parseInt(userUid) || 1000,
|
||||||
|
gid: parseInt(userGid) || 1000,
|
||||||
|
create_home: true,
|
||||||
|
shell: "/bin/bash",
|
||||||
|
},
|
||||||
|
env,
|
||||||
|
scripts,
|
||||||
|
mounts: mts,
|
||||||
|
runtime: {
|
||||||
|
command:
|
||||||
|
command.filter(Boolean).length > 0
|
||||||
|
? command.filter(Boolean)
|
||||||
|
: ["/bin/bash"],
|
||||||
|
stdin_open: stdinOpen,
|
||||||
|
tty: tty,
|
||||||
|
working_dir: workingDir,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (baseImage) result.base_image = baseImage;
|
||||||
|
if (baseDefinitionId) result.base_definition_id = baseDefinitionId;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [
|
||||||
|
aptPackages,
|
||||||
|
npmPackages,
|
||||||
|
pipPackages,
|
||||||
|
nodeVersion,
|
||||||
|
userName,
|
||||||
|
userUid,
|
||||||
|
userGid,
|
||||||
|
envVars,
|
||||||
|
buildScripts,
|
||||||
|
startupScripts,
|
||||||
|
mounts,
|
||||||
|
command,
|
||||||
|
workingDir,
|
||||||
|
stdinOpen,
|
||||||
|
tty,
|
||||||
|
baseImage,
|
||||||
|
baseDefinitionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Notify parent of changes
|
||||||
|
useEffect(() => {
|
||||||
|
const m = buildManifest();
|
||||||
|
onChange(m);
|
||||||
|
}, [buildManifest, onChange]);
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (!definitionId) {
|
||||||
|
setPreviewError("Save the tool definition first to preview");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewLoading(true);
|
||||||
|
setPreviewError(null);
|
||||||
|
try {
|
||||||
|
const result = await compileToolDefinition(definitionId);
|
||||||
|
setPreview({
|
||||||
|
dockerfile: result.dockerfile,
|
||||||
|
compose: result.compose,
|
||||||
|
entrypoint: result.entrypoint,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setPreviewError(extractErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAptPackage = () => setAptPackages([...aptPackages, { name: "" }]);
|
||||||
|
const removeAptPackage = (idx: number) =>
|
||||||
|
setAptPackages(aptPackages.filter((_, i) => i !== idx));
|
||||||
|
const updateAptPackage = (idx: number, name: string) => {
|
||||||
|
const copy = [...aptPackages];
|
||||||
|
copy[idx] = { name };
|
||||||
|
setAptPackages(copy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNpmPackage = () => setNpmPackages([...npmPackages, { name: "" }]);
|
||||||
|
const removeNpmPackage = (idx: number) =>
|
||||||
|
setNpmPackages(npmPackages.filter((_, i) => i !== idx));
|
||||||
|
const updateNpmPackage = (idx: number, name: string) => {
|
||||||
|
const copy = [...npmPackages];
|
||||||
|
copy[idx] = { name };
|
||||||
|
setNpmPackages(copy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPipPackage = () => setPipPackages([...pipPackages, { name: "" }]);
|
||||||
|
const removePipPackage = (idx: number) =>
|
||||||
|
setPipPackages(pipPackages.filter((_, i) => i !== idx));
|
||||||
|
const updatePipPackage = (idx: number, name: string) => {
|
||||||
|
const copy = [...pipPackages];
|
||||||
|
copy[idx] = { name };
|
||||||
|
setPipPackages(copy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEnvVar = () => setEnvVars([...envVars, { key: "", value: "" }]);
|
||||||
|
const removeEnvVar = (idx: number) =>
|
||||||
|
setEnvVars(envVars.filter((_, i) => i !== idx));
|
||||||
|
const updateEnvVar = (idx: number, field: "key" | "value", val: string) => {
|
||||||
|
const copy = [...envVars];
|
||||||
|
copy[idx] = { ...copy[idx], [field]: val };
|
||||||
|
setEnvVars(copy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addBuildScript = () => setBuildScripts([...buildScripts, ""]);
|
||||||
|
const removeBuildScript = (idx: number) =>
|
||||||
|
setBuildScripts(buildScripts.filter((_, i) => i !== idx));
|
||||||
|
const updateBuildScript = (idx: number, val: string) => {
|
||||||
|
const copy = [...buildScripts];
|
||||||
|
copy[idx] = val;
|
||||||
|
setBuildScripts(copy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addStartupScript = () => setStartupScripts([...startupScripts, ""]);
|
||||||
|
const removeStartupScript = (idx: number) =>
|
||||||
|
setStartupScripts(startupScripts.filter((_, i) => i !== idx));
|
||||||
|
const updateStartupScript = (idx: number, val: string) => {
|
||||||
|
const copy = [...startupScripts];
|
||||||
|
copy[idx] = val;
|
||||||
|
setStartupScripts(copy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMount = () =>
|
||||||
|
setMounts([
|
||||||
|
...mounts,
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
target: "",
|
||||||
|
source_type: "repo",
|
||||||
|
writable: true,
|
||||||
|
owner: "",
|
||||||
|
mode: "",
|
||||||
|
file_mode: "",
|
||||||
|
readonly: false,
|
||||||
|
git_mount_ref: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const removeMount = (idx: number) =>
|
||||||
|
setMounts(mounts.filter((_, i) => i !== idx));
|
||||||
|
const updateMount = (idx: number, field: keyof MountEntry, val: unknown) => {
|
||||||
|
const copy = [...mounts];
|
||||||
|
copy[idx] = { ...copy[idx], [field]: val };
|
||||||
|
setMounts(copy);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack" style={{ gap: "1.5rem" }}>
|
||||||
|
{/* Base Image */}
|
||||||
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||||
|
<h4 style={{ margin: 0 }}>Base Image</h4>
|
||||||
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label>Base Definition</label>
|
||||||
|
<select
|
||||||
|
value={baseDefinitionId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBaseDefinitionId(e.target.value);
|
||||||
|
setBaseImage("");
|
||||||
|
}}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
<option value="">Custom image...</option>
|
||||||
|
{baseDefinitions.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.display_name} ({b.version})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label>Custom Base Image</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={baseImage}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBaseImage(e.target.value);
|
||||||
|
setBaseDefinitionId("");
|
||||||
|
}}
|
||||||
|
placeholder="e.g., ubuntu:24.04"
|
||||||
|
className="form-input"
|
||||||
|
disabled={!!baseDefinitionId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Packages */}
|
||||||
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||||
|
<h4 style={{ margin: 0 }}>Packages</h4>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Node.js Version</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nodeVersion}
|
||||||
|
onChange={(e) => setNodeVersion(e.target.value)}
|
||||||
|
placeholder="e.g., 20"
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: "120px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>APT Packages</label>
|
||||||
|
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||||
|
{aptPackages.map((pkg, idx) => (
|
||||||
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pkg.name}
|
||||||
|
onChange={(e) => updateAptPackage(idx, e.target.value)}
|
||||||
|
placeholder="e.g., neovim"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeAptPackage(idx)}
|
||||||
|
className="button-icon"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addAptPackage}
|
||||||
|
className="button-secondary"
|
||||||
|
style={{ width: "fit-content" }}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> Add APT Package
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>NPM Global Packages</label>
|
||||||
|
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||||
|
{npmPackages.map((pkg, idx) => (
|
||||||
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pkg.name}
|
||||||
|
onChange={(e) => updateNpmPackage(idx, e.target.value)}
|
||||||
|
placeholder="e.g., @scope/pkg"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeNpmPackage(idx)}
|
||||||
|
className="button-icon"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addNpmPackage}
|
||||||
|
className="button-secondary"
|
||||||
|
style={{ width: "fit-content" }}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> Add NPM Package
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>Pip Packages</label>
|
||||||
|
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||||
|
{pipPackages.map((pkg, idx) => (
|
||||||
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pkg.name}
|
||||||
|
onChange={(e) => updatePipPackage(idx, e.target.value)}
|
||||||
|
placeholder="e.g., requests"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePipPackage(idx)}
|
||||||
|
className="button-icon"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addPipPackage}
|
||||||
|
className="button-secondary"
|
||||||
|
style={{ width: "fit-content" }}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> Add Pip Package
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Runtime User */}
|
||||||
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||||
|
<h4 style={{ margin: 0 }}>Runtime User</h4>
|
||||||
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label>User Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userName}
|
||||||
|
onChange={(e) => setUserName(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label>UID</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={userUid}
|
||||||
|
onChange={(e) => setUserUid(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label>GID</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={userGid}
|
||||||
|
onChange={(e) => setUserGid(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environment */}
|
||||||
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||||
|
<h4 style={{ margin: 0 }}>Environment Variables</h4>
|
||||||
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||||
|
{envVars.map((ev, idx) => (
|
||||||
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ev.key}
|
||||||
|
onChange={(e) => updateEnvVar(idx, "key", e.target.value)}
|
||||||
|
placeholder="KEY"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ev.value}
|
||||||
|
onChange={(e) => updateEnvVar(idx, "value", e.target.value)}
|
||||||
|
placeholder="value"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeEnvVar(idx)}
|
||||||
|
className="button-icon"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addEnvVar}
|
||||||
|
className="button-secondary"
|
||||||
|
style={{ width: "fit-content" }}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> Add Env Var
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Build Scripts */}
|
||||||
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||||
|
<h4 style={{ margin: 0 }}>Build Scripts (run during docker build)</h4>
|
||||||
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||||
|
{buildScripts.map((script, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="row"
|
||||||
|
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
value={script}
|
||||||
|
onChange={(e) => updateBuildScript(idx, e.target.value)}
|
||||||
|
placeholder="git config --global user.email 'dev@example.com'"
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.8125rem",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeBuildScript(idx)}
|
||||||
|
className="button-icon"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addBuildScript}
|
||||||
|
className="button-secondary"
|
||||||
|
style={{ width: "fit-content" }}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> Add Build Script
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Startup Scripts */}
|
||||||
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||||
|
<h4 style={{ margin: 0 }}>
|
||||||
|
Startup Scripts (run when container starts)
|
||||||
|
</h4>
|
||||||
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||||
|
{startupScripts.map((script, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="row"
|
||||||
|
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
value={script}
|
||||||
|
onChange={(e) => updateStartupScript(idx, e.target.value)}
|
||||||
|
placeholder="chown -R user:user /workspace"
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.8125rem",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeStartupScript(idx)}
|
||||||
|
className="button-icon"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addStartupScript}
|
||||||
|
className="button-secondary"
|
||||||
|
style={{ width: "fit-content" }}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> Add Startup Script
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mounts */}
|
||||||
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||||
|
<h4 style={{ margin: 0 }}>Mount Schema</h4>
|
||||||
|
<div className="stack" style={{ gap: "1rem" }}>
|
||||||
|
{mounts.map((mount, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="stack"
|
||||||
|
style={{
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.75rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="row" style={{ gap: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mount.name}
|
||||||
|
onChange={(e) => updateMount(idx, "name", e.target.value)}
|
||||||
|
placeholder="Name (e.g., workspace)"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mount.target}
|
||||||
|
onChange={(e) => updateMount(idx, "target", e.target.value)}
|
||||||
|
placeholder="Target (e.g., /workspace)"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={mount.source_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMount(idx, "source_type", e.target.value)
|
||||||
|
}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
<option value="repo">Repository</option>
|
||||||
|
<option value="ssh_key">SSH Key</option>
|
||||||
|
<option value="instance">Instance</option>
|
||||||
|
<option value="git_mount">Git Mount</option>
|
||||||
|
<option value="host_path">Host Path</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMount(idx)}
|
||||||
|
className="button-icon"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: "0.5rem" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mount.writable}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMount(idx, "writable", e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Writable
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mount.readonly}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMount(idx, "readonly", e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Read-only
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mount.owner}
|
||||||
|
onChange={(e) => updateMount(idx, "owner", e.target.value)}
|
||||||
|
placeholder="Owner (e.g., user)"
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: "120px" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mount.mode}
|
||||||
|
onChange={(e) => updateMount(idx, "mode", e.target.value)}
|
||||||
|
placeholder="Mode (e.g., 0755)"
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: "100px" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mount.file_mode}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMount(idx, "file_mode", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="File mode (e.g., 0644)"
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: "120px" }}
|
||||||
|
/>
|
||||||
|
{mount.source_type === "git_mount" && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mount.git_mount_ref}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMount(idx, "git_mount_ref", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Git mount ref"
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: "120px" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addMount}
|
||||||
|
className="button-secondary"
|
||||||
|
style={{ width: "fit-content" }}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> Add Mount
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Runtime */}
|
||||||
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||||
|
<h4 style={{ margin: 0 }}>Runtime</h4>
|
||||||
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label>Command</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={command.join(" ")}
|
||||||
|
onChange={(e) => setCommand(e.target.value.split(" "))}
|
||||||
|
placeholder="/bin/bash"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label>Working Directory</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={workingDir}
|
||||||
|
onChange={(e) => setWorkingDir(e.target.value)}
|
||||||
|
placeholder="/workspace"
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={stdinOpen}
|
||||||
|
onChange={(e) => setStdinOpen(e.target.checked)}
|
||||||
|
/>
|
||||||
|
stdin_open
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tty}
|
||||||
|
onChange={(e) => setTty(e.target.checked)}
|
||||||
|
/>
|
||||||
|
tty
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h4 style={{ margin: 0 }}>Live Preview</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={previewLoading}
|
||||||
|
className="button-secondary"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
{previewLoading ? "Compiling..." : "Preview"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{previewError && <p className="text-error">{previewError}</p>}
|
||||||
|
{preview && (
|
||||||
|
<div className="stack" style={{ gap: "1rem" }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
||||||
|
Dockerfile
|
||||||
|
</label>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
background: "var(--code-bg, #1e1e1e)",
|
||||||
|
color: "var(--code-fg, #d4d4d4)",
|
||||||
|
padding: "1rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
overflow: "auto",
|
||||||
|
fontSize: "0.8125rem",
|
||||||
|
maxHeight: "300px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preview.dockerfile}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
||||||
|
Compose
|
||||||
|
</label>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
background: "var(--code-bg, #1e1e1e)",
|
||||||
|
color: "var(--code-fg, #d4d4d4)",
|
||||||
|
padding: "1rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
overflow: "auto",
|
||||||
|
fontSize: "0.8125rem",
|
||||||
|
maxHeight: "200px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preview.compose}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Session } from "../api/sessions";
|
import type { Session } from "../api/sessions";
|
||||||
import { Icon } from "./icon";
|
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 {
|
export interface SessionCardProps {
|
||||||
session: Session;
|
session: Session;
|
||||||
@@ -45,6 +48,8 @@ export function SessionCard({
|
|||||||
}: SessionCardProps) {
|
}: SessionCardProps) {
|
||||||
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = 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 status = statusConfig[session.status] || { color: "gray", label: session.status };
|
||||||
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
|
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
|
||||||
@@ -117,124 +122,217 @@ export function SessionCard({
|
|||||||
)}
|
)}
|
||||||
{session.created_at && (
|
{session.created_at && (
|
||||||
<p className="muted session-card-meta">
|
<p className="muted session-card-meta">
|
||||||
Created: {new Date(session.created_at).toLocaleDateString()}
|
Created: {new Date(session.created_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="session-card-actions">
|
{isMobile ? (
|
||||||
{isActive && (
|
<div className="session-card-actions mobile">
|
||||||
<>
|
{isActive && (
|
||||||
{session.url ? (
|
<>
|
||||||
<a
|
{session.url ? (
|
||||||
href={session.url}
|
<a
|
||||||
target="_blank"
|
href={session.url}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="secondary-button small"
|
rel="noopener noreferrer"
|
||||||
>
|
className="secondary-button mobile-primary"
|
||||||
<Icon name="external" size="sm" />
|
>
|
||||||
<span className="action-label">Open</span>
|
<Icon name="external" size="sm" />
|
||||||
</a>
|
Open
|
||||||
) : (
|
</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
|
<button
|
||||||
className="danger-button small"
|
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}
|
onClick={handleStop}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
>
|
>
|
||||||
Stop
|
<Icon name="stop" size="sm" />
|
||||||
|
<span className="action-label">Stop</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
)}
|
||||||
className="ghost-button small"
|
</>
|
||||||
onClick={handleCancelStop}
|
)}
|
||||||
type="button"
|
|
||||||
>
|
{!isActive && onStart && (
|
||||||
Cancel
|
<button
|
||||||
</button>
|
className="secondary-button small"
|
||||||
</div>
|
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
|
<button
|
||||||
className="ghost-button small"
|
className="danger-button small"
|
||||||
onClick={handleStop}
|
onClick={handleDelete}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
>
|
>
|
||||||
<Icon name="stop" size="sm" />
|
Delete
|
||||||
<span className="action-label">Stop</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
</>
|
className="ghost-button small"
|
||||||
)}
|
onClick={handleCancelDelete}
|
||||||
|
type="button"
|
||||||
{!isActive && onStart && (
|
>
|
||||||
<button
|
Cancel
|
||||||
className="secondary-button small"
|
</button>
|
||||||
onClick={() => onStart(session)}
|
</div>
|
||||||
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
|
<button
|
||||||
className="danger-button small"
|
className="ghost-button small danger-text"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
>
|
>
|
||||||
Delete
|
<Icon name="delete" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
)}
|
||||||
className="ghost-button small"
|
</div>
|
||||||
onClick={handleCancelDelete}
|
)}
|
||||||
type="button"
|
|
||||||
>
|
<MobileActionSheet
|
||||||
Cancel
|
isOpen={showActionSheet}
|
||||||
</button>
|
onClose={() => setShowActionSheet(false)}
|
||||||
</div>
|
title={session.display_name}
|
||||||
) : (
|
actions={[
|
||||||
<button
|
...(isActive && hasTunnelError && onRecreateTunnel
|
||||||
className="ghost-button small danger-text"
|
? [{
|
||||||
onClick={handleDelete}
|
id: "tunnel",
|
||||||
type="button"
|
label: "Recreate Tunnel",
|
||||||
disabled={isBusy}
|
icon: "refresh" as IconName,
|
||||||
>
|
onClick: () => onRecreateTunnel(session),
|
||||||
<Icon name="delete" size="sm" />
|
}]
|
||||||
</button>
|
: []),
|
||||||
)}
|
...(isActive && onStop
|
||||||
</div>
|
? [{
|
||||||
|
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>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { getSequenceWithModifier, type SpecialKey, type ModifierKey, KEY_SEQUENCES } from "../hooks/use-special-keys";
|
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
|
||||||
|
|
||||||
interface SpecialKeysStripProps {
|
interface SpecialKeysStripProps {
|
||||||
onSend: (data: string) => void;
|
onSend: (data: string) => void;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
interface ToolsBottomSheetProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLS_ITEMS = [
|
||||||
|
{ to: "/tool-workshop", label: "Tool Workshop" },
|
||||||
|
{ to: "/config-profiles", label: "Config Profiles" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ToolsBottomSheet: React.FC<ToolsBottomSheetProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSelect = (to: string) => {
|
||||||
|
onClose();
|
||||||
|
navigate(to);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mobile-bottom-sheet-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mobile-bottom-sheet"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Tools menu"
|
||||||
|
>
|
||||||
|
<div className="mobile-bottom-sheet-header">
|
||||||
|
<div className="mobile-bottom-sheet-handle" />
|
||||||
|
<h3 className="mobile-bottom-sheet-title">Tools</h3>
|
||||||
|
</div>
|
||||||
|
<div className="mobile-bottom-sheet-content">
|
||||||
|
{TOOLS_ITEMS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.to}
|
||||||
|
className={`mobile-bottom-sheet-item ${
|
||||||
|
location.pathname === item.to ? "active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelect(item.to)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="mobile-bottom-sheet-item-label">{item.label}</span>
|
||||||
|
{location.pathname === item.to && <Icon name="success" size="sm" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type AsyncStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
interface UseAsyncDataResult<T> {
|
||||||
|
data: T | null;
|
||||||
|
status: AsyncStatus;
|
||||||
|
error: string | null;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsyncData<T>(
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
deps: React.DependencyList = []
|
||||||
|
): UseAsyncDataResult<T> {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [status, setStatus] = useState<AsyncStatus>("idle");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await fetcher();
|
||||||
|
setData(result);
|
||||||
|
setStatus("ready");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load data");
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { data, status, error, reload };
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
stopInstance,
|
||||||
|
deleteInstance,
|
||||||
|
startInstance,
|
||||||
|
recreateInstanceTunnel,
|
||||||
|
} from "../api/sessions";
|
||||||
|
import type { Session } from "../api/sessions";
|
||||||
|
|
||||||
|
interface UseInstanceActionsOptions {
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseInstanceActionsReturn {
|
||||||
|
loadingSessionId: string | null;
|
||||||
|
dirtyDeleteSession: Session | null;
|
||||||
|
dirtyDeleteFiles: string[];
|
||||||
|
handleOpen: (session: Session) => void;
|
||||||
|
handleStart: (session: Session) => Promise<void>;
|
||||||
|
handleStop: (session: Session) => Promise<void>;
|
||||||
|
handleDelete: (session: Session) => Promise<void>;
|
||||||
|
handleForceDelete: (session: Session) => Promise<void>;
|
||||||
|
handleRecreateTunnel: (session: Session) => Promise<void>;
|
||||||
|
clearDirtyDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstanceActions(
|
||||||
|
options: UseInstanceActionsOptions
|
||||||
|
): UseInstanceActionsReturn {
|
||||||
|
const { onRefresh } = options;
|
||||||
|
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||||
|
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||||
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleOpen = useCallback((session: Session) => {
|
||||||
|
if (session.url) {
|
||||||
|
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.tool_type_interfaces?.includes("terminal")) {
|
||||||
|
window.location.href = `/instances/${session.id}/terminal`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = `/projects/${session.project_id}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStart = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
if (loadingSessionId === session.id) return;
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
try {
|
||||||
|
await startInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
await onRefresh();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadingSessionId, onRefresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStop = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
if (loadingSessionId === session.id) return;
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
try {
|
||||||
|
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
await onRefresh();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadingSessionId, onRefresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
if (loadingSessionId === session.id) return;
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
try {
|
||||||
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
setDirtyDeleteSession(null);
|
||||||
|
setDirtyDeleteFiles([]);
|
||||||
|
await onRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { status?: number; data?: { detail?: { changed_files?: string[] } } };
|
||||||
|
};
|
||||||
|
if (axiosError.response?.status === 409) {
|
||||||
|
const detail = axiosError.response.data?.detail;
|
||||||
|
if (detail?.changed_files) {
|
||||||
|
setDirtyDeleteSession(session);
|
||||||
|
setDirtyDeleteFiles(detail.changed_files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadingSessionId, onRefresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleForceDelete = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
if (loadingSessionId === session.id) return;
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
try {
|
||||||
|
await deleteInstance(session.project_id, session.repository_id, session.id, true);
|
||||||
|
setDirtyDeleteSession(null);
|
||||||
|
setDirtyDeleteFiles([]);
|
||||||
|
await onRefresh();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadingSessionId, onRefresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRecreateTunnel = useCallback(
|
||||||
|
async (session: Session) => {
|
||||||
|
if (loadingSessionId === session.id) return;
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||||
|
await onRefresh();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadingSessionId, onRefresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearDirtyDelete = useCallback(() => {
|
||||||
|
setDirtyDeleteSession(null);
|
||||||
|
setDirtyDeleteFiles([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadingSessionId,
|
||||||
|
dirtyDeleteSession,
|
||||||
|
dirtyDeleteFiles,
|
||||||
|
handleOpen,
|
||||||
|
handleStart,
|
||||||
|
handleStop,
|
||||||
|
handleDelete,
|
||||||
|
handleForceDelete,
|
||||||
|
handleRecreateTunnel,
|
||||||
|
clearDirtyDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
import { extractErrorMessage } from "../utils/errors";
|
||||||
|
import { MobileListView } from "../components/mobile-list-view";
|
||||||
|
import { MobileDetailView } from "../components/mobile-detail-view";
|
||||||
|
import { MobileEditView } from "../components/mobile-edit-view";
|
||||||
|
import { MobileFAB } from "../components/mobile-fab";
|
||||||
import {
|
import {
|
||||||
createConfigProfile,
|
createConfigProfile,
|
||||||
deleteConfigProfile,
|
deleteConfigProfile,
|
||||||
@@ -14,10 +21,14 @@ import {
|
|||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
|
import { GitMountEditor } from "../components/git-mount-editor";
|
||||||
|
|
||||||
type Status = "loading" | "ready" | "error";
|
type Status = "loading" | "ready" | "error";
|
||||||
|
type MobileView = "list" | "detail" | "edit";
|
||||||
|
|
||||||
export const ConfigProfilesPage = () => {
|
export const ConfigProfilesPage = () => {
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [mobileView, setMobileView] = useState<MobileView>("list");
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
@@ -37,6 +48,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
env_vars: {},
|
env_vars: {},
|
||||||
runtime_hints: {},
|
runtime_hints: {},
|
||||||
mounts: [],
|
mounts: [],
|
||||||
|
git_mounts: [],
|
||||||
files: {},
|
files: {},
|
||||||
is_default: false,
|
is_default: false,
|
||||||
});
|
});
|
||||||
@@ -57,6 +69,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
setProfiles(profs || []);
|
setProfiles(profs || []);
|
||||||
setProjects(projs || []);
|
setProjects(projs || []);
|
||||||
setToolTypes(types || []);
|
setToolTypes(types || []);
|
||||||
|
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
@@ -74,6 +87,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
env_vars: {},
|
env_vars: {},
|
||||||
runtime_hints: {},
|
runtime_hints: {},
|
||||||
mounts: [],
|
mounts: [],
|
||||||
|
git_mounts: [],
|
||||||
files: {},
|
files: {},
|
||||||
is_default: false,
|
is_default: false,
|
||||||
});
|
});
|
||||||
@@ -92,6 +106,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
env_vars: profile.env_vars,
|
env_vars: profile.env_vars,
|
||||||
runtime_hints: profile.runtime_hints,
|
runtime_hints: profile.runtime_hints,
|
||||||
mounts: profile.mounts,
|
mounts: profile.mounts,
|
||||||
|
git_mounts: profile.git_mounts || [],
|
||||||
files: profile.files,
|
files: profile.files,
|
||||||
is_default: profile.is_default,
|
is_default: profile.is_default,
|
||||||
});
|
});
|
||||||
@@ -119,16 +134,6 @@ export const ConfigProfilesPage = () => {
|
|||||||
resetForm();
|
resetForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractErrorMessage = (err: unknown): string => {
|
|
||||||
const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } };
|
|
||||||
const detail = axiosError?.response?.data?.detail;
|
|
||||||
if (typeof detail === "string") return detail;
|
|
||||||
if (Array.isArray(detail)) {
|
|
||||||
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
|
|
||||||
}
|
|
||||||
return "Failed to save";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Include management functions
|
// Include management functions
|
||||||
const getIncludedProfile = (id: string): ConfigProfile | undefined => profiles.find((p) => p.id === id);
|
const getIncludedProfile = (id: string): ConfigProfile | undefined => profiles.find((p) => p.id === id);
|
||||||
|
|
||||||
@@ -409,7 +414,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<p>Loading Config Profiles...</p>
|
<LoadingState message="Loading Config Profiles..." />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -417,10 +422,269 @@ export const ConfigProfilesPage = () => {
|
|||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<p className="text-error">Failed to load Config Profiles.</p>
|
<ErrorState message="Failed to load Config Profiles." onRetry={loadData} />
|
||||||
<button onClick={loadData}>
|
</div>
|
||||||
<Icon name="refresh" size="sm" /> Retry
|
);
|
||||||
</button>
|
}
|
||||||
|
|
||||||
|
// Mobile view rendering
|
||||||
|
if (isMobile) {
|
||||||
|
if (mobileView === "list") {
|
||||||
|
return (
|
||||||
|
<div className="mobile-page">
|
||||||
|
<div className="mobile-page-header">
|
||||||
|
<h1>Config Profiles</h1>
|
||||||
|
</div>
|
||||||
|
<MobileListView
|
||||||
|
items={profiles.map((profile) => ({
|
||||||
|
id: profile.id,
|
||||||
|
title: profile.name,
|
||||||
|
subtitle: profile.description || getScopeLabel(profile),
|
||||||
|
}))}
|
||||||
|
onItemClick={(id: string) => {
|
||||||
|
setSelectedProfileId(id);
|
||||||
|
setIsCreating(false);
|
||||||
|
const profile = profiles.find((p) => p.id === id);
|
||||||
|
if (profile) populateForm(profile);
|
||||||
|
setMobileView("detail");
|
||||||
|
}}
|
||||||
|
onItemDelete={(id: string) => handleDelete(id)}
|
||||||
|
emptyMessage="No config profiles yet"
|
||||||
|
/>
|
||||||
|
<MobileFAB onClick={() => {
|
||||||
|
setIsCreating(true);
|
||||||
|
setSelectedProfileId(null);
|
||||||
|
resetForm();
|
||||||
|
setMobileView("edit");
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileView === "detail" && selectedProfile) {
|
||||||
|
const fields = [
|
||||||
|
{ label: "Name", value: selectedProfile.name },
|
||||||
|
{ label: "Description", value: selectedProfile.description || "-" },
|
||||||
|
{ label: "Scope", value: getScopeLabel(selectedProfile) },
|
||||||
|
{ label: "Default", value: selectedProfile.is_default ? "Yes" : "No" },
|
||||||
|
{ label: "Environment Variables", value: Object.keys(selectedProfile.env_vars).length > 0 ? Object.entries(selectedProfile.env_vars).map(([k, v]) => `${k}=${v}`).join(", ") : "-" },
|
||||||
|
{ label: "Mounts", value: selectedProfile.mounts.length > 0 ? selectedProfile.mounts.map((m) => `${m.target} (${m.mode})`).join(", ") : "-" },
|
||||||
|
{ label: "Includes", value: selectedProfile.includes.length > 0 ? `${selectedProfile.includes.length} profile(s)` : "-" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileDetailView
|
||||||
|
title={selectedProfile.name}
|
||||||
|
subtitle={getScopeLabel(selectedProfile)}
|
||||||
|
fields={fields}
|
||||||
|
onBack={() => setMobileView("list")}
|
||||||
|
onEdit={() => {
|
||||||
|
populateForm(selectedProfile);
|
||||||
|
setIsCreating(false);
|
||||||
|
setMobileView("edit");
|
||||||
|
}}
|
||||||
|
onDelete={() => handleDelete(selectedProfile.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileView === "edit") {
|
||||||
|
return (
|
||||||
|
<MobileEditView
|
||||||
|
title={isCreating ? "Create Profile" : "Edit Profile"}
|
||||||
|
onCancel={() => {
|
||||||
|
if (isCreating) {
|
||||||
|
setMobileView("list");
|
||||||
|
} else if (selectedProfile) {
|
||||||
|
setMobileView("detail");
|
||||||
|
} else {
|
||||||
|
setMobileView("list");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSave={() => handleSubmit({ preventDefault: () => {} } as React.FormEvent)}
|
||||||
|
isSaving={saveStatus === "saving"}
|
||||||
|
>
|
||||||
|
{/* Profile form fields */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => updateFormField("name", e.target.value)}
|
||||||
|
placeholder="Profile name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description || ""}
|
||||||
|
onChange={(e) => updateFormField("description", e.target.value)}
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Project</label>
|
||||||
|
<select
|
||||||
|
value={formData.project_id || ""}
|
||||||
|
onChange={(e) => updateFormField("project_id", e.target.value || undefined)}
|
||||||
|
>
|
||||||
|
<option value="">Global (all projects)</option>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>{project.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Tool Type</label>
|
||||||
|
<select
|
||||||
|
value={formData.tool_type_id || ""}
|
||||||
|
onChange={(e) => updateFormField("tool_type_id", e.target.value || undefined)}
|
||||||
|
>
|
||||||
|
<option value="">Any tool type</option>
|
||||||
|
{toolTypes.map((toolType) => (
|
||||||
|
<option key={toolType.id} value={toolType.id}>{toolType.display_name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_default || false}
|
||||||
|
onChange={(e) => updateFormField("is_default", e.target.checked)}
|
||||||
|
/>
|
||||||
|
Default Profile
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environment Variables */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Environment Variables</label>
|
||||||
|
{Object.entries(formData.env_vars || {}).map(([key, value], index) => (
|
||||||
|
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newEnvVars = { ...formData.env_vars };
|
||||||
|
delete newEnvVars[key];
|
||||||
|
newEnvVars[e.target.value] = value;
|
||||||
|
updateFormField("env_vars", newEnvVars);
|
||||||
|
}}
|
||||||
|
placeholder="KEY"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newEnvVars = { ...formData.env_vars };
|
||||||
|
newEnvVars[key] = e.target.value;
|
||||||
|
updateFormField("env_vars", newEnvVars);
|
||||||
|
}}
|
||||||
|
placeholder="value"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newEnvVars = { ...formData.env_vars };
|
||||||
|
delete newEnvVars[key];
|
||||||
|
updateFormField("env_vars", newEnvVars);
|
||||||
|
}}
|
||||||
|
className="secondary-button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="secondary-button" onClick={addEnvVar}>
|
||||||
|
<Icon name="add" size="sm" /> Add Variable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mounts */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Mounts</label>
|
||||||
|
{(formData.mounts || []).map((mount, index) => (
|
||||||
|
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mount.target}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newMounts = [...(formData.mounts || [])];
|
||||||
|
newMounts[index] = { ...mount, target: e.target.value };
|
||||||
|
updateFormField("mounts", newMounts);
|
||||||
|
}}
|
||||||
|
placeholder="Target path"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={mount.mode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newMounts = [...(formData.mounts || [])];
|
||||||
|
newMounts[index] = { ...mount, mode: e.target.value as "ro" | "rw" };
|
||||||
|
updateFormField("mounts", newMounts);
|
||||||
|
}}
|
||||||
|
style={{ width: "80px" }}
|
||||||
|
>
|
||||||
|
<option value="ro">Read</option>
|
||||||
|
<option value="rw">Write</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newMounts = (formData.mounts || []).filter((_, i) => i !== index);
|
||||||
|
updateFormField("mounts", newMounts);
|
||||||
|
}}
|
||||||
|
className="secondary-button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="secondary-button" onClick={addMount}>
|
||||||
|
<Icon name="add" size="sm" /> Add Mount
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MobileEditView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to list
|
||||||
|
return (
|
||||||
|
<div className="mobile-page">
|
||||||
|
<div className="mobile-page-header">
|
||||||
|
<h1>Config Profiles</h1>
|
||||||
|
</div>
|
||||||
|
<MobileListView
|
||||||
|
items={profiles.map((profile) => ({
|
||||||
|
id: profile.id,
|
||||||
|
title: profile.name,
|
||||||
|
subtitle: profile.description || getScopeLabel(profile),
|
||||||
|
}))}
|
||||||
|
onItemClick={(id: string) => {
|
||||||
|
setSelectedProfileId(id);
|
||||||
|
setIsCreating(false);
|
||||||
|
const profile = profiles.find((p) => p.id === id);
|
||||||
|
if (profile) populateForm(profile);
|
||||||
|
setMobileView("detail");
|
||||||
|
}}
|
||||||
|
onItemDelete={(id: string) => handleDelete(id)}
|
||||||
|
emptyMessage="No config profiles yet"
|
||||||
|
/>
|
||||||
|
<MobileFAB onClick={() => {
|
||||||
|
setIsCreating(true);
|
||||||
|
setSelectedProfileId(null);
|
||||||
|
resetForm();
|
||||||
|
setMobileView("edit");
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -980,6 +1244,13 @@ export const ConfigProfilesPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<GitMountEditor
|
||||||
|
mounts={formData.git_mounts || []}
|
||||||
|
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
|
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
|
||||||
<button type="submit" disabled={saveStatus === "saving"}>
|
<button type="submit" disabled={saveStatus === "saving"}>
|
||||||
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
<Icon name={isCreating ? "add" : "save"} size="sm" />
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||||
import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
import { getUserSessions, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { updateUserConfig } from "../api/settings";
|
import { updateUserConfig } from "../api/settings";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { Icon } from "../components/icon";
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { CreateSessionForm } from "../components/create-session-form";
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
import { SessionList } from "../components/session-list";
|
import { SessionList } from "../components/session-list";
|
||||||
|
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||||
|
|
||||||
type HomeStatus = "loading" | "ready" | "error";
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -31,7 +32,6 @@ export const HomePage = () => {
|
|||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState("");
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
|
||||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||||
|
|
||||||
@@ -58,6 +58,15 @@ export const HomePage = () => {
|
|||||||
void loadHome();
|
void loadHome();
|
||||||
}, [loadHome]);
|
}, [loadHome]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadingSessionId: actionBusy,
|
||||||
|
handleOpen,
|
||||||
|
handleStart,
|
||||||
|
handleStop,
|
||||||
|
handleDelete,
|
||||||
|
handleRecreateTunnel,
|
||||||
|
} = useInstanceActions({ onRefresh: loadHome });
|
||||||
|
|
||||||
// Poll tunnel health every 30 seconds for running instances
|
// Poll tunnel health every 30 seconds for running instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkHealth = async () => {
|
const checkHealth = async () => {
|
||||||
@@ -130,64 +139,6 @@ export const HomePage = () => {
|
|||||||
await loadHome();
|
await loadHome();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpen = (session: SessionView) => {
|
|
||||||
if (session.url) {
|
|
||||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session.tool_type_interfaces.includes("terminal")) {
|
|
||||||
navigate(`/instances/${session.id}/terminal`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(`/projects/${session.project_id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStop = async (session: SessionView) => {
|
|
||||||
if (actionBusy === session.id) return;
|
|
||||||
setActionBusy(session.id);
|
|
||||||
try {
|
|
||||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
await loadHome();
|
|
||||||
} finally {
|
|
||||||
setActionBusy(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (session: SessionView) => {
|
|
||||||
if (actionBusy === session.id) return;
|
|
||||||
setActionBusy(session.id);
|
|
||||||
try {
|
|
||||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
|
||||||
} catch {
|
|
||||||
// error - session remains in state
|
|
||||||
} finally {
|
|
||||||
setActionBusy(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRecreateTunnel = async (session: SessionView) => {
|
|
||||||
if (actionBusy === session.id) return;
|
|
||||||
setActionBusy(session.id);
|
|
||||||
try {
|
|
||||||
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
|
||||||
await loadHome();
|
|
||||||
} finally {
|
|
||||||
setActionBusy(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStart = async (session: SessionView) => {
|
|
||||||
if (actionBusy === session.id) return;
|
|
||||||
setActionBusy(session.id);
|
|
||||||
try {
|
|
||||||
await startInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
await loadHome();
|
|
||||||
} finally {
|
|
||||||
setActionBusy(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack home-page">
|
<section className="stack home-page">
|
||||||
<header className="home-hero card">
|
<header className="home-hero card">
|
||||||
@@ -202,17 +153,9 @@ export const HomePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading overview...</p>}
|
{status === "loading" && <LoadingState message="Loading overview..." />}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
|
||||||
<div className="card stack">
|
|
||||||
<p>Unable to load your workspace overview.</p>
|
|
||||||
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "ready" && summary && (
|
{status === "ready" && summary && (
|
||||||
<>
|
<>
|
||||||
@@ -260,7 +203,7 @@ export const HomePage = () => {
|
|||||||
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||||
</div>
|
</div>
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<p className="muted">No projects yet.</p>
|
<EmptyState message="No projects yet." />
|
||||||
) : (
|
) : (
|
||||||
<div className="home-project-grid">
|
<div className="home-project-grid">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry } from "../api/git_repositories";
|
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryResponse } from "../api/git_repositories";
|
||||||
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
|
|
||||||
export const GitHistoryPage = () => {
|
export const GitHistoryPage = () => {
|
||||||
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
|
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [commits, setCommits] = useState<CommitHistoryEntry[]>([]);
|
|
||||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||||
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
|
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
|
||||||
const [branches, setBranches] = useState<string[]>([]);
|
|
||||||
const [selectedBranch, setSelectedBranch] = useState<string>("");
|
const [selectedBranch, setSelectedBranch] = useState<string>("");
|
||||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
|
||||||
const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
||||||
|
|
||||||
const loadHistory = useCallback(async () => {
|
const { data: historyData, status, reload } = useAsyncData<CommitHistoryResponse>(
|
||||||
if (!projectId || !repoId) return;
|
async () => {
|
||||||
setStatus("loading");
|
if (!projectId || !repoId) return { commits: [], branches: [], tags: [] };
|
||||||
try {
|
return await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
|
||||||
const data = await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
|
},
|
||||||
setCommits(data.commits);
|
[projectId, repoId, selectedBranch]
|
||||||
setBranches(data.branches);
|
);
|
||||||
if (data.branches.length > 0 && !selectedBranch) {
|
|
||||||
setSelectedBranch(data.branches[0]);
|
|
||||||
}
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}, [projectId, repoId, selectedBranch]);
|
|
||||||
|
|
||||||
|
// Auto-select first branch when data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadHistory();
|
if (historyData?.branches.length && !selectedBranch) {
|
||||||
}, [loadHistory]);
|
setSelectedBranch(historyData.branches[0]);
|
||||||
|
}
|
||||||
|
}, [historyData?.branches, selectedBranch]);
|
||||||
|
|
||||||
const handleCommitClick = async (hash: string) => {
|
const handleCommitClick = async (hash: string) => {
|
||||||
if (!projectId || !repoId) return;
|
if (!projectId || !repoId) return;
|
||||||
@@ -61,7 +55,7 @@ export const GitHistoryPage = () => {
|
|||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<p className="muted">Loading commit history...</p>
|
<LoadingState message="Loading commit history..." />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,15 +63,14 @@ export const GitHistoryPage = () => {
|
|||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<p>Failed to load commit history</p>
|
<ErrorState message="Failed to load commit history" onRetry={reload} />
|
||||||
<button className="secondary-button" onClick={() => void loadHistory()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commits = historyData?.commits ?? [];
|
||||||
|
const branches = historyData?.branches ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -6,48 +6,38 @@ import {
|
|||||||
listRepositories,
|
listRepositories,
|
||||||
} from "../api/git_repositories";
|
} from "../api/git_repositories";
|
||||||
import type { GitRepository } from "../api/git_repositories";
|
import type { GitRepository } from "../api/git_repositories";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
|
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
type RepoStatus = "loading" | "ready" | "error";
|
|
||||||
|
|
||||||
export const GitRepositoriesPage = () => {
|
export const GitRepositoriesPage = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [status, setStatus] = useState<RepoStatus>("loading");
|
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadRepositories = useCallback(async () => {
|
const { data: repositories, status, reload } = useAsyncData<GitRepository[]>(
|
||||||
if (!projectId) return;
|
async () => {
|
||||||
setStatus("loading");
|
if (!projectId) return [];
|
||||||
try {
|
return await listRepositories(projectId);
|
||||||
const data = await listRepositories(projectId);
|
},
|
||||||
setRepositories(data);
|
[projectId]
|
||||||
setStatus("ready");
|
);
|
||||||
} catch {
|
|
||||||
setRepositories([]);
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadRepositories();
|
|
||||||
}, [loadRepositories]);
|
|
||||||
|
|
||||||
const handleDelete = async (repoId: string) => {
|
const handleDelete = async (repoId: string) => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
try {
|
try {
|
||||||
await deleteRepository(projectId, repoId);
|
await deleteRepository(projectId, repoId);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
await loadRepositories();
|
reload();
|
||||||
} catch {
|
} catch {
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEmpty = status === "ready" && repositories.length === 0;
|
const safeRepositories = repositories ?? [];
|
||||||
|
const isEmpty = status === "ready" && safeRepositories.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
@@ -59,23 +49,15 @@ export const GitRepositoriesPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading repositories...</p>}
|
{status === "loading" && <LoadingState message="Loading repositories..." />}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && <ErrorState message="Failed to load repositories" onRetry={reload} />}
|
||||||
<div className="card stack">
|
|
||||||
<p>Failed to load repositories</p>
|
|
||||||
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
|
{isEmpty && <EmptyState message="No repositories yet. Create your first repository above." />}
|
||||||
|
|
||||||
{status === "ready" && repositories.length > 0 && (
|
{status === "ready" && safeRepositories.length > 0 && (
|
||||||
<div className="repository-list">
|
<div className="repository-list">
|
||||||
{repositories.map((repo) => (
|
{safeRepositories.map((repo) => (
|
||||||
<article className="card repository-card" key={repo.id}>
|
<article className="card repository-card" key={repo.id}>
|
||||||
<div className="repository-info">
|
<div className="repository-info">
|
||||||
<h3>{repo.name}</h3>
|
<h3>{repo.name}</h3>
|
||||||
@@ -131,7 +113,7 @@ export const GitRepositoriesPage = () => {
|
|||||||
open={showCreate}
|
open={showCreate}
|
||||||
title="Create Repository"
|
title="Create Repository"
|
||||||
onClose={() => setShowCreate(false)}
|
onClose={() => setShowCreate(false)}
|
||||||
onCreated={loadRepositories}
|
onCreated={reload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,39 +1,38 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
||||||
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { useAuth } from "../state/auth";
|
import { useAuth } from "../state/auth";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
import type { UserProfile } from "../api/profile";
|
import type { UserProfile } from "../api/profile";
|
||||||
|
|
||||||
type ProfileStatus = "loading" | "ready" | "error" | "saving";
|
type ProfileStatus = "loading" | "ready" | "error" | "saving";
|
||||||
|
|
||||||
export const ProfilePage = () => {
|
export const ProfilePage = () => {
|
||||||
const { refreshSession } = useAuth();
|
const { refreshSession } = useAuth();
|
||||||
const [status, setStatus] = useState<ProfileStatus>("loading");
|
const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []);
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const loadProfile = useCallback(async () => {
|
// Sync loaded profile into form fields
|
||||||
setStatus("loading");
|
useEffect(() => {
|
||||||
setError(null);
|
if (profile) {
|
||||||
try {
|
setName(profile.name);
|
||||||
const data = await getProfile();
|
setEmail(profile.email);
|
||||||
setProfile(data);
|
setDisplayStatus("ready");
|
||||||
setName(data.name);
|
setError(null);
|
||||||
setEmail(data.email);
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setProfile(null);
|
|
||||||
setStatus("error");
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, [profile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadProfile();
|
if (loadStatus === "error") {
|
||||||
}, [loadProfile]);
|
setDisplayStatus("error");
|
||||||
|
}
|
||||||
|
}, [loadStatus]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -45,16 +44,15 @@ export const ProfilePage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("saving");
|
setDisplayStatus("saving");
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await updateProfile({ name: name.trim(), email: email.trim() });
|
await updateProfile({ name: name.trim(), email: email.trim() });
|
||||||
setProfile(updated);
|
|
||||||
await refreshSession();
|
await refreshSession();
|
||||||
setStatus("ready");
|
setDisplayStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to update profile");
|
setError("Failed to update profile");
|
||||||
setStatus("ready");
|
setDisplayStatus("ready");
|
||||||
}
|
}
|
||||||
}, [name, email, refreshSession]);
|
}, [name, email, refreshSession]);
|
||||||
|
|
||||||
@@ -73,19 +71,19 @@ export const ProfilePage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("saving");
|
setDisplayStatus("saving");
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await uploadAvatar(file);
|
await uploadAvatar(file);
|
||||||
setProfile(updated);
|
|
||||||
await refreshSession();
|
await refreshSession();
|
||||||
setStatus("ready");
|
reload();
|
||||||
|
setDisplayStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to upload avatar");
|
setError("Failed to upload avatar");
|
||||||
setStatus("ready");
|
setDisplayStatus("ready");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshSession]
|
[refreshSession, reload]
|
||||||
);
|
);
|
||||||
|
|
||||||
const avatarUrl = profile?.avatar_url ?? null;
|
const avatarUrl = profile?.avatar_url ?? null;
|
||||||
@@ -94,19 +92,11 @@ export const ProfilePage = () => {
|
|||||||
<section className="stack">
|
<section className="stack">
|
||||||
<h1>Profile</h1>
|
<h1>Profile</h1>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading profile...</p>}
|
{displayStatus === "loading" && <LoadingState message="Loading profile..." />}
|
||||||
|
|
||||||
{status === "error" && (
|
{displayStatus === "error" && <ErrorState message="Failed to load profile" onRetry={reload} />}
|
||||||
<div className="card stack">
|
|
||||||
<p>Failed to load profile</p>
|
|
||||||
<button className="secondary-button" onClick={() => void loadProfile()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(status === "ready" || status === "saving") && profile && (
|
{(displayStatus === "ready" || displayStatus === "saving") && profile && (
|
||||||
<div className="card stack">
|
<div className="card stack">
|
||||||
<div className="profile-avatar-section">
|
<div className="profile-avatar-section">
|
||||||
<div className="avatar-preview">
|
<div className="avatar-preview">
|
||||||
@@ -118,11 +108,11 @@ export const ProfilePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
disabled={status === "saving"}
|
disabled={displayStatus === "saving"}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{status === "saving" ? (
|
{displayStatus === "saving" ? (
|
||||||
<>
|
<>
|
||||||
<Icon name="loading" size="sm" />
|
<Icon name="loading" size="sm" />
|
||||||
Uploading...
|
Uploading...
|
||||||
@@ -146,7 +136,7 @@ export const ProfilePage = () => {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profile-name">Name</label>
|
<label htmlFor="profile-name">Name</label>
|
||||||
<input
|
<input
|
||||||
disabled={status === "saving"}
|
disabled={displayStatus === "saving"}
|
||||||
id="profile-name"
|
id="profile-name"
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -157,7 +147,7 @@ export const ProfilePage = () => {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profile-email">Email</label>
|
<label htmlFor="profile-email">Email</label>
|
||||||
<input
|
<input
|
||||||
disabled={status === "saving"}
|
disabled={displayStatus === "saving"}
|
||||||
id="profile-email"
|
id="profile-email"
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
type="email"
|
type="email"
|
||||||
@@ -170,11 +160,11 @@ export const ProfilePage = () => {
|
|||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={status === "saving"}
|
disabled={displayStatus === "saving"}
|
||||||
onClick={() => void handleSave()}
|
onClick={() => void handleSave()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{status === "saving" ? (
|
{displayStatus === "saving" ? (
|
||||||
<>
|
<>
|
||||||
<Icon name="loading" size="sm" />
|
<Icon name="loading" size="sm" />
|
||||||
Saving...
|
Saving...
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
@@ -10,15 +10,15 @@ import {
|
|||||||
type ProjectCreateInput,
|
type ProjectCreateInput,
|
||||||
type ProjectUpdateInput,
|
type ProjectUpdateInput,
|
||||||
} from "../api/projects";
|
} from "../api/projects";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
|
|
||||||
type ProjectsStatus = "loading" | "ready" | "error";
|
|
||||||
type DialogMode = "none" | "create" | "edit";
|
type DialogMode = "none" | "create" | "edit";
|
||||||
|
|
||||||
export const ProjectsPage = () => {
|
export const ProjectsPage = () => {
|
||||||
const [status, setStatus] = useState<ProjectsStatus>("loading");
|
const { data: projects, status, reload } = useAsyncData<Project[]>(listProjects, []);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||||
const [formName, setFormName] = useState("");
|
const [formName, setFormName] = useState("");
|
||||||
@@ -26,21 +26,7 @@ export const ProjectsPage = () => {
|
|||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadProjects = useCallback(async () => {
|
const safeProjects = projects ?? [];
|
||||||
setStatus("loading");
|
|
||||||
try {
|
|
||||||
const data = await listProjects();
|
|
||||||
setProjects(data);
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setProjects([]);
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadProjects();
|
|
||||||
}, [loadProjects]);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setFormName("");
|
setFormName("");
|
||||||
@@ -88,7 +74,7 @@ export const ProjectsPage = () => {
|
|||||||
await updateProject(editingProject.id, input);
|
await updateProject(editingProject.id, input);
|
||||||
}
|
}
|
||||||
closeDialog();
|
closeDialog();
|
||||||
await loadProjects();
|
reload();
|
||||||
} catch {
|
} catch {
|
||||||
setFormError("Failed to save project");
|
setFormError("Failed to save project");
|
||||||
}
|
}
|
||||||
@@ -98,13 +84,13 @@ export const ProjectsPage = () => {
|
|||||||
try {
|
try {
|
||||||
await deleteProject(projectId);
|
await deleteProject(projectId);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
await loadProjects();
|
reload();
|
||||||
} catch {
|
} catch {
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEmpty = status === "ready" && projects.length === 0;
|
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
@@ -116,23 +102,15 @@ export const ProjectsPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading projects...</p>}
|
{status === "loading" && <LoadingState message="Loading projects..." />}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && <ErrorState message="Failed to load projects" onRetry={reload} />}
|
||||||
<div className="card stack">
|
|
||||||
<p>Failed to load projects</p>
|
|
||||||
<button className="secondary-button" onClick={() => void loadProjects()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEmpty && <p className="muted">No projects yet. Create your first project above.</p>}
|
{isEmpty && <EmptyState message="No projects yet. Create your first project above." />}
|
||||||
|
|
||||||
{status === "ready" && projects.length > 0 && (
|
{status === "ready" && safeProjects.length > 0 && (
|
||||||
<div className="project-list">
|
<div className="project-list">
|
||||||
{projects.map((project) => (
|
{safeProjects.map((project) => (
|
||||||
<article className="card project-card" key={project.id}>
|
<article className="card project-card" key={project.id}>
|
||||||
<div className="project-info">
|
<div className="project-info">
|
||||||
<h3>{project.name}</h3>
|
<h3>{project.name}</h3>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
|
||||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ import { WorkspaceHeader } from "../components/workspace-header";
|
|||||||
import { listToolTypes } from "../api/tool_types";
|
import { listToolTypes } from "../api/tool_types";
|
||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
|
|
||||||
|
type MobileTab = "files" | "editor" | "git" | "terminal";
|
||||||
|
|
||||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||||
|
|
||||||
interface FileTreeEntry {
|
interface FileTreeEntry {
|
||||||
@@ -43,6 +47,8 @@ interface Project {
|
|||||||
export const RepoWorkspace = () => {
|
export const RepoWorkspace = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
|
||||||
|
|
||||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
@@ -159,26 +165,16 @@ export const RepoWorkspace = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "loading" && (
|
{status === "loading" && (
|
||||||
<p className="muted">Loading repositories...</p>
|
<LoadingState message="Loading repositories..." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="card stack">
|
<ErrorState message="Failed to load repositories" onRetry={() => void loadRepositories()} />
|
||||||
<p>Failed to load repositories</p>
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={() => void loadRepositories()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "empty" && (
|
{status === "empty" && (
|
||||||
<div className="card stack">
|
<div className="card stack">
|
||||||
<p>No repositories in this project yet.</p>
|
<EmptyState message="No repositories in this project yet." />
|
||||||
<Link
|
<Link
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
to={`/projects/${projectId}/settings/repositories`}
|
to={`/projects/${projectId}/settings/repositories`}
|
||||||
@@ -190,67 +186,70 @@ export const RepoWorkspace = () => {
|
|||||||
|
|
||||||
{status === "ready" && repositories.length > 0 && (
|
{status === "ready" && repositories.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{selectedRepoId && (
|
{isMobile ? (
|
||||||
<GitToolbar
|
// Mobile Layout
|
||||||
projectId={projectId!}
|
<div className="mobile-workspace">
|
||||||
repoId={selectedRepoId}
|
<div className="mobile-workspace-header">
|
||||||
currentBranch={currentBranch}
|
<select
|
||||||
branches={branches}
|
value={selectedRepoId || ""}
|
||||||
hasRemote={Boolean(selectedRepo?.remote_url)}
|
onChange={(e) => handleRepoChange(e.target.value)}
|
||||||
isMirror={Boolean(selectedRepo?.is_mirror)}
|
className="mobile-repo-selector"
|
||||||
onBranchChange={(branch) => {
|
>
|
||||||
setCurrentBranch(branch);
|
{repositories.map((repo) => (
|
||||||
const newParams = new URLSearchParams(searchParams);
|
<option key={repo.id} value={repo.id}>
|
||||||
newParams.set("branch", branch);
|
{repo.name}
|
||||||
setSearchParams(newParams);
|
</option>
|
||||||
}}
|
))}
|
||||||
onRefresh={() => {
|
</select>
|
||||||
void loadBranches();
|
{selectedRepoId && (
|
||||||
void loadGitStatus();
|
|
||||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="workspace-layout">
|
|
||||||
<aside className="workspace-sidebar">
|
|
||||||
<div className="sidebar-section">
|
|
||||||
<label className="form-field">
|
|
||||||
Repository
|
|
||||||
<select
|
<select
|
||||||
value={selectedRepoId || ""}
|
value={currentBranch}
|
||||||
onChange={(e) => handleRepoChange(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const branch = e.target.value;
|
||||||
|
setCurrentBranch(branch);
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set("branch", branch);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
}}
|
||||||
|
className="mobile-branch-selector"
|
||||||
>
|
>
|
||||||
{repositories.map((repo) => (
|
{branches.map((branch) => (
|
||||||
<option key={repo.id} value={repo.id}>
|
<option key={branch} value={branch}>
|
||||||
{repo.name}
|
{branch}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedRepoId && (
|
<div className="mobile-workspace-content">
|
||||||
<>
|
{mobileTab === "files" && selectedRepoId && (
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
projectId={projectId!}
|
projectId={projectId!}
|
||||||
repoId={selectedRepoId}
|
repoId={selectedRepoId}
|
||||||
gitStatus={gitStatus}
|
gitStatus={gitStatus}
|
||||||
/>
|
/>
|
||||||
{gitStatus && (
|
|
||||||
<CommitPanel
|
|
||||||
projectId={projectId!}
|
|
||||||
repoId={selectedRepoId}
|
|
||||||
modified={gitStatus.modified}
|
|
||||||
added={gitStatus.added}
|
|
||||||
deleted={gitStatus.deleted}
|
|
||||||
untracked={gitStatus.untracked}
|
|
||||||
onCommit={() => {
|
|
||||||
void loadGitStatus();
|
|
||||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{selectedRepoId && (
|
{mobileTab === "editor" && selectedRepoId && (
|
||||||
|
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
||||||
|
)}
|
||||||
|
{mobileTab === "git" && selectedRepoId && gitStatus && (
|
||||||
|
<div className="mobile-git-view">
|
||||||
|
<CommitPanel
|
||||||
|
projectId={projectId!}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
modified={gitStatus.modified}
|
||||||
|
added={gitStatus.added}
|
||||||
|
deleted={gitStatus.deleted}
|
||||||
|
untracked={gitStatus.untracked}
|
||||||
|
onCommit={() => {
|
||||||
|
void loadGitStatus();
|
||||||
|
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mobileTab === "terminal" && selectedRepoId && (
|
||||||
<InstanceList
|
<InstanceList
|
||||||
projectId={projectId!}
|
projectId={projectId!}
|
||||||
repoId={selectedRepoId}
|
repoId={selectedRepoId}
|
||||||
@@ -259,17 +258,126 @@ export const RepoWorkspace = () => {
|
|||||||
toolTypes={toolTypes}
|
toolTypes={toolTypes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="workspace-main">
|
<div className="mobile-workspace-tabs">
|
||||||
{selectedRepoId && (
|
<button
|
||||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
|
||||||
)}
|
onClick={() => setMobileTab("files")}
|
||||||
</main>
|
type="button"
|
||||||
</div>
|
>
|
||||||
</>
|
<Icon name="folder" size="sm" />
|
||||||
|
<span>Files</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
|
||||||
|
onClick={() => setMobileTab("editor")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
<span>Editor</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
|
||||||
|
onClick={() => setMobileTab("git")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="branch" size="sm" />
|
||||||
|
<span>Git</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
|
||||||
|
onClick={() => setMobileTab("terminal")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="terminal" size="sm" />
|
||||||
|
<span>Terminal</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Desktop Layout
|
||||||
|
<>
|
||||||
|
{selectedRepoId && (
|
||||||
|
<GitToolbar
|
||||||
|
projectId={projectId!}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
currentBranch={currentBranch}
|
||||||
|
branches={branches}
|
||||||
|
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||||
|
isMirror={Boolean(selectedRepo?.is_mirror)}
|
||||||
|
onBranchChange={(branch) => {
|
||||||
|
setCurrentBranch(branch);
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set("branch", branch);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
}}
|
||||||
|
onRefresh={() => {
|
||||||
|
void loadBranches();
|
||||||
|
void loadGitStatus();
|
||||||
|
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="workspace-layout">
|
||||||
|
<aside className="workspace-sidebar">
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select
|
||||||
|
value={selectedRepoId || ""}
|
||||||
|
onChange={(e) => handleRepoChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<option key={repo.id} value={repo.id}>
|
||||||
|
{repo.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRepoId && (
|
||||||
|
<>
|
||||||
|
<FileBrowser
|
||||||
|
projectId={projectId!}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
gitStatus={gitStatus}
|
||||||
|
/>
|
||||||
|
{gitStatus && (
|
||||||
|
<CommitPanel
|
||||||
|
projectId={projectId!}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
modified={gitStatus.modified}
|
||||||
|
added={gitStatus.added}
|
||||||
|
deleted={gitStatus.deleted}
|
||||||
|
untracked={gitStatus.untracked}
|
||||||
|
onCommit={() => {
|
||||||
|
void loadGitStatus();
|
||||||
|
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<InstanceList
|
||||||
|
projectId={projectId!}
|
||||||
|
repoId={selectedRepoId}
|
||||||
|
projectName={project?.name}
|
||||||
|
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
||||||
|
toolTypes={toolTypes}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="workspace-main">
|
||||||
|
{selectedRepoId && (
|
||||||
|
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -369,7 +477,7 @@ const FileBrowser = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{entries.length === 0 && (
|
{entries.length === 0 && (
|
||||||
<p className="muted">No files in this repository yet.</p>
|
<EmptyState message="No files in this repository yet." />
|
||||||
)}
|
)}
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||||
|
|||||||
+18
-117
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
@@ -7,24 +6,20 @@ import { listRepositories, type GitRepository } from "../api/git_repositories";
|
|||||||
import {
|
import {
|
||||||
getUserSessions,
|
getUserSessions,
|
||||||
type Session,
|
type Session,
|
||||||
deleteInstance,
|
|
||||||
stopInstance,
|
|
||||||
startInstance,
|
|
||||||
checkInstanceHealth,
|
checkInstanceHealth,
|
||||||
recreateInstanceTunnel,
|
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||||
import { Icon } from "../components/icon";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { CreateSessionForm } from "../components/create-session-form";
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
import { SessionList } from "../components/session-list";
|
import { SessionList } from "../components/session-list";
|
||||||
import { SessionCard } from "../components/session-card";
|
import { SessionCard } from "../components/session-card";
|
||||||
|
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||||
import type { InstanceHealth } from "../api/sessions";
|
import type { InstanceHealth } from "../api/sessions";
|
||||||
|
|
||||||
type SessionsStatus = "loading" | "ready" | "error";
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
export const SessionsPage = () => {
|
export const SessionsPage = () => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||||
@@ -34,11 +29,7 @@ export const SessionsPage = () => {
|
|||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||||
|
|
||||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
|
||||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -83,7 +74,18 @@ export const SessionsPage = () => {
|
|||||||
void loadToolTypes();
|
void loadToolTypes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadingSessionId,
|
||||||
|
dirtyDeleteSession,
|
||||||
|
dirtyDeleteFiles,
|
||||||
|
handleOpen,
|
||||||
|
handleStart,
|
||||||
|
handleStop,
|
||||||
|
handleDelete,
|
||||||
|
handleForceDelete,
|
||||||
|
handleRecreateTunnel,
|
||||||
|
clearDirtyDelete,
|
||||||
|
} = useInstanceActions({ onRefresh: loadSessions });
|
||||||
|
|
||||||
// Poll health every 30 seconds for active web-enabled instances
|
// Poll health every 30 seconds for active web-enabled instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -154,116 +156,15 @@ export const SessionsPage = () => {
|
|||||||
await loadSessions();
|
await loadSessions();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (session: Session) => {
|
|
||||||
if (loadingSessionId === session.id) return;
|
|
||||||
setLoadingSessionId(session.id);
|
|
||||||
try {
|
|
||||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (session: Session) => {
|
|
||||||
if (loadingSessionId === session.id) return;
|
|
||||||
setLoadingSessionId(session.id);
|
|
||||||
try {
|
|
||||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
setDirtyDeleteSession(null);
|
|
||||||
setDirtyDeleteFiles([]);
|
|
||||||
// Remove from local state immediately
|
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
|
||||||
} catch (error) {
|
|
||||||
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
|
||||||
if (axiosError.response?.status === 409) {
|
|
||||||
const detail = axiosError.response.data?.detail;
|
|
||||||
if (detail?.changed_files) {
|
|
||||||
setDirtyDeleteSession(session);
|
|
||||||
setDirtyDeleteFiles(detail.changed_files);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleForceDelete = async (session: Session) => {
|
|
||||||
if (loadingSessionId === session.id) return;
|
|
||||||
setLoadingSessionId(session.id);
|
|
||||||
try {
|
|
||||||
await deleteInstance(session.project_id, session.repository_id, session.id, true);
|
|
||||||
setDirtyDeleteSession(null);
|
|
||||||
setDirtyDeleteFiles([]);
|
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRecreateTunnel = async (session: Session) => {
|
|
||||||
if (loadingSessionId === session.id) return;
|
|
||||||
setLoadingSessionId(session.id);
|
|
||||||
try {
|
|
||||||
await recreateInstanceTunnel(
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id,
|
|
||||||
session.id
|
|
||||||
);
|
|
||||||
// Refresh sessions to get new URL
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStart = async (session: Session) => {
|
|
||||||
if (loadingSessionId === session.id) return;
|
|
||||||
setLoadingSessionId(session.id);
|
|
||||||
try {
|
|
||||||
await startInstance(session.project_id, session.repository_id, session.id);
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLoadingSessionId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpen = (session: Session) => {
|
|
||||||
if (session.url) {
|
|
||||||
window.open(session.url, '_blank', 'noopener,noreferrer');
|
|
||||||
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
|
||||||
navigate(`/instances/${session.id}/terminal`);
|
|
||||||
} else {
|
|
||||||
navigate(`/projects/${session.project_id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack sessions-page">
|
<section className="stack sessions-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Sessions</h1>
|
<h1>Sessions</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading sessions...</p>}
|
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && <ErrorState message="Failed to load sessions" onRetry={() => void loadSessions()} />}
|
||||||
<div className="card stack">
|
|
||||||
<p>Failed to load sessions</p>
|
|
||||||
<button className="secondary-button" onClick={() => void loadSessions()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "ready" && (
|
{status === "ready" && (
|
||||||
<>
|
<>
|
||||||
@@ -311,7 +212,7 @@ export const SessionsPage = () => {
|
|||||||
|
|
||||||
{/* Dirty Delete Confirmation Modal */}
|
{/* Dirty Delete Confirmation Modal */}
|
||||||
{dirtyDeleteSession && (
|
{dirtyDeleteSession && (
|
||||||
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
|
<div className="modal-overlay" onClick={clearDirtyDelete}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>Uncommitted Changes</h3>
|
<h3>Uncommitted Changes</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -330,7 +231,7 @@ export const SessionsPage = () => {
|
|||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() => setDirtyDeleteSession(null)}
|
onClick={clearDirtyDelete}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||||
|
|
||||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||||
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
type SettingsStatus = "loading" | "ready" | "error";
|
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ label: "General", path: "general" },
|
{ label: "General", path: "general" },
|
||||||
@@ -26,7 +26,7 @@ type SettingsOutletContext = {
|
|||||||
|
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
const { data: loadedConfig, status, reload } = useAsyncData<UserConfig>(getUserConfig, []);
|
||||||
const [config, setConfig] = useState<UserConfig>({
|
const [config, setConfig] = useState<UserConfig>({
|
||||||
theme: "system",
|
theme: "system",
|
||||||
default_editor: null,
|
default_editor: null,
|
||||||
@@ -36,19 +36,12 @@ export const SettingsPage = () => {
|
|||||||
});
|
});
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
|
||||||
const loadConfig = useCallback(async () => {
|
// Sync loaded config into local editable state
|
||||||
try {
|
|
||||||
const data = await getUserConfig();
|
|
||||||
setConfig(data);
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadConfig();
|
if (loadedConfig) {
|
||||||
}, [loadConfig]);
|
setConfig(loadedConfig);
|
||||||
|
}
|
||||||
|
}, [loadedConfig]);
|
||||||
|
|
||||||
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
|
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
|
||||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -79,17 +72,13 @@ export const SettingsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return <section className="stack"><p className="muted">Loading settings...</p></section>;
|
return <section className="stack"><LoadingState message="Loading settings..." /></section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<p>Failed to load settings</p>
|
<ErrorState message="Failed to load settings" onRetry={reload} />
|
||||||
<button className="secondary-button" onClick={() => void loadConfig()} type="button">
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
|
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
|
|
||||||
export const SSHKeysPage = () => {
|
export const SSHKeysPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [newKeyName, setNewKeyName] = useState("");
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||||
@@ -17,23 +17,9 @@ export const SSHKeysPage = () => {
|
|||||||
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
||||||
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
||||||
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
||||||
|
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const safeKeys = keys ?? [];
|
||||||
loadKeys();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadKeys() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await listSSHKeys();
|
|
||||||
setKeys(data);
|
|
||||||
setError(null);
|
|
||||||
} catch {
|
|
||||||
setError("Failed to load SSH keys");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGenerate(e: React.FormEvent) {
|
async function handleGenerate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -45,7 +31,7 @@ export const SSHKeysPage = () => {
|
|||||||
setNewKeyName("");
|
setNewKeyName("");
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to generate SSH key");
|
setMutationError("Failed to generate SSH key");
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +44,7 @@ export const SSHKeysPage = () => {
|
|||||||
await deleteSSHKey(keyId);
|
await deleteSSHKey(keyId);
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to delete SSH key");
|
setMutationError("Failed to delete SSH key");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +60,9 @@ export const SSHKeysPage = () => {
|
|||||||
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
||||||
const result = await signPayload(keyId, { payload: payload.trim() });
|
const result = await signPayload(keyId, { payload: payload.trim() });
|
||||||
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
||||||
setError(null);
|
setMutationError(null);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to sign payload");
|
setMutationError("Failed to sign payload");
|
||||||
} finally {
|
} finally {
|
||||||
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
||||||
}
|
}
|
||||||
@@ -94,15 +80,15 @@ export const SSHKeysPage = () => {
|
|||||||
signature: signature.trim(),
|
signature: signature.trim(),
|
||||||
});
|
});
|
||||||
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
||||||
setError(null);
|
setMutationError(null);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to verify signature");
|
setMutationError("Failed to verify signature");
|
||||||
} finally {
|
} finally {
|
||||||
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
@@ -116,7 +102,7 @@ export const SSHKeysPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{mutationError && <div className="error">{mutationError}</div>}
|
||||||
|
|
||||||
<form onSubmit={handleGenerate} className="stack">
|
<form onSubmit={handleGenerate} className="stack">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -145,11 +131,13 @@ export const SSHKeysPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
|
||||||
|
|
||||||
<div className="keys-list">
|
<div className="keys-list">
|
||||||
{keys.length === 0 ? (
|
{safeKeys.length === 0 ? (
|
||||||
<p className="muted">No SSH keys yet. Generate one above.</p>
|
<EmptyState message="No SSH keys yet. Generate one above." />
|
||||||
) : (
|
) : (
|
||||||
keys.map((key) => (
|
safeKeys.map((key) => (
|
||||||
<div key={key.id} className="key-card">
|
<div key={key.id} className="key-card">
|
||||||
<div className="key-header">
|
<div className="key-header">
|
||||||
<h3>{key.name}</h3>
|
<h3>{key.name}</h3>
|
||||||
|
|||||||
+1974
-1194
File diff suppressed because it is too large
Load Diff
+609
-30
@@ -2633,26 +2633,17 @@ a.nav-item,
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: var(--space-2);
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-container .xterm {
|
/* xterm.js manages its own positioning and sizing */
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-container canvas {
|
.terminal-container canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure xterm fills container */
|
/* xterm.js manages its own viewport dimensions - do not override */
|
||||||
.terminal-container .xterm-viewport {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile terminal container - no padding to maximize space */
|
/* Mobile terminal container - no padding to maximize space */
|
||||||
.terminal-wrapper.mobile .terminal-container {
|
.terminal-wrapper.mobile .terminal-container {
|
||||||
@@ -3122,8 +3113,8 @@ a.nav-item,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-terminal-wrapper {
|
.mobile-terminal-wrapper {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: auto 1fr auto;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
@@ -3239,36 +3230,34 @@ a.nav-item,
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Terminal wrapper - fills content area */
|
/* Terminal wrapper - fills content area */
|
||||||
.terminal-wrapper.mobile {
|
.terminal-wrapper.mobile {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
position: absolute;
|
flex: 1;
|
||||||
top: 0;
|
min-height: 0;
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-wrapper.mobile .terminal-container {
|
.terminal-wrapper.mobile .terminal-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
max-height: 100%;
|
min-height: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* xterm fills container */
|
/* xterm.js manages its own sizing */
|
||||||
.terminal-wrapper.mobile .terminal-container .xterm {
|
|
||||||
width: 100%;
|
/* xterm.js manages its own scrolling and viewport dimensions */
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Special Keys Strip */
|
/* Special Keys Strip */
|
||||||
.special-keys-strip {
|
.special-keys-strip {
|
||||||
@@ -3499,16 +3488,17 @@ a.nav-item,
|
|||||||
/* Disable zoom on mobile terminal */
|
/* Disable zoom on mobile terminal */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.mobile-terminal-wrapper {
|
.mobile-terminal-wrapper {
|
||||||
touch-action: none;
|
touch-action: pan-y;
|
||||||
-webkit-text-size-adjust: none;
|
-webkit-text-size-adjust: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-terminal-wrapper * {
|
.mobile-terminal-wrapper button,
|
||||||
|
.mobile-terminal-wrapper .special-key-button {
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-container {
|
.terminal-container {
|
||||||
touch-action: none;
|
touch-action: pan-y;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3577,3 +3567,592 @@ a.nav-item,
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile Navigation */
|
||||||
|
.mobile-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
z-index: 100;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 64px;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-item.active {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-icon-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -8px;
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Bottom Sheet */
|
||||||
|
.mobile-bottom-sheet-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-sheet {
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 70vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slide-up 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-sheet-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-sheet-handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-sheet-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-sheet-content {
|
||||||
|
padding: 8px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-sheet-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-sheet-item:active {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-sheet-item.active {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Page Header */
|
||||||
|
.mobile-page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-header-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-header-back:active {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile shell content padding adjustment */
|
||||||
|
.shell-content.mobile {
|
||||||
|
padding-bottom: calc(1.25rem + 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Ensure minimum touch targets on mobile */
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
[role="button"] {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active states for touch feedback */
|
||||||
|
button:active,
|
||||||
|
a:active,
|
||||||
|
[role="button"]:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove transform on buttons that shouldn't scale */
|
||||||
|
.mobile-nav-item:active,
|
||||||
|
.mobile-action-sheet-button:active,
|
||||||
|
.mobile-action-sheet-cancel:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.dialog-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.workspace-main {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-entry {
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.modal-content {
|
||||||
|
min-width: auto;
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Sessions Page */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sessions-page {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-page .page-header {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-page .page-header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-session-section {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-session-section h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-section {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-section h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.session-card-actions.mobile {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-actions.mobile .mobile-primary {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-actions.mobile .mobile-more {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.create-session-form .form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-form input,
|
||||||
|
.create-session-form select,
|
||||||
|
.create-session-form textarea,
|
||||||
|
.create-session-form button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Action Sheet */
|
||||||
|
.mobile-action-sheet-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-sheet {
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-sheet-header {
|
||||||
|
padding: var(--space-3) var(--space-4) var(--space-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-sheet-handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0 auto var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-sheet-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-sheet-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-sheet-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--bg);
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 56px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-sheet-button.danger {
|
||||||
|
color: #cd3131;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-sheet-button:active {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-sheet-cancel {
|
||||||
|
display: block;
|
||||||
|
width: calc(100% - var(--space-4));
|
||||||
|
margin: var(--space-3) var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-action-sheet-cancel:active {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Git Mount Editor Styles */
|
||||||
|
.git-mount-editor {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-editor .section-subtitle {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-item {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-repo {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-paths {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-branch {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-add {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-add h5 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row input,
|
||||||
|
.git-mount-form .form-row select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row input.error,
|
||||||
|
.git-mount-form .form-row select.error {
|
||||||
|
border-color: #cd3131;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row .hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-row .error-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #cd3131;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-mount-form .form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo-form input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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