Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22c035984e | |||
| d35037df01 | |||
| 99097090e6 | |||
| 6f35eb77ae | |||
| 575e5837ea | |||
| 996ea73bbf | |||
| d0e5feeaa5 | |||
| 1f784b552d | |||
| 10cbe63095 | |||
| 4547105f3b | |||
| dacf105200 | |||
| 2525c58471 | |||
| 8dd350286e | |||
| 5af4de0d7e | |||
| ebfe991a15 | |||
| cc1507a33e | |||
| ae377baa74 | |||
| a0d4e76662 | |||
| b61c2256a8 | |||
| 7e6588a99d | |||
| 56b54e269a | |||
| 6e597b9e21 | |||
| a2c3d6877e | |||
| a200955ef2 | |||
| 76266fc3d0 | |||
| 9d54a2542b | |||
| 520078fd64 | |||
| 55c4dc1281 | |||
| 74ac7b00bb | |||
| ecc0acc8dd | |||
| fba01ddfb2 | |||
| 35a251a0b4 | |||
| b10eadf64b | |||
| 50fcf5c077 | |||
| 19db7db8d0 | |||
| 189f29ee41 | |||
| f2c3264be6 | |||
| f07a632c86 | |||
| 0b68efb6e0 | |||
| 54ec87a836 | |||
| 3d64ec9061 | |||
| ac6bd3304d | |||
| 6ec35988cc | |||
| e985f0122e | |||
| 2bd778117f | |||
| 23ae12e69c | |||
| 65d4fad3c5 | |||
| e6f64c39f3 | |||
| e7c42c17b9 | |||
| f7be50952a | |||
| 402e662c0c | |||
| 7450dd0ce5 | |||
| 7a88639250 | |||
| 1d33735e2f | |||
| d11b43b69f | |||
| 294d02f9fb | |||
| a10029f36c | |||
| 6a2ebae8c9 | |||
| 1a922d4171 | |||
| d906c12aa9 | |||
| 4906ce0cd9 | |||
| e7804c0f58 | |||
| b40eb3e88c | |||
| 98795e31dd | |||
| b4a7627718 | |||
| 9d27cd9fe8 | |||
| e3ef852eca | |||
| f824a1e6fe | |||
| ad09ffa6ec | |||
| 63ae706dd0 | |||
| 74b5d0dc8c | |||
| 25662e525c | |||
| d4e992a9e2 | |||
| 35ada0e662 |
+25
-9
@@ -16,34 +16,50 @@ RUN pip install --no-cache-dir --user -e ".[dev]"
|
|||||||
# Production stage
|
# Production stage
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user and add to docker group
|
||||||
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
|
RUN groupadd -r appgroup && useradd -r -g appgroup appuser \
|
||||||
|
&& groupadd -r docker || true \
|
||||||
|
&& usermod -aG docker appuser
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install runtime dependencies including Docker CLI
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
git \
|
git \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
gnupg \
|
||||||
|
&& install -m 0755 -d /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
|
||||||
|
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" > /etc/apt/sources.list.d/docker.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||||
|
&& curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
|
||||||
|
&& chmod +x /usr/local/bin/cloudflared \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy dependencies from builder
|
# Copy dependencies from builder
|
||||||
COPY --from=builder /root/.local /home/appuser/.local
|
COPY --from=builder /root/.local /root/.local
|
||||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
ENV PATH=/root/.local/bin:$PATH
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY --chown=appuser:appgroup . .
|
COPY --chown=appuser:appgroup . .
|
||||||
|
|
||||||
# Create directories for repo storage
|
# Create directories for repo and instance storage
|
||||||
RUN mkdir -p /data/repos && chown -R appuser:appgroup /data/repos
|
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
||||||
|
|
||||||
# Copy wait-for-db script
|
# Copy wait-for-db script
|
||||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
||||||
RUN chmod +x /usr/local/bin/wait-for-db.sh
|
RUN chmod +x /usr/local/bin/wait-for-db.sh
|
||||||
|
|
||||||
# Switch to non-root user
|
# NOTE: Running as root to access Docker socket for managing tool instances
|
||||||
USER appuser
|
# This is required because Docker socket permissions require root or docker group membership
|
||||||
|
# which doesn't work well across container boundaries.
|
||||||
|
# Consider using Docker-in-Docker or rootless Docker for production hardening.
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add container_name to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0007_instance_container_name
|
||||||
|
Revises: 0006_tool_instances
|
||||||
|
Create Date: 2026-05-20 08:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0007_instance_container_name"
|
||||||
|
down_revision: Union[str, None] = "0006_tool_instances"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("container_name", sa.String(255), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "container_name")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add category and interfaces to tool_types
|
||||||
|
|
||||||
|
Revision ID: 0008_tool_type_category
|
||||||
|
Revises: 0007_instance_container_name
|
||||||
|
Create Date: 2026-05-20 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0008_tool_type_category"
|
||||||
|
down_revision: Union[str, None] = "0007_instance_container_name"
|
||||||
|
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("category", sa.String(50), nullable=False, server_default="other")
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tool_types",
|
||||||
|
sa.Column("interfaces", sa.JSON(), nullable=False, server_default='["web"]')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_types", "interfaces")
|
||||||
|
op.drop_column("tool_types", "category")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""add tool_configs table
|
||||||
|
|
||||||
|
Revision ID: 0009_tool_configs
|
||||||
|
Revises: 0008_tool_type_category
|
||||||
|
Create Date: 2026-05-20 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0009_tool_configs"
|
||||||
|
down_revision: Union[str, None] = "0008_tool_type_category"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tool_configs",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("key", sa.String(255), nullable=False),
|
||||||
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("config_type", sa.String(20), nullable=False, server_default="env"),
|
||||||
|
sa.Column("file_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_configs_user_tool", "tool_configs", ["user_id", "tool_type_id"])
|
||||||
|
op.create_index("idx_tool_configs_project", "tool_configs", ["project_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_tool_configs_project", table_name="tool_configs")
|
||||||
|
op.drop_index("idx_tool_configs_user_tool", table_name="tool_configs")
|
||||||
|
op.drop_table("tool_configs")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add default_port to tool_types
|
||||||
|
|
||||||
|
Revision ID: 0010_tool_type_default_port
|
||||||
|
Revises: 0009_tool_configs
|
||||||
|
Create Date: 2026-05-20 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0010_tool_type_default_port"
|
||||||
|
down_revision: Union[str, None] = "0009_tool_configs"
|
||||||
|
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("default_port", sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_types", "default_port")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add tunnel fields to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0011_tool_instance_tunnel_fields
|
||||||
|
Revises: 0010_tool_type_default_port
|
||||||
|
Create Date: 2026-05-20 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0011_tool_instance_tunnel_fields"
|
||||||
|
down_revision: Union[str, None] = "0010_tool_type_default_port"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("public_url", sa.String(1024), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("tunnel_id", sa.String(255), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "tunnel_id")
|
||||||
|
op.drop_column("tool_instances", "public_url")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""make default_port non-nullable and set values
|
||||||
|
|
||||||
|
Revision ID: 0012_default_port_req
|
||||||
|
Revises: 0011_tool_instance_tunnel_fields
|
||||||
|
Create Date: 2026-05-20 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0012_default_port_req"
|
||||||
|
down_revision: Union[str, None] = "0011_tool_instance_tunnel_fields"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Set default_port for existing built-in tool types
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET default_port = CASE
|
||||||
|
WHEN name = 'code-server' THEN 8443
|
||||||
|
WHEN name = 'jupyter-notebook' THEN 8888
|
||||||
|
WHEN name = 'opencode' THEN 3000
|
||||||
|
ELSE 8080
|
||||||
|
END
|
||||||
|
WHERE default_port IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Make default_port non-nullable
|
||||||
|
op.alter_column(
|
||||||
|
"tool_types",
|
||||||
|
"default_port",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"tool_types",
|
||||||
|
"default_port",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""add config profiles, includes, mounts, and tool instance profile selection
|
||||||
|
|
||||||
|
Revision ID: 0013_add_config_profiles
|
||||||
|
Revises: 0012_default_port_req
|
||||||
|
Create Date: 2026-05-24 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0013_add_config_profiles"
|
||||||
|
down_revision: Union[str, None] = "0012_default_port_req"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create config_profiles table
|
||||||
|
op.create_table(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
||||||
|
|
||||||
|
# Create config_includes table
|
||||||
|
op.create_table(
|
||||||
|
"config_includes",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
|
||||||
|
op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"])
|
||||||
|
|
||||||
|
# Create config_mounts table
|
||||||
|
op.create_table(
|
||||||
|
"config_mounts",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("mount_path", sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
|
||||||
|
|
||||||
|
# Add selected_profile_id to tool_instances
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_tool_instances_selected_profile",
|
||||||
|
"tool_instances",
|
||||||
|
"config_profiles",
|
||||||
|
["selected_profile_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove selected_profile_id from tool_instances
|
||||||
|
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
|
||||||
|
op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey")
|
||||||
|
op.drop_column("tool_instances", "selected_profile_id")
|
||||||
|
|
||||||
|
# Drop config_mounts
|
||||||
|
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
|
||||||
|
op.drop_table("config_mounts")
|
||||||
|
|
||||||
|
# Drop config_includes
|
||||||
|
op.drop_index("idx_config_includes_included", table_name="config_includes")
|
||||||
|
op.drop_index("idx_config_includes_profile", table_name="config_includes")
|
||||||
|
op.drop_table("config_includes")
|
||||||
|
|
||||||
|
# Drop config_profiles
|
||||||
|
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
|
||||||
|
op.drop_table("config_profiles")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""add_tool_config_fields
|
||||||
|
|
||||||
|
Revision ID: 398082499c30
|
||||||
|
Revises: af8512103d67
|
||||||
|
Create Date: 2026-05-22 18:38:20.166184
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '398082499c30'
|
||||||
|
down_revision = 'af8512103d67'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add new columns to tool_configs
|
||||||
|
op.add_column('tool_configs', sa.Column('port_override', sa.Integer(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('start_command', sa.Text(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('working_directory', sa.Text(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('environment_variables', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
|
||||||
|
op.add_column('tool_configs', sa.Column('volumes', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='[]'))
|
||||||
|
|
||||||
|
# Add CHECK constraint for port range
|
||||||
|
op.create_check_constraint('chk_port_range', 'tool_configs', sa.text('port_override IS NULL OR (port_override >= 1 AND port_override <= 65535)'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop CHECK constraint
|
||||||
|
op.drop_constraint('chk_port_range', 'tool_configs', type_='check')
|
||||||
|
|
||||||
|
# Drop columns
|
||||||
|
op.drop_column('tool_configs', 'port_override')
|
||||||
|
op.drop_column('tool_configs', 'start_command')
|
||||||
|
op.drop_column('tool_configs', 'working_directory')
|
||||||
|
op.drop_column('tool_configs', 'environment_variables')
|
||||||
|
op.drop_column('tool_configs', 'volumes')
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""create_config_folders_table
|
||||||
|
|
||||||
|
Revision ID: 8ed7dd80973d
|
||||||
|
Revises: 398082499c30
|
||||||
|
Create Date: 2026-05-22 18:38:22.133696
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '8ed7dd80973d'
|
||||||
|
down_revision = '398082499c30'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'config_folders',
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||||
|
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('mount_path', sa.String(1024), nullable=False),
|
||||||
|
sa.Column('files', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
|
||||||
|
sa.Column('project_overrides', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
|
||||||
|
sa.UniqueConstraint('user_id', 'name', name='uq_config_folders_user_name')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add index on user_id for filtering
|
||||||
|
op.create_index('idx_config_folders_user', 'config_folders', ['user_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop index
|
||||||
|
op.drop_index('idx_config_folders_user', table_name='config_folders')
|
||||||
|
|
||||||
|
# Drop table
|
||||||
|
op.drop_table('config_folders')
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""add_tool_type_fields
|
||||||
|
|
||||||
|
Revision ID: af8512103d67
|
||||||
|
Revises: 0012_default_port_req
|
||||||
|
Create Date: 2026-05-22 18:37:56.607240
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'af8512103d67'
|
||||||
|
down_revision = '0012_default_port_req'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add new columns to tool_types
|
||||||
|
op.add_column('tool_types', sa.Column('definition_type', sa.String(20), nullable=False, server_default='compose'))
|
||||||
|
op.add_column('tool_types', sa.Column('dockerfile_template', sa.Text(), nullable=True))
|
||||||
|
op.add_column('tool_types', sa.Column('build_context', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
|
||||||
|
op.add_column('tool_types', sa.Column('readiness_probe', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||||
|
|
||||||
|
# Add CHECK constraint for definition_type
|
||||||
|
op.create_check_constraint('chk_definition_type', 'tool_types', sa.text("definition_type IN ('compose', 'dockerfile')"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop CHECK constraint
|
||||||
|
op.drop_constraint('chk_definition_type', 'tool_types', type_='check')
|
||||||
|
|
||||||
|
# Drop columns
|
||||||
|
op.drop_column('tool_types', 'definition_type')
|
||||||
|
op.drop_column('tool_types', 'dockerfile_template')
|
||||||
|
op.drop_column('tool_types', 'build_context')
|
||||||
|
op.drop_column('tool_types', 'readiness_probe')
|
||||||
@@ -59,7 +59,6 @@ async def login(next: str = "/") -> RedirectResponse:
|
|||||||
async def callback(
|
async def callback(
|
||||||
code: str,
|
code: str,
|
||||||
state: str,
|
state: str,
|
||||||
response: Response,
|
|
||||||
auth_state: str | None = Cookie(default=None),
|
auth_state: str | None = Cookie(default=None),
|
||||||
auth_next: str | None = Cookie(default="/"),
|
auth_next: str | None = Cookie(default="/"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
@@ -129,7 +128,13 @@ async def callback(
|
|||||||
cookie_secure = bool(cookie_options["secure"])
|
cookie_secure = bool(cookie_options["secure"])
|
||||||
cookie_domain = str(cookie_options["domain"]) if cookie_options.get("domain") else None
|
cookie_domain = str(cookie_options["domain"]) if cookie_options.get("domain") else None
|
||||||
|
|
||||||
response.set_cookie(
|
logger.info("Auth callback complete for user id=%s, redirecting to %s", user.id, auth_next)
|
||||||
|
|
||||||
|
# Redirect to frontend with the original next path
|
||||||
|
redirect_url = f"{settings.web_base_url}{auth_next}"
|
||||||
|
redirect_response = RedirectResponse(url=redirect_url)
|
||||||
|
|
||||||
|
redirect_response.set_cookie(
|
||||||
"session",
|
"session",
|
||||||
session_cookie,
|
session_cookie,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
@@ -137,14 +142,10 @@ async def callback(
|
|||||||
secure=cookie_secure,
|
secure=cookie_secure,
|
||||||
domain=cookie_domain,
|
domain=cookie_domain,
|
||||||
)
|
)
|
||||||
response.delete_cookie("auth_state", samesite="lax", domain=cookie_domain)
|
redirect_response.delete_cookie("auth_state", samesite="lax", domain=cookie_domain)
|
||||||
response.delete_cookie("auth_next", samesite="lax", domain=cookie_domain)
|
redirect_response.delete_cookie("auth_next", samesite="lax", domain=cookie_domain)
|
||||||
|
|
||||||
logger.info("Auth callback complete for user id=%s, redirecting to %s", user.id, auth_next)
|
|
||||||
|
|
||||||
# Redirect to frontend with the original next path
|
return redirect_response
|
||||||
redirect_url = f"{settings.web_base_url}{auth_next}"
|
|
||||||
return RedirectResponse(url=redirect_url)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""Config folder API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
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.config_folder import ConfigFolder
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
||||||
|
|
||||||
|
MAX_FOLDER_SIZE_MB = 10
|
||||||
|
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderCreate(BaseModel):
|
||||||
|
name: str = Field(description="Folder name (unique per user)")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
mount_path: str = Field(description="Default mount path in container")
|
||||||
|
files: dict = Field(default_factory=dict, description="Files as {path: content}")
|
||||||
|
|
||||||
|
@field_validator("mount_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_mount_path(cls, v: str) -> str:
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount path must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict) -> dict:
|
||||||
|
total_size = 0
|
||||||
|
for path, content in v.items():
|
||||||
|
# Check for path traversal
|
||||||
|
if ".." in path or path.startswith("/"):
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
total_size += len(content.encode("utf-8"))
|
||||||
|
|
||||||
|
if total_size > MAX_FOLDER_SIZE_BYTES:
|
||||||
|
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, description="Folder name")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
mount_path: str | None = Field(default=None, description="Default mount path")
|
||||||
|
files: dict | None = Field(default=None, description="Files as {path: content}")
|
||||||
|
is_active: bool | None = Field(default=None, description="Active/inactive toggle")
|
||||||
|
|
||||||
|
@field_validator("mount_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount path must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict | None) -> dict | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
for path, content in v.items():
|
||||||
|
# Check for path traversal
|
||||||
|
if ".." in path or path.startswith("/"):
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
total_size += len(content.encode("utf-8"))
|
||||||
|
|
||||||
|
if total_size > MAX_FOLDER_SIZE_BYTES:
|
||||||
|
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectOverrideCreate(BaseModel):
|
||||||
|
mount_path: str | None = Field(default=None, description="Override mount path")
|
||||||
|
files: dict = Field(default_factory=dict, description="Override files")
|
||||||
|
|
||||||
|
@field_validator("mount_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount path must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
mount_path: str
|
||||||
|
files: dict
|
||||||
|
project_overrides: dict | None
|
||||||
|
is_active: bool
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
|
||||||
|
async def list_config_folders(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List config folders for the current user."""
|
||||||
|
query = select(ConfigFolder).where(ConfigFolder.user_id == user_id)
|
||||||
|
result = await session.execute(query)
|
||||||
|
folders = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"id": str(f.id),
|
||||||
|
"user_id": str(f.user_id),
|
||||||
|
"name": f.name,
|
||||||
|
"description": f.description,
|
||||||
|
"mount_path": f.mount_path,
|
||||||
|
"files": f.files,
|
||||||
|
"project_overrides": f.project_overrides,
|
||||||
|
"is_active": f.is_active,
|
||||||
|
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||||
|
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||||
|
}
|
||||||
|
for f in folders
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_config_folder(
|
||||||
|
data: ConfigFolderCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a config folder."""
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ConfigFolder).where(
|
||||||
|
ConfigFolder.user_id == user_id,
|
||||||
|
ConfigFolder.name == data.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"config folder with name '{data.name}' already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = ConfigFolder(
|
||||||
|
user_id=user_id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
mount_path=data.mount_path,
|
||||||
|
files=data.files,
|
||||||
|
)
|
||||||
|
session.add(folder)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(folder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"user_id": str(folder.user_id),
|
||||||
|
"name": folder.name,
|
||||||
|
"description": folder.description,
|
||||||
|
"mount_path": folder.mount_path,
|
||||||
|
"files": folder.files,
|
||||||
|
"project_overrides": folder.project_overrides,
|
||||||
|
"is_active": folder.is_active,
|
||||||
|
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||||
|
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.")
|
||||||
|
async def update_config_folder(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
data: ConfigFolderUpdate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update a config folder."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
if data.name is not None:
|
||||||
|
folder.name = data.name
|
||||||
|
if data.description is not None:
|
||||||
|
folder.description = data.description
|
||||||
|
if data.mount_path is not None:
|
||||||
|
folder.mount_path = data.mount_path
|
||||||
|
if data.files is not None:
|
||||||
|
folder.files = data.files
|
||||||
|
if data.is_active is not None:
|
||||||
|
folder.is_active = data.is_active
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(folder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"user_id": str(folder.user_id),
|
||||||
|
"name": folder.name,
|
||||||
|
"description": folder.description,
|
||||||
|
"mount_path": folder.mount_path,
|
||||||
|
"files": folder.files,
|
||||||
|
"project_overrides": folder.project_overrides,
|
||||||
|
"is_active": folder.is_active,
|
||||||
|
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||||
|
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_config_folder(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""Delete a config folder."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
await session.delete(folder)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectOverrideWithId(ProjectOverrideCreate):
|
||||||
|
project_id: uuid.UUID = Field(description="Project ID for the override")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
|
||||||
|
async def get_config_folder(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get a config folder by ID."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"user_id": str(folder.user_id),
|
||||||
|
"name": folder.name,
|
||||||
|
"description": folder.description,
|
||||||
|
"mount_path": folder.mount_path,
|
||||||
|
"files": folder.files,
|
||||||
|
"project_overrides": folder.project_overrides,
|
||||||
|
"is_active": folder.is_active,
|
||||||
|
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||||
|
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
|
||||||
|
async def add_project_override(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
data: ProjectOverrideWithId,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Add a project override to a config folder."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
# Initialize project_overrides if None
|
||||||
|
if folder.project_overrides is None:
|
||||||
|
folder.project_overrides = {}
|
||||||
|
|
||||||
|
# Add/update override
|
||||||
|
override_data = {}
|
||||||
|
if data.mount_path is not None:
|
||||||
|
override_data["mount_path"] = data.mount_path
|
||||||
|
if data.files is not None:
|
||||||
|
override_data["files"] = data.files
|
||||||
|
|
||||||
|
# Use a copy to trigger SQLAlchemy change detection on JSONB
|
||||||
|
current_overrides = dict(folder.project_overrides or {})
|
||||||
|
current_overrides[str(data.project_id)] = override_data
|
||||||
|
folder.project_overrides = current_overrides
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(folder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"project_overrides": folder.project_overrides,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.")
|
||||||
|
async def update_project_override(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
data: ProjectOverrideCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update a project override."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
# Initialize project_overrides if None
|
||||||
|
if folder.project_overrides is None:
|
||||||
|
folder.project_overrides = {}
|
||||||
|
|
||||||
|
# Update override
|
||||||
|
current_overrides = dict(folder.project_overrides or {})
|
||||||
|
override_data = current_overrides.get(str(project_id), {})
|
||||||
|
if data.mount_path is not None:
|
||||||
|
override_data["mount_path"] = data.mount_path
|
||||||
|
if data.files is not None:
|
||||||
|
override_data["files"] = data.files
|
||||||
|
|
||||||
|
current_overrides[str(project_id)] = override_data
|
||||||
|
folder.project_overrides = current_overrides
|
||||||
|
|
||||||
|
# Mark the field as modified to ensure SQLAlchemy detects the change
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
flag_modified(folder, "project_overrides")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(folder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"project_overrides": folder.project_overrides,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.")
|
||||||
|
async def remove_project_override(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""Remove a project override."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
# Remove override if exists
|
||||||
|
current_overrides = dict(folder.project_overrides or {})
|
||||||
|
if str(project_id) in current_overrides:
|
||||||
|
del current_overrides[str(project_id)]
|
||||||
|
folder.project_overrides = current_overrides
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(folder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"project_overrides": folder.project_overrides or {},
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -36,6 +37,8 @@ from src.utils.git_url_parser import parse_git_url
|
|||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
"""Fetch a user by ID or raise 401 if not found."""
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
@@ -86,6 +89,88 @@ def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
|||||||
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_provider_clone_url(owner: str, repo: str) -> str:
|
||||||
|
"""Build the SSH clone URL for the fixed git provider."""
|
||||||
|
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_remote_repository(remote_url: str) -> None:
|
||||||
|
"""Verify a remote repository is reachable before cloning."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", remote_url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="repository not found or inaccessible",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "clone", remote_url, repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to clone repository: {result.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_working_repository(repo_path: str) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "init", "-b", "main", repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
fallback = subprocess.run(
|
||||||
|
["git", "init", repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if fallback.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to initialize repository: {fallback.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
ref_result = subprocess.run(
|
||||||
|
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if ref_result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to set initial branch: {ref_result.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GitRepositoryCreate(BaseModel):
|
class GitRepositoryCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
remote_url: str | None = None
|
remote_url: str | None = None
|
||||||
@@ -214,7 +299,7 @@ async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
|||||||
response_model=GitRepositoryResponse,
|
response_model=GitRepositoryResponse,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Create a repository",
|
summary="Create a repository",
|
||||||
description="Create a new git repository in a project. Can clone from remote or initialize bare.",
|
description="Create a new git repository in a project. Can clone from remote or initialize a working repository.",
|
||||||
)
|
)
|
||||||
async def create_repository(
|
async def create_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
@@ -264,47 +349,25 @@ async def create_repository(
|
|||||||
if parse_result["base_url"]:
|
if parse_result["base_url"]:
|
||||||
remote_url = parse_result["base_url"]
|
remote_url = parse_result["base_url"]
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
_preflight_remote_repository(remote_url)
|
||||||
|
|
||||||
repo_path = _get_repo_path(user_id, project_id, data.name)
|
repo_path = _get_repo_path(user_id, project_id, data.name)
|
||||||
|
|
||||||
# Ensure parent directory exists
|
# Ensure parent directory exists
|
||||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
# Clone as mirror
|
_clone_working_repository(remote_url, repo_path)
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "clone", "--mirror", remote_url, repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"failed to clone repository: {result.stderr}",
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
else:
|
else:
|
||||||
# Init bare repo
|
_init_working_repository(repo_path)
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "init", "--bare", repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
|
|
||||||
repo = GitRepository(
|
repo = GitRepository(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
path=repo_path,
|
path=repo_path,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
owner_id=user_id,
|
owner_id=user_id,
|
||||||
is_mirror=bool(remote_url),
|
is_mirror=False,
|
||||||
remote_url=remote_url,
|
remote_url=remote_url,
|
||||||
)
|
)
|
||||||
session.add(repo)
|
session.add(repo)
|
||||||
@@ -494,6 +557,14 @@ async def list_repository_files(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list files for repo %s (path=%s, branch=%s): %s",
|
||||||
|
repo_id,
|
||||||
|
path,
|
||||||
|
branch,
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@@ -599,6 +670,12 @@ async def get_repository_branches(
|
|||||||
default_branch=default_branch,
|
default_branch=default_branch,
|
||||||
)
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list branches for repo %s: %s",
|
||||||
|
repo_id,
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Instance proxy router for forwarding HTTP requests to running containers."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/instances", tags=["instance-proxy"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _proxy_request(
|
||||||
|
request: Request,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
path: str,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> Response:
|
||||||
|
"""Proxy an HTTP request to a running instance."""
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
if instance.owner_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="not authorized to access this instance",
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="instance is not running",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the tool type to find the internal port
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
internal_port = tool_type.default_port if tool_type and tool_type.default_port else instance.port
|
||||||
|
|
||||||
|
# Build target URL using internal port
|
||||||
|
target_url = f"http://{instance.container_name}:{internal_port}"
|
||||||
|
if path:
|
||||||
|
target_url += f"/{path}"
|
||||||
|
|
||||||
|
# Get query string
|
||||||
|
query_string = str(request.query_params)
|
||||||
|
if query_string:
|
||||||
|
target_url += f"?{query_string}"
|
||||||
|
|
||||||
|
# Forward headers (excluding host and cookies)
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
for key, value in request.headers.items():
|
||||||
|
if key.lower() not in ("host", "cookie", "content-length"):
|
||||||
|
headers[key] = value
|
||||||
|
|
||||||
|
# Forward the request
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
body = await request.body()
|
||||||
|
response = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
headers=headers,
|
||||||
|
content=body,
|
||||||
|
follow_redirects=False,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Proxy error to %s: %s", target_url, exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"failed to reach instance: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
response_headers = dict(response.headers)
|
||||||
|
# Remove hop-by-hop headers
|
||||||
|
for header in ("content-encoding", "transfer-encoding", "connection"):
|
||||||
|
response_headers.pop(header, None)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=response_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instance_id}/proxy/{path:path}")
|
||||||
|
@router.post("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.put("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.delete("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.patch("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.head("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.options("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
async def proxy_to_instance(
|
||||||
|
request: Request,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
path: str = "",
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Proxy requests to a running tool instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
instance_id: UUID of the instance.
|
||||||
|
path: The path to proxy to the instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response from the proxied instance.
|
||||||
|
"""
|
||||||
|
return await _proxy_request(request, instance_id, path, user_id, session)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""WebSocket terminal endpoint for tool instances."""
|
"""WebSocket terminal endpoint for tool instances."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||||
@@ -10,6 +12,7 @@ from src.models.tool_instance import ToolInstance
|
|||||||
from src.services.terminal_manager import terminal_manager
|
from src.services.terminal_manager import terminal_manager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket(
|
||||||
@@ -32,35 +35,42 @@ 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)
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse instance_id
|
# Parse instance_id
|
||||||
instance_uuid = uuid.UUID(instance_id)
|
instance_uuid = uuid.UUID(instance_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
logger.error("Invalid instance ID: %s", instance_id)
|
||||||
await websocket.close(code=4001, reason="Invalid instance ID")
|
await websocket.close(code=4001, reason="Invalid instance ID")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Authenticate user from session cookie
|
# Authenticate user from session cookie
|
||||||
user_id = await _get_user_from_websocket(websocket, db_session)
|
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
|
logger.warning("Unauthorized terminal access attempt for instance %s", instance_id)
|
||||||
await websocket.close(code=4003, reason="Unauthorized")
|
await websocket.close(code=4003, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get instance and verify ownership
|
# Get instance and verify ownership
|
||||||
instance = await db_session.get(ToolInstance, instance_uuid)
|
instance = await db_session.get(ToolInstance, instance_uuid)
|
||||||
if instance is None:
|
if instance is None:
|
||||||
|
logger.warning("Instance %s not found", instance_id)
|
||||||
await websocket.close(code=4004, reason="Instance not found")
|
await websocket.close(code=4004, reason="Instance not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
if instance.owner_id != user_id:
|
if instance.owner_id != user_id:
|
||||||
|
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
|
||||||
await websocket.close(code=4003, reason="Forbidden")
|
await websocket.close(code=4003, reason="Forbidden")
|
||||||
return
|
return
|
||||||
|
|
||||||
if instance.status != "running" or not instance.container_id:
|
if instance.status != "running" or not instance.container_id:
|
||||||
|
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
|
||||||
await websocket.close(code=4004, reason="Instance not running")
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id)
|
||||||
# Create terminal session
|
# Create terminal session
|
||||||
try:
|
try:
|
||||||
session = await terminal_manager.create_session(
|
session = await terminal_manager.create_session(
|
||||||
@@ -68,22 +78,18 @@ async def terminal_websocket(
|
|||||||
instance.container_id,
|
instance.container_id,
|
||||||
websocket,
|
websocket,
|
||||||
)
|
)
|
||||||
|
logger.info("Terminal session created successfully 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"})
|
||||||
|
|
||||||
# Keep connection alive until closed
|
# Keep connection alive until session ends
|
||||||
while True:
|
# The terminal_manager handles I/O loops, we just wait here
|
||||||
try:
|
while session.is_alive() and not session._closed:
|
||||||
message = await websocket.receive()
|
await asyncio.sleep(0.5)
|
||||||
if message["type"] == "websocket.disconnect":
|
|
||||||
break
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
break
|
|
||||||
except RuntimeError:
|
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||||
await websocket.close(code=4000, reason=f"Error: {exc}")
|
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||||
finally:
|
finally:
|
||||||
# Cleanup will be handled by the session manager
|
# Cleanup will be handled by the session manager
|
||||||
@@ -103,17 +109,16 @@ async def _get_user_from_websocket(
|
|||||||
Returns:
|
Returns:
|
||||||
The user's UUID if authenticated, None otherwise.
|
The user's UUID if authenticated, None otherwise.
|
||||||
"""
|
"""
|
||||||
from src.auth.session import verify_session_token
|
from src.auth.session import decode_session_cookie
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
session_cookie = websocket.cookies.get("session")
|
session_cookie = websocket.cookies.get("session")
|
||||||
if not session_cookie:
|
if not session_cookie:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user_id = verify_session_token(session_cookie)
|
settings = Settings()
|
||||||
if not user_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return uuid.UUID(user_id)
|
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
except ValueError:
|
return uuid.UUID(str(payload["user_id"]))
|
||||||
|
except (ValueError, KeyError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""Tool configuration API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
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_config import ToolConfig
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||||
|
|
||||||
|
|
||||||
|
class ToolConfigCreate(BaseModel):
|
||||||
|
tool_type_id: str = Field(description="UUID of the tool type")
|
||||||
|
project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config")
|
||||||
|
key: str = Field(description="Config key name")
|
||||||
|
value: str = Field(description="Config value")
|
||||||
|
config_type: str = Field(default="env", description="Type: env or file")
|
||||||
|
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
||||||
|
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
||||||
|
start_command: str | None = Field(default=None, description="Override container start command")
|
||||||
|
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
||||||
|
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
||||||
|
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
||||||
|
|
||||||
|
@field_validator("port_override")
|
||||||
|
@classmethod
|
||||||
|
def validate_port(cls, v: int | None) -> int | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v < 1 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("environment_variables")
|
||||||
|
@classmethod
|
||||||
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("environment_variables must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("volumes")
|
||||||
|
@classmethod
|
||||||
|
def validate_volumes(cls, v: list | None) -> list | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, list):
|
||||||
|
raise ValueError("volumes must be a JSON array")
|
||||||
|
for i, vol in enumerate(v):
|
||||||
|
if not isinstance(vol, dict):
|
||||||
|
raise ValueError(f"Volume at index {i} must be an object")
|
||||||
|
if "source" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||||
|
if "target" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ToolConfigUpdate(BaseModel):
|
||||||
|
key: str | None = Field(default=None, description="Config key name")
|
||||||
|
value: str | None = Field(default=None, description="Config value")
|
||||||
|
config_type: str | None = Field(default=None, description="Type: env or file")
|
||||||
|
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
||||||
|
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
||||||
|
start_command: str | None = Field(default=None, description="Override container start command")
|
||||||
|
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
||||||
|
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
||||||
|
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
||||||
|
|
||||||
|
@field_validator("port_override")
|
||||||
|
@classmethod
|
||||||
|
def validate_port(cls, v: int | None) -> int | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v < 1 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("environment_variables")
|
||||||
|
@classmethod
|
||||||
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("environment_variables must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("volumes")
|
||||||
|
@classmethod
|
||||||
|
def validate_volumes(cls, v: list | None) -> list | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, list):
|
||||||
|
raise ValueError("volumes must be a JSON array")
|
||||||
|
for i, vol in enumerate(v):
|
||||||
|
if not isinstance(vol, dict):
|
||||||
|
raise ValueError(f"Volume at index {i} must be an object")
|
||||||
|
if "source" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||||
|
if "target" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ToolConfigResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tool_type_id: str
|
||||||
|
project_id: str | None
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
config_type: str
|
||||||
|
file_path: str | None
|
||||||
|
port_override: int | None
|
||||||
|
start_command: str | None
|
||||||
|
working_directory: str | None
|
||||||
|
environment_variables: dict | None
|
||||||
|
volumes: list[dict] | None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
||||||
|
async def list_configs(
|
||||||
|
tool_type_id: str | None = None,
|
||||||
|
project_id: str | None = None,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list:
|
||||||
|
"""List tool configs for the current user."""
|
||||||
|
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
|
||||||
|
|
||||||
|
if tool_type_id:
|
||||||
|
query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id))
|
||||||
|
if project_id:
|
||||||
|
query = query.where(ToolConfig.project_id == uuid.UUID(project_id))
|
||||||
|
else:
|
||||||
|
# If no project specified, get only global configs (project_id is None)
|
||||||
|
query = query.where(ToolConfig.project_id.is_(None))
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
configs = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(c.id),
|
||||||
|
"tool_type_id": str(c.tool_type_id),
|
||||||
|
"project_id": str(c.project_id) if c.project_id else None,
|
||||||
|
"key": c.key,
|
||||||
|
"value": c.value,
|
||||||
|
"config_type": c.config_type,
|
||||||
|
"file_path": c.file_path,
|
||||||
|
"port_override": c.port_override,
|
||||||
|
"start_command": c.start_command,
|
||||||
|
"working_directory": c.working_directory,
|
||||||
|
"environment_variables": c.environment_variables,
|
||||||
|
"volumes": c.volumes,
|
||||||
|
}
|
||||||
|
for c in configs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_config(
|
||||||
|
data: ToolConfigCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a tool config."""
|
||||||
|
# Verify tool type exists
|
||||||
|
tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id))
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
|
||||||
|
# Check for existing config with same key
|
||||||
|
query = select(ToolConfig).where(
|
||||||
|
ToolConfig.user_id == user_id,
|
||||||
|
ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id),
|
||||||
|
ToolConfig.key == data.key,
|
||||||
|
)
|
||||||
|
if data.project_id:
|
||||||
|
query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id))
|
||||||
|
else:
|
||||||
|
query = query.where(ToolConfig.project_id.is_(None))
|
||||||
|
|
||||||
|
existing = await session.scalar(query)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"config with key '{data.key}' already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
config = ToolConfig(
|
||||||
|
user_id=user_id,
|
||||||
|
tool_type_id=uuid.UUID(data.tool_type_id),
|
||||||
|
project_id=uuid.UUID(data.project_id) if data.project_id else None,
|
||||||
|
key=data.key,
|
||||||
|
value=data.value,
|
||||||
|
config_type=data.config_type,
|
||||||
|
file_path=data.file_path,
|
||||||
|
port_override=data.port_override,
|
||||||
|
start_command=data.start_command,
|
||||||
|
working_directory=data.working_directory,
|
||||||
|
environment_variables=data.environment_variables,
|
||||||
|
volumes=data.volumes,
|
||||||
|
)
|
||||||
|
session.add(config)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(config)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(config.id),
|
||||||
|
"tool_type_id": str(config.tool_type_id),
|
||||||
|
"project_id": str(config.project_id) if config.project_id else None,
|
||||||
|
"key": config.key,
|
||||||
|
"value": config.value,
|
||||||
|
"config_type": config.config_type,
|
||||||
|
"file_path": config.file_path,
|
||||||
|
"port_override": config.port_override,
|
||||||
|
"start_command": config.start_command,
|
||||||
|
"working_directory": config.working_directory,
|
||||||
|
"environment_variables": config.environment_variables,
|
||||||
|
"volumes": config.volumes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
|
||||||
|
async def update_config(
|
||||||
|
config_id: uuid.UUID,
|
||||||
|
data: ToolConfigUpdate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update a tool config."""
|
||||||
|
config = await session.get(ToolConfig, config_id)
|
||||||
|
if config is None or config.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
||||||
|
|
||||||
|
if data.key is not None:
|
||||||
|
config.key = data.key
|
||||||
|
if data.value is not None:
|
||||||
|
config.value = data.value
|
||||||
|
if data.config_type is not None:
|
||||||
|
config.config_type = data.config_type
|
||||||
|
if data.file_path is not None:
|
||||||
|
config.file_path = data.file_path
|
||||||
|
if data.port_override is not None:
|
||||||
|
config.port_override = data.port_override
|
||||||
|
if data.start_command is not None:
|
||||||
|
config.start_command = data.start_command
|
||||||
|
if data.working_directory is not None:
|
||||||
|
config.working_directory = data.working_directory
|
||||||
|
if data.environment_variables is not None:
|
||||||
|
config.environment_variables = data.environment_variables
|
||||||
|
if data.volumes is not None:
|
||||||
|
config.volumes = data.volumes
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(config)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(config.id),
|
||||||
|
"tool_type_id": str(config.tool_type_id),
|
||||||
|
"project_id": str(config.project_id) if config.project_id else None,
|
||||||
|
"key": config.key,
|
||||||
|
"value": config.value,
|
||||||
|
"config_type": config.config_type,
|
||||||
|
"file_path": config.file_path,
|
||||||
|
"port_override": config.port_override,
|
||||||
|
"start_command": config.start_command,
|
||||||
|
"working_directory": config.working_directory,
|
||||||
|
"environment_variables": config.environment_variables,
|
||||||
|
"volumes": config.volumes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.")
|
||||||
|
async def get_default_configs(
|
||||||
|
tool_type_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get suggested default configs for a tool type."""
|
||||||
|
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
|
||||||
|
# Return suggested defaults based on required_variables
|
||||||
|
defaults = []
|
||||||
|
for var in tool_type.required_variables:
|
||||||
|
defaults.append({
|
||||||
|
"key": var,
|
||||||
|
"value": "",
|
||||||
|
"config_type": "env",
|
||||||
|
"description": f"Required variable: {var}",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tool_type_id": tool_type_id,
|
||||||
|
"suggested_configs": defaults,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.")
|
||||||
|
async def delete_config(
|
||||||
|
config_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""Delete a tool config."""
|
||||||
|
config = await session.get(ToolConfig, config_id)
|
||||||
|
if config is None or config.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
||||||
|
|
||||||
|
await session.delete(config)
|
||||||
|
await session.commit()
|
||||||
@@ -5,30 +5,43 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id
|
from src.auth.dependencies import get_current_user_id
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
|
from src.models.tool_config import ToolConfig
|
||||||
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.config_folder import ConfigFolder
|
||||||
from src.services.docker import (
|
from src.services.docker import (
|
||||||
|
check_tunnel_health,
|
||||||
|
connect_container_to_network,
|
||||||
ensure_instance_directory,
|
ensure_instance_directory,
|
||||||
execute_compose_command,
|
execute_compose_command,
|
||||||
find_free_port,
|
find_free_port,
|
||||||
get_container_id,
|
get_container_id,
|
||||||
get_container_logs,
|
get_container_name,
|
||||||
get_container_status,
|
recreate_tunnel,
|
||||||
render_compose_template,
|
render_compose_template,
|
||||||
|
start_cloudflared_tunnel,
|
||||||
|
stop_cloudflared_tunnel,
|
||||||
write_compose_file,
|
write_compose_file,
|
||||||
|
write_config_files,
|
||||||
|
write_env_file,
|
||||||
|
write_config_folder_files,
|
||||||
)
|
)
|
||||||
|
from src.services.docker_build import build_image
|
||||||
|
from src.services.readiness_probe import execute_probe
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||||
|
|
||||||
@@ -42,6 +55,58 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
||||||
|
|
||||||
|
|
||||||
|
def _modify_compose_file(
|
||||||
|
compose_path: str,
|
||||||
|
port_override: int | None = None,
|
||||||
|
start_command: str | None = None,
|
||||||
|
working_directory: str | None = None,
|
||||||
|
extra_volumes: list[dict] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Modify compose file with runtime overrides."""
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
compose_file = Path(compose_path)
|
||||||
|
content = compose_file.read_text()
|
||||||
|
compose_data = yaml.safe_load(content)
|
||||||
|
|
||||||
|
if not compose_data or "services" not in compose_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Apply modifications to the first service
|
||||||
|
for service_name, service_config in compose_data["services"].items():
|
||||||
|
if port_override and "ports" in service_config:
|
||||||
|
# Update port mapping
|
||||||
|
for i, port_mapping in enumerate(service_config["ports"]):
|
||||||
|
if isinstance(port_mapping, str) and ":" in port_mapping:
|
||||||
|
host_port, container_port = port_mapping.split(":", 1)
|
||||||
|
service_config["ports"][i] = f"{port_override}:{container_port}"
|
||||||
|
break
|
||||||
|
|
||||||
|
if start_command:
|
||||||
|
service_config["command"] = start_command
|
||||||
|
|
||||||
|
if working_directory:
|
||||||
|
service_config["working_dir"] = working_directory
|
||||||
|
|
||||||
|
if extra_volumes:
|
||||||
|
if "volumes" not in service_config:
|
||||||
|
service_config["volumes"] = []
|
||||||
|
for vol in extra_volumes:
|
||||||
|
source = vol.get("source", "")
|
||||||
|
target = vol.get("target", "")
|
||||||
|
vol_type = vol.get("type", "bind")
|
||||||
|
if vol_type == "bind":
|
||||||
|
service_config["volumes"].append(f"{source}:{target}")
|
||||||
|
else:
|
||||||
|
service_config["volumes"].append(f"{source}:{target}:{vol_type}")
|
||||||
|
|
||||||
|
break # Only modify the first service
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
"""Fetch a user by ID or raise 404 if not found."""
|
"""Fetch a user by ID or raise 404 if not found."""
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
@@ -124,53 +189,98 @@ async def create_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate unique name
|
try:
|
||||||
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
# Generate unique name
|
||||||
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}"
|
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
||||||
|
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}"
|
||||||
|
|
||||||
# Create instance directory
|
# Create instance directory
|
||||||
instance_dir = ensure_instance_directory(instance_name)
|
instance_dir = ensure_instance_directory(instance_name)
|
||||||
compose_path = os.path.join(instance_dir, "docker-compose.yml")
|
compose_path = os.path.join(instance_dir, "docker-compose.yml")
|
||||||
|
|
||||||
# Find free port
|
# Find free port
|
||||||
tool_port = find_free_port()
|
tool_port = find_free_port()
|
||||||
|
|
||||||
# Render compose template
|
# Handle based on definition type
|
||||||
variables = {
|
if tool_type.definition_type == "dockerfile":
|
||||||
"REPO_PATH": repo.path,
|
# Build image from Dockerfile
|
||||||
"INSTANCE_NAME": instance_name,
|
image_tag = f"headquarter/{instance_name}:latest"
|
||||||
"INSTANCE_ID": instance_name,
|
|
||||||
"TOOL_PORT": tool_port,
|
if tool_type.dockerfile_template:
|
||||||
"USER_ID": str(user_id),
|
returncode, stdout, stderr = build_image(
|
||||||
"PROJECT_ID": str(project_id),
|
instance_dir=instance_dir,
|
||||||
}
|
dockerfile=tool_type.dockerfile_template,
|
||||||
compose_content = render_compose_template(tool_type.compose_template, variables)
|
tag=image_tag,
|
||||||
write_compose_file(instance_dir, compose_content)
|
build_context=tool_type.build_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
logger.error("Failed to build image for instance %s: %s", instance_name, stderr)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to build Docker image: {stderr[:500]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Successfully built image %s for instance %s", image_tag, instance_name)
|
||||||
|
|
||||||
|
# Generate compose for dockerfile-built image
|
||||||
|
compose_content = f"""version: "3.8"
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: {image_tag}
|
||||||
|
container_name: {instance_name}
|
||||||
|
ports:
|
||||||
|
- "{tool_port}:{tool_type.default_port}"
|
||||||
|
volumes:
|
||||||
|
- {repo.path}:/workspace
|
||||||
|
restart: unless-stopped
|
||||||
|
"""
|
||||||
|
write_compose_file(instance_dir, compose_content)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Render compose template
|
||||||
|
variables = {
|
||||||
|
"REPO_PATH": repo.path,
|
||||||
|
"INSTANCE_NAME": instance_name,
|
||||||
|
"INSTANCE_ID": instance_name,
|
||||||
|
"TOOL_NAME": instance_name,
|
||||||
|
"TOOL_PORT": tool_port,
|
||||||
|
"USER_ID": str(user_id),
|
||||||
|
"PROJECT_ID": str(project_id),
|
||||||
|
}
|
||||||
|
compose_content = render_compose_template(tool_type.compose_template, variables)
|
||||||
|
write_compose_file(instance_dir, compose_content)
|
||||||
|
|
||||||
# Create database record
|
# Create database record
|
||||||
instance = ToolInstance(
|
instance = ToolInstance(
|
||||||
name=instance_name,
|
name=instance_name,
|
||||||
display_name=instance_display,
|
display_name=instance_display,
|
||||||
tool_type_id=tool_type_id,
|
tool_type_id=tool_type_id,
|
||||||
repository_id=repo_id,
|
repository_id=repo_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
owner_id=user_id,
|
owner_id=user_id,
|
||||||
status="pending",
|
status="pending",
|
||||||
compose_path=compose_path,
|
compose_path=compose_path,
|
||||||
port=tool_port,
|
port=tool_port,
|
||||||
)
|
)
|
||||||
session.add(instance)
|
session.add(instance)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(instance)
|
await session.refresh(instance)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(instance.id),
|
"id": str(instance.id),
|
||||||
"name": instance.name,
|
"name": instance.name,
|
||||||
"display_name": instance.display_name,
|
"display_name": instance.display_name,
|
||||||
"tool_type_id": str(instance.tool_type_id),
|
"tool_type_id": str(instance.tool_type_id),
|
||||||
"status": instance.status,
|
"status": instance.status,
|
||||||
"created_at": instance.created_at.isoformat(),
|
"created_at": instance.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to create instance: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create instance: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -212,21 +322,23 @@ async def list_instances(
|
|||||||
)
|
)
|
||||||
instances = result.scalars().all()
|
instances = result.scalars().all()
|
||||||
|
|
||||||
return {
|
instances_data = []
|
||||||
"instances": [
|
for i in instances:
|
||||||
{
|
tool_type = await session.get(ToolType, i.tool_type_id)
|
||||||
"id": str(i.id),
|
instances_data.append({
|
||||||
"name": i.name,
|
"id": str(i.id),
|
||||||
"display_name": i.display_name,
|
"name": i.name,
|
||||||
"tool_type_id": str(i.tool_type_id),
|
"display_name": i.display_name,
|
||||||
"status": i.status,
|
"tool_type_id": str(i.tool_type_id),
|
||||||
"url": i.url,
|
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||||
"port": i.port,
|
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
|
||||||
"created_at": i.created_at.isoformat(),
|
"status": i.status,
|
||||||
}
|
"url": i.url,
|
||||||
for i in instances
|
"port": i.port,
|
||||||
]
|
"created_at": i.created_at.isoformat(),
|
||||||
}
|
})
|
||||||
|
|
||||||
|
return {"instances": instances_data}
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -329,29 +441,220 @@ async def start_instance(
|
|||||||
|
|
||||||
instance.status = "building"
|
instance.status = "building"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
logger.info("Starting instance %s (name=%s)", instance.id, instance.name)
|
||||||
|
|
||||||
# Execute docker compose up
|
# Fetch tool configs for this tool type
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
env_vars = {}
|
||||||
instance.compose_path, "up"
|
config_files = {}
|
||||||
|
port_override = None
|
||||||
|
start_command = None
|
||||||
|
working_directory = None
|
||||||
|
extra_env_vars = {}
|
||||||
|
extra_volumes = []
|
||||||
|
|
||||||
|
config_query = select(ToolConfig).where(
|
||||||
|
ToolConfig.user_id == user_id,
|
||||||
|
ToolConfig.tool_type_id == instance.tool_type_id,
|
||||||
|
).where(
|
||||||
|
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
config_result = await session.execute(config_query)
|
||||||
|
configs = config_result.scalars().all()
|
||||||
|
logger.info("Found %d tool configs for instance %s", len(configs), instance.id)
|
||||||
|
|
||||||
|
for config in configs:
|
||||||
|
if config.config_type == "env":
|
||||||
|
env_vars[config.key] = config.value
|
||||||
|
elif config.config_type == "file" and config.file_path:
|
||||||
|
config_files[config.file_path] = config.value
|
||||||
|
|
||||||
|
# Handle new config fields
|
||||||
|
if config.port_override:
|
||||||
|
port_override = config.port_override
|
||||||
|
if config.start_command:
|
||||||
|
start_command = config.start_command
|
||||||
|
if config.working_directory:
|
||||||
|
working_directory = config.working_directory
|
||||||
|
if config.environment_variables:
|
||||||
|
extra_env_vars.update(config.environment_variables)
|
||||||
|
if config.volumes:
|
||||||
|
extra_volumes.extend(config.volumes)
|
||||||
|
|
||||||
|
# Merge extra env vars
|
||||||
|
env_vars.update(extra_env_vars)
|
||||||
|
|
||||||
|
# Fetch active config folders for this user
|
||||||
|
folder_query = select(ConfigFolder).where(
|
||||||
|
ConfigFolder.user_id == user_id,
|
||||||
|
ConfigFolder.is_active == True,
|
||||||
|
)
|
||||||
|
folder_result = await session.execute(folder_query)
|
||||||
|
config_folders = folder_result.scalars().all()
|
||||||
|
logger.info("Found %d active config folders for instance %s", len(config_folders), instance.id)
|
||||||
|
|
||||||
|
# Write env file and config files
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_file_path = None
|
||||||
|
|
||||||
|
if env_vars:
|
||||||
|
env_file_path = write_env_file(instance_dir, env_vars)
|
||||||
|
logger.info("Wrote env file for instance %s: %s", instance.id, env_file_path)
|
||||||
|
|
||||||
|
if config_files:
|
||||||
|
write_config_files(instance_dir, config_files)
|
||||||
|
logger.info("Wrote %d config files for instance %s", len(config_files), instance.id)
|
||||||
|
|
||||||
|
# Write config folder files
|
||||||
|
if config_folders:
|
||||||
|
folder_volumes = write_config_folder_files(instance_dir, config_folders, str(project_id))
|
||||||
|
extra_volumes.extend(folder_volumes)
|
||||||
|
logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id)
|
||||||
|
|
||||||
|
# Modify compose file if needed (port override, start command, working dir, volumes)
|
||||||
|
if port_override or start_command or working_directory or extra_volumes:
|
||||||
|
_modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes)
|
||||||
|
logger.info("Modified compose file for instance %s", instance.id)
|
||||||
|
|
||||||
|
# Execute docker compose up with env file
|
||||||
|
logger.info("Running docker compose up for instance %s (compose_path=%s)", instance.id, instance.compose_path)
|
||||||
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
|
instance.compose_path, "up", env_file=env_file_path
|
||||||
|
)
|
||||||
|
logger.info("Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s",
|
||||||
|
instance.id, returncode, stdout[:200] if stdout else "", stderr[:500] if stderr else "")
|
||||||
|
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
instance.status = "error"
|
instance.status = "error"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
logger.error("Failed to start instance %s: %s", instance.id, stderr)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"failed to start instance: {stderr}",
|
detail=f"failed to start instance: {stderr}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get container ID
|
# Get container ID and name
|
||||||
container_id = get_container_id(instance.name)
|
container_id = get_container_id(instance.name)
|
||||||
if container_id:
|
if container_id:
|
||||||
instance.container_id = container_id
|
instance.container_id = container_id
|
||||||
|
logger.info("Container ID for instance %s: %s", instance.id, container_id)
|
||||||
|
|
||||||
|
container_name = get_container_name(instance.name)
|
||||||
|
if container_name:
|
||||||
|
instance.container_name = container_name
|
||||||
|
logger.info("Container name for instance %s: %s", instance.id, container_name)
|
||||||
|
|
||||||
|
# Connect container to backend network so API can reach it
|
||||||
|
logger.info("Connecting container %s to backend network...", container_name)
|
||||||
|
connected = connect_container_to_network(container_name, "backend")
|
||||||
|
if connected:
|
||||||
|
logger.info("Successfully connected %s to backend network", container_name)
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to connect %s to backend network", container_name)
|
||||||
|
|
||||||
|
instance.status = "starting"
|
||||||
|
instance.last_started_at = datetime.now()
|
||||||
|
await session.commit()
|
||||||
|
logger.info("Instance %s container is running, checking readiness", instance.id)
|
||||||
|
|
||||||
|
# Execute readiness probe if configured
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
if tool_type and tool_type.readiness_probe:
|
||||||
|
probe_config = tool_type.readiness_probe
|
||||||
|
probe_command = probe_config.get("command", "")
|
||||||
|
probe_timeout = probe_config.get("timeout", 30)
|
||||||
|
probe_interval = probe_config.get("interval", 2)
|
||||||
|
|
||||||
|
if probe_command and instance.container_id:
|
||||||
|
logger.info(
|
||||||
|
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
|
||||||
|
instance.id, probe_command, probe_timeout, probe_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
success, probe_logs = await execute_probe(
|
||||||
|
container_id=instance.container_id,
|
||||||
|
command=probe_command,
|
||||||
|
timeout=probe_timeout,
|
||||||
|
interval=probe_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
instance.status = "failed"
|
||||||
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
await session.commit()
|
||||||
|
logger.error("Readiness probe failed for instance %s: %s", instance.id, "\n".join(probe_logs))
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"error": f"Readiness probe failed after {probe_timeout}s",
|
||||||
|
"probe_logs": probe_logs,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Readiness probe succeeded for instance %s", instance.id)
|
||||||
|
|
||||||
instance.status = "running"
|
instance.status = "running"
|
||||||
instance.last_started_at = datetime.now()
|
|
||||||
instance.url = f"http://localhost:{instance.port}"
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
logger.info("Instance %s is now running", instance.id)
|
||||||
|
|
||||||
|
# Get tool type for default port
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
if not tool_type or not tool_type.default_port:
|
||||||
|
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.",
|
||||||
|
instance.tool_type_id)
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
|
||||||
|
}
|
||||||
|
|
||||||
|
instance_port = tool_type.default_port
|
||||||
|
logger.info("Tool type for instance %s: name=%s, default_port=%s, interfaces=%s",
|
||||||
|
instance.id, tool_type.name, instance_port, tool_type.interfaces)
|
||||||
|
|
||||||
|
# Only create Cloudflare tunnel for web-enabled tools
|
||||||
|
if "web" in tool_type.interfaces:
|
||||||
|
# Create temporary Cloudflare tunnel for public access
|
||||||
|
try:
|
||||||
|
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)",
|
||||||
|
instance.id, instance.container_name, instance_port)
|
||||||
|
tunnel_info = start_cloudflared_tunnel(
|
||||||
|
container_name=instance.container_name or instance.name,
|
||||||
|
port=instance_port,
|
||||||
|
)
|
||||||
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
|
instance.public_url = tunnel_info["url"]
|
||||||
|
instance.url = tunnel_info["url"]
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Created temporary tunnel for instance %s: pid=%s, url=%s",
|
||||||
|
instance.id,
|
||||||
|
tunnel_info["pid"],
|
||||||
|
tunnel_info["url"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
import traceback
|
||||||
|
error_msg = str(exc)
|
||||||
|
error_trace = traceback.format_exc()
|
||||||
|
logger.error(
|
||||||
|
"Failed to create tunnel for instance %s: %s\nTraceback:\n%s",
|
||||||
|
instance.id,
|
||||||
|
error_msg,
|
||||||
|
error_trace,
|
||||||
|
)
|
||||||
|
instance.status = "error"
|
||||||
|
instance.url = None
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Failed to create tunnel: {error_msg}",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Terminal-only tool - no tunnel needed
|
||||||
|
logger.info("Instance %s is terminal-only (no web interface), skipping tunnel creation", instance.id)
|
||||||
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
return {"status": instance.status, "url": instance.url}
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|
||||||
@@ -389,12 +692,22 @@ async def stop_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stop Cloudflare tunnel if exists
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
logger.info("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
execute_compose_command(instance.compose_path, "stop")
|
execute_compose_command(instance.compose_path, "stop")
|
||||||
|
|
||||||
instance.status = "stopped"
|
instance.status = "stopped"
|
||||||
instance.last_stopped_at = datetime.now()
|
instance.last_stopped_at = datetime.now()
|
||||||
instance.url = None
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
instance.tunnel_id = None
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {"status": instance.status}
|
return {"status": instance.status}
|
||||||
@@ -433,6 +746,14 @@ async def restart_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stop old tunnel if exists
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
logger.info("Stopped old tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
instance.compose_path, "restart"
|
instance.compose_path, "restart"
|
||||||
@@ -441,7 +762,55 @@ async def restart_instance(
|
|||||||
if returncode == 0:
|
if returncode == 0:
|
||||||
instance.status = "running"
|
instance.status = "running"
|
||||||
instance.last_started_at = datetime.now()
|
instance.last_started_at = datetime.now()
|
||||||
instance.url = f"http://localhost:{instance.port}"
|
|
||||||
|
# Get tool type for default port
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
if not tool_type or not tool_type.default_port:
|
||||||
|
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.",
|
||||||
|
instance.tool_type_id)
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
|
||||||
|
}
|
||||||
|
|
||||||
|
instance_port = tool_type.default_port
|
||||||
|
|
||||||
|
# Only create tunnel for web-enabled tools
|
||||||
|
if "web" in tool_type.interfaces:
|
||||||
|
# Create new temporary tunnel
|
||||||
|
try:
|
||||||
|
tunnel_info = start_cloudflared_tunnel(
|
||||||
|
container_name=instance.container_name or instance.name,
|
||||||
|
port=instance_port,
|
||||||
|
)
|
||||||
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
|
instance.public_url = tunnel_info["url"]
|
||||||
|
instance.url = tunnel_info["url"]
|
||||||
|
logger.info(
|
||||||
|
"Created new tunnel for instance %s: %s",
|
||||||
|
instance.id,
|
||||||
|
tunnel_info["url"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to create tunnel for instance %s: %s",
|
||||||
|
instance.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
instance.status = "error"
|
||||||
|
instance.url = None
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Failed to create tunnel: {exc}",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Terminal-only tool
|
||||||
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"status": instance.status, "url": instance.url}
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|
||||||
@@ -483,6 +852,14 @@ async def delete_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stop Cloudflare tunnel if exists
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
logger.info("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
# Stop and remove container
|
# Stop and remove container
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
execute_compose_command(instance.compose_path, "down")
|
execute_compose_command(instance.compose_path, "down")
|
||||||
@@ -540,6 +917,244 @@ async def get_instance_logs(
|
|||||||
return {"logs": logs}
|
return {"logs": logs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel",
|
||||||
|
summary="Recreate tunnel",
|
||||||
|
description="Recreate the temporary Cloudflare tunnel for a running instance.",
|
||||||
|
)
|
||||||
|
async def recreate_tunnel_endpoint(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Recreate the temporary tunnel for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with new URL and status.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="instance must be running to recreate tunnel",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get tool type for default port
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
|
||||||
|
|
||||||
|
try:
|
||||||
|
tunnel_info = recreate_tunnel(
|
||||||
|
container_name=instance.container_name or instance.name,
|
||||||
|
port=instance_port,
|
||||||
|
old_pid=instance.tunnel_id,
|
||||||
|
)
|
||||||
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
|
instance.public_url = tunnel_info["url"]
|
||||||
|
instance.url = tunnel_info["url"]
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Recreated tunnel for instance %s: pid=%s, url=%s",
|
||||||
|
instance.id,
|
||||||
|
tunnel_info["pid"],
|
||||||
|
tunnel_info["url"],
|
||||||
|
)
|
||||||
|
return {"status": "healthy", "url": instance.url}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to recreate tunnel for instance %s", instance.id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to recreate tunnel: {str(exc)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
|
||||||
|
summary="Check tunnel health",
|
||||||
|
description="Check if the temporary Cloudflare tunnel for an instance is healthy.",
|
||||||
|
)
|
||||||
|
async def check_instance_tunnel_health(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Check tunnel health for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with health status.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not instance.url or instance.status != "running":
|
||||||
|
return {"healthy": False, "status_code": None, "error": "instance not running"}
|
||||||
|
|
||||||
|
health = check_tunnel_health(instance.url)
|
||||||
|
return health
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
)
|
||||||
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
@router.put(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
@router.delete(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
@router.patch(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
@router.head(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
@router.options(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
async def proxy_to_instance(
|
||||||
|
request: Request,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
path: str = "",
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Proxy requests to a running tool instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the instance.
|
||||||
|
path: The path to proxy to the instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response from the proxied instance.
|
||||||
|
"""
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
if instance.owner_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="not authorized to access this instance",
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="instance is not running",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build target URL
|
||||||
|
target_url = f"http://{instance.container_name}:{instance.port}"
|
||||||
|
if path:
|
||||||
|
target_url += f"/{path}"
|
||||||
|
|
||||||
|
# Get query string
|
||||||
|
query_string = str(request.query_params)
|
||||||
|
if query_string:
|
||||||
|
target_url += f"?{query_string}"
|
||||||
|
|
||||||
|
# Forward headers (excluding host)
|
||||||
|
headers = dict(request.headers)
|
||||||
|
headers.pop("host", None)
|
||||||
|
headers.pop("cookie", None) # Don't forward session cookies
|
||||||
|
|
||||||
|
# Forward the request
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
body = await request.body()
|
||||||
|
response = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
headers=headers,
|
||||||
|
content=body,
|
||||||
|
follow_redirects=False,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Proxy error: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"failed to reach instance: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
response_headers = dict(response.headers)
|
||||||
|
# Remove hop-by-hop headers
|
||||||
|
for header in ["content-encoding", "transfer-encoding", "connection"]:
|
||||||
|
response_headers.pop(header, None)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=response_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
from fastapi import APIRouter as FastAPIRouter
|
from fastapi import APIRouter as FastAPIRouter
|
||||||
|
|
||||||
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
|
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
|
||||||
@@ -567,7 +1182,7 @@ async def get_user_sessions(
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ToolInstance)
|
select(ToolInstance)
|
||||||
.where(ToolInstance.owner_id == user_id)
|
.where(ToolInstance.owner_id == user_id)
|
||||||
.where(ToolInstance.status.in_(["running", "building", "pending"]))
|
.where(ToolInstance.status.in_(["running", "building", "pending", "stopped", "error"]))
|
||||||
.order_by(ToolInstance.created_at.desc())
|
.order_by(ToolInstance.created_at.desc())
|
||||||
)
|
)
|
||||||
instances = result.scalars().all()
|
instances = result.scalars().all()
|
||||||
@@ -583,8 +1198,11 @@ async def get_user_sessions(
|
|||||||
"display_name": instance.display_name,
|
"display_name": instance.display_name,
|
||||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||||
"tool_icon": tool_type.name if tool_type else "code",
|
"tool_icon": tool_type.name if tool_type else "code",
|
||||||
|
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
|
||||||
"repository_name": repo.name if repo else "unknown",
|
"repository_name": repo.name if repo else "unknown",
|
||||||
|
"repository_id": str(instance.repository_id),
|
||||||
"project_name": project.name if project else "unknown",
|
"project_name": project.name if project else "unknown",
|
||||||
|
"project_id": str(instance.project_id),
|
||||||
"status": instance.status,
|
"status": instance.status,
|
||||||
"url": instance.url,
|
"url": instance.url,
|
||||||
})
|
})
|
||||||
|
|||||||
+325
-28
@@ -3,7 +3,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
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
|
||||||
|
|
||||||
@@ -37,12 +37,33 @@ class ToolTypeCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
compose_template: str
|
default_port: int
|
||||||
|
definition_type: str = "compose"
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
required_variables: list[str] = []
|
required_variables: list[str] = []
|
||||||
|
category: str = "other"
|
||||||
|
interfaces: list[str] = ["web"]
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str) -> str:
|
||||||
|
if v not in ("compose", "dockerfile"):
|
||||||
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_compose_template(cls, v: str) -> str:
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if v is None:
|
||||||
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(v)
|
parsed = yaml.safe_load(v)
|
||||||
except yaml.YAMLError as e:
|
except yaml.YAMLError as e:
|
||||||
@@ -59,18 +80,79 @@ class ToolTypeCreate(BaseModel):
|
|||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("dockerfile_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "dockerfile":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if v is None:
|
||||||
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
|
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("default_port")
|
||||||
|
@classmethod
|
||||||
|
def validate_default_port(cls, v: int, info) -> int:
|
||||||
|
if v <= 0 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
|
||||||
|
# Get compose_template from the model data
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
template = data.get("compose_template")
|
||||||
|
if not template:
|
||||||
|
return v
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(template)
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return v
|
||||||
|
|
||||||
|
# Check if the port is exposed in any service
|
||||||
|
port_str = str(v)
|
||||||
|
port_exposed = False
|
||||||
|
|
||||||
|
if isinstance(parsed, dict) and "services" in parsed:
|
||||||
|
for service_name, service_config in parsed["services"].items():
|
||||||
|
if isinstance(service_config, dict) and "ports" in service_config:
|
||||||
|
for port_mapping in service_config["ports"]:
|
||||||
|
if isinstance(port_mapping, str):
|
||||||
|
# Format: "8443:8443" or "8443"
|
||||||
|
if port_str in port_mapping:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
elif isinstance(port_mapping, int) and port_mapping == v:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
if port_exposed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not port_exposed:
|
||||||
|
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("required_variables")
|
@field_validator("required_variables")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
||||||
if not v:
|
if not v:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
# Get compose_template from the model data
|
|
||||||
data = info.data
|
data = info.data
|
||||||
if "compose_template" not in data:
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
template = data.get("compose_template")
|
||||||
|
if not template:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
template = data["compose_template"]
|
|
||||||
for var in v:
|
for var in v:
|
||||||
placeholder = f"{{{{{var}}}}}"
|
placeholder = f"{{{{{var}}}}}"
|
||||||
if placeholder not in template:
|
if placeholder not in template:
|
||||||
@@ -78,19 +160,48 @@ class ToolTypeCreate(BaseModel):
|
|||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_templates(self) -> "ToolTypeCreate":
|
||||||
|
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
|
||||||
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
|
if self.definition_type == "compose" and self.compose_template is None:
|
||||||
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeUpdate(BaseModel):
|
class ToolTypeUpdate(BaseModel):
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
default_port: int | None = None
|
||||||
|
definition_type: str | None = None
|
||||||
compose_template: str | None = None
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
required_variables: list[str] | None = None
|
required_variables: list[str] | None = None
|
||||||
|
category: str | None = None
|
||||||
|
interfaces: list[str] | None = None
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v not in ("compose", "dockerfile"):
|
||||||
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_compose_template(cls, v: str | None) -> 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
|
||||||
|
definition_type = data.get("definition_type")
|
||||||
|
if definition_type and definition_type != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(v)
|
parsed = yaml.safe_load(v)
|
||||||
except yaml.YAMLError as e:
|
except yaml.YAMLError as e:
|
||||||
@@ -107,6 +218,22 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("dockerfile_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
data = info.data
|
||||||
|
definition_type = data.get("definition_type")
|
||||||
|
if definition_type and definition_type != "dockerfile":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeResponse(BaseModel):
|
class ToolTypeResponse(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -115,7 +242,14 @@ class ToolTypeResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
compose_template: str
|
category: str
|
||||||
|
interfaces: list[str]
|
||||||
|
default_port: int
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None
|
||||||
|
dockerfile_template: str | None
|
||||||
|
build_context: dict | None
|
||||||
|
readiness_probe: dict | None
|
||||||
required_variables: list[str]
|
required_variables: list[str]
|
||||||
is_builtin: bool
|
is_builtin: bool
|
||||||
created_by_id: uuid.UUID | None
|
created_by_id: uuid.UUID | None
|
||||||
@@ -157,8 +291,15 @@ async def create_tool_type(
|
|||||||
name=data.name,
|
name=data.name,
|
||||||
display_name=data.display_name,
|
display_name=data.display_name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
default_port=data.default_port,
|
||||||
|
definition_type=data.definition_type,
|
||||||
compose_template=data.compose_template,
|
compose_template=data.compose_template,
|
||||||
|
dockerfile_template=data.dockerfile_template,
|
||||||
|
build_context=data.build_context,
|
||||||
|
readiness_probe=data.readiness_probe,
|
||||||
required_variables=data.required_variables,
|
required_variables=data.required_variables,
|
||||||
|
category=data.category,
|
||||||
|
interfaces=data.interfaces,
|
||||||
is_builtin=False,
|
is_builtin=False,
|
||||||
created_by_id=user.id,
|
created_by_id=user.id,
|
||||||
)
|
)
|
||||||
@@ -255,26 +396,68 @@ async def update_tool_type(
|
|||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Validate required variables if both are being updated
|
# Validate port if being updated
|
||||||
if "required_variables" in update_data and "compose_template" in update_data:
|
if "default_port" in update_data:
|
||||||
template = update_data["compose_template"]
|
new_port = update_data["default_port"]
|
||||||
for var in update_data["required_variables"]:
|
if new_port <= 0 or new_port > 65535:
|
||||||
placeholder = f"{{{{{var}}}}}"
|
raise HTTPException(
|
||||||
if placeholder not in template:
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
raise HTTPException(
|
detail="Port must be between 1 and 65535"
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
)
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
|
||||||
)
|
# Only validate port exposure for compose definitions
|
||||||
elif "required_variables" in update_data:
|
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||||
# Only updating variables, check against existing template
|
if definition_type == "compose":
|
||||||
template = tool_type.compose_template
|
template = update_data.get("compose_template", tool_type.compose_template)
|
||||||
for var in update_data["required_variables"]:
|
if template:
|
||||||
placeholder = f"{{{{{var}}}}}"
|
try:
|
||||||
if placeholder not in template:
|
parsed = yaml.safe_load(template)
|
||||||
raise HTTPException(
|
except yaml.YAMLError:
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
parsed = None
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
|
||||||
)
|
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(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Port {new_port} is not exposed in the compose template"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate required variables for compose definitions
|
||||||
|
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||||
|
if definition_type == "compose":
|
||||||
|
if "required_variables" in update_data and "compose_template" in update_data:
|
||||||
|
template = update_data["compose_template"]
|
||||||
|
for var in update_data["required_variables"]:
|
||||||
|
placeholder = f"{{{{{var}}}}}"
|
||||||
|
if placeholder not in template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Required variable '{var}' not found in compose template"
|
||||||
|
)
|
||||||
|
elif "required_variables" in update_data:
|
||||||
|
template = tool_type.compose_template
|
||||||
|
if template:
|
||||||
|
for var in update_data["required_variables"]:
|
||||||
|
placeholder = f"{{{{{var}}}}}"
|
||||||
|
if placeholder not in template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Required variable '{var}' not found in compose template"
|
||||||
|
)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(tool_type, field, value)
|
setattr(tool_type, field, value)
|
||||||
@@ -284,6 +467,120 @@ async def update_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeValidateRequest(BaseModel):
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/validate",
|
||||||
|
summary="Validate tool type template",
|
||||||
|
description="Validate a compose template or dockerfile syntax before creating a tool type.",
|
||||||
|
)
|
||||||
|
async def validate_tool_type_template(
|
||||||
|
data: ToolTypeValidateRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Validate a tool type template syntax.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Validation request with definition type and template.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validation result with success status and any errors.
|
||||||
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if data.definition_type == "compose":
|
||||||
|
if not data.compose_template:
|
||||||
|
errors.append("Compose template is required")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(data.compose_template)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
errors.append("Compose template must be a YAML mapping")
|
||||||
|
elif "services" not in parsed:
|
||||||
|
errors.append("Compose template must contain 'services' key")
|
||||||
|
elif not parsed["services"]:
|
||||||
|
errors.append("Compose template must define at least one service")
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
errors.append(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
|
elif data.definition_type == "dockerfile":
|
||||||
|
if not data.dockerfile_template:
|
||||||
|
errors.append("Dockerfile template is required")
|
||||||
|
elif not data.dockerfile_template.strip().startswith("FROM"):
|
||||||
|
errors.append("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
else:
|
||||||
|
errors.append("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{tool_type_id}/validate",
|
||||||
|
summary="Validate tool type",
|
||||||
|
description="Validate the compose template or dockerfile syntax of a tool type.",
|
||||||
|
)
|
||||||
|
async def validate_tool_type(
|
||||||
|
tool_type_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Validate a tool type's template syntax.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_type_id: UUID of the tool type to validate.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validation result with success status and any errors.
|
||||||
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if tool_type.definition_type == "compose":
|
||||||
|
if not tool_type.compose_template:
|
||||||
|
errors.append("Compose template is empty")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(tool_type.compose_template)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
errors.append("Compose template must be a YAML mapping")
|
||||||
|
elif "services" not in parsed:
|
||||||
|
errors.append("Compose template must contain 'services' key")
|
||||||
|
elif not parsed["services"]:
|
||||||
|
errors.append("Compose template must define at least one service")
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
errors.append(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
|
elif tool_type.definition_type == "dockerfile":
|
||||||
|
if not tool_type.dockerfile_template:
|
||||||
|
errors.append("Dockerfile template is empty")
|
||||||
|
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
|
||||||
|
errors.append("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{tool_type_id}",
|
"/{tool_type_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
|||||||
@@ -110,9 +110,10 @@ async def update_user_config(
|
|||||||
config = await _get_or_create_config(session, user_id)
|
config = await _get_or_create_config(session, user_id)
|
||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True, exclude_none=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
logger.info("Updating user config for user %s: %s", user_id, update_data)
|
logger.info("Updating user config for user %s: %s", user_id, update_data)
|
||||||
config.config.update(update_data)
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Repository storage
|
# Repository storage
|
||||||
repo_base_path: str = "/data/repos"
|
repo_base_path: str = "/data/repos"
|
||||||
|
|
||||||
|
# Tool instance storage
|
||||||
|
instance_base_path: str = "/data/instances"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
|
||||||
|
|
||||||
|
|||||||
+105
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -15,6 +16,9 @@ from src.api.health import router as health_router
|
|||||||
from src.api.projects import router as projects_router
|
from src.api.projects import router as projects_router
|
||||||
from src.api.ssh_keys import router as ssh_keys_router
|
from src.api.ssh_keys import router as ssh_keys_router
|
||||||
from src.api.terminal import router as terminal_router
|
from src.api.terminal import router as terminal_router
|
||||||
|
from src.api.instance_proxy import router as instance_proxy_router
|
||||||
|
from src.api.config_folders import router as config_folders_router
|
||||||
|
from src.api.tool_configs import router as tool_configs_router
|
||||||
from src.api.tool_instances import router as tool_instances_router
|
from src.api.tool_instances import router as tool_instances_router
|
||||||
from src.api.tool_instances import sessions_router
|
from src.api.tool_instances import sessions_router
|
||||||
from src.api.tool_types import router as tool_types_router
|
from src.api.tool_types import router as tool_types_router
|
||||||
@@ -38,9 +42,15 @@ settings = Settings()
|
|||||||
app = FastAPI(title="Headquarter API")
|
app = FastAPI(title="Headquarter API")
|
||||||
|
|
||||||
# Configure CORS - must be before other middleware
|
# Configure CORS - must be before other middleware
|
||||||
|
# Build allowed origins list including web and api domains
|
||||||
|
cors_origins = [settings.web_base_url]
|
||||||
|
if settings.api_base_url != settings.web_base_url:
|
||||||
|
cors_origins.append(settings.api_base_url)
|
||||||
|
logger.info("CORS configured with origins: %s", cors_origins)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[settings.web_base_url],
|
allow_origins=cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -50,6 +60,32 @@ app.add_middleware(RequestLoggingMiddleware)
|
|||||||
app.add_middleware(ExceptionLoggingMiddleware)
|
app.add_middleware(ExceptionLoggingMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_validation_errors(errors):
|
||||||
|
"""Convert validation errors to JSON-safe format."""
|
||||||
|
sanitized = []
|
||||||
|
for error in errors:
|
||||||
|
safe_error = {
|
||||||
|
"type": error.get("type"),
|
||||||
|
"loc": error.get("loc"),
|
||||||
|
"msg": error.get("msg"),
|
||||||
|
"input": str(error.get("input")) if error.get("input") is not None else None,
|
||||||
|
}
|
||||||
|
# Convert ctx to safe format
|
||||||
|
ctx = error.get("ctx")
|
||||||
|
if ctx:
|
||||||
|
safe_ctx = {}
|
||||||
|
for key, value in ctx.items():
|
||||||
|
if isinstance(value, Exception):
|
||||||
|
safe_ctx[key] = str(value)
|
||||||
|
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||||
|
safe_ctx[key] = value
|
||||||
|
else:
|
||||||
|
safe_ctx[key] = str(value)
|
||||||
|
safe_error["ctx"] = safe_ctx
|
||||||
|
sanitized.append(safe_error)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
"""Log validation errors and return detailed response."""
|
"""Log validation errors and return detailed response."""
|
||||||
@@ -60,9 +96,10 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|||||||
request.url.path,
|
request.url.path,
|
||||||
errors,
|
errors,
|
||||||
)
|
)
|
||||||
|
safe_errors = _sanitize_validation_errors(errors)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
content={"detail": errors},
|
content={"detail": safe_errors},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -99,6 +136,8 @@ async def seed_builtin_tool_types():
|
|||||||
"name": "code-server",
|
"name": "code-server",
|
||||||
"display_name": "VS Code Server",
|
"display_name": "VS Code Server",
|
||||||
"description": "VS Code running in the browser via code-server",
|
"description": "VS Code running in the browser via code-server",
|
||||||
|
"category": "editor",
|
||||||
|
"interfaces": ["web"],
|
||||||
"compose_template": """version: "3.8"
|
"compose_template": """version: "3.8"
|
||||||
services:
|
services:
|
||||||
code-server:
|
code-server:
|
||||||
@@ -113,12 +152,16 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8443:8443"
|
- "8443:8443"
|
||||||
restart: unless-stopped""",
|
restart: unless-stopped""",
|
||||||
|
"default_port": 8443,
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "jupyter-notebook",
|
"name": "jupyter-notebook",
|
||||||
"display_name": "Jupyter Notebook",
|
"display_name": "Jupyter Notebook",
|
||||||
"description": "Jupyter Lab for interactive development",
|
"description": "Jupyter Lab for interactive development",
|
||||||
|
"category": "notebook",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"default_port": 8888,
|
||||||
"compose_template": """version: "3.8"
|
"compose_template": """version: "3.8"
|
||||||
services:
|
services:
|
||||||
jupyter:
|
jupyter:
|
||||||
@@ -133,6 +176,47 @@ services:
|
|||||||
restart: unless-stopped""",
|
restart: unless-stopped""",
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "opencode",
|
||||||
|
"display_name": "OpenCode",
|
||||||
|
"description": "AI coding assistant - run opencode in terminal",
|
||||||
|
"category": "ai-assistant",
|
||||||
|
"interfaces": ["terminal"],
|
||||||
|
"default_port": 3000,
|
||||||
|
"compose_template": """version: "3.8"
|
||||||
|
services:
|
||||||
|
opencode:
|
||||||
|
image: node:20-slim
|
||||||
|
container_name: {{TOOL_NAME}}
|
||||||
|
working_dir: /workspace
|
||||||
|
environment:
|
||||||
|
- HOME=/tmp
|
||||||
|
volumes:
|
||||||
|
- {{REPO_PATH}}:/workspace
|
||||||
|
- opencode_home:/tmp
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
command: >
|
||||||
|
sh -c "set -x &&
|
||||||
|
apt-get update && apt-get install -y git ca-certificates &&
|
||||||
|
echo 'Installing opencode...' &&
|
||||||
|
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
||||||
|
which opencode || echo 'ERROR: opencode not in PATH' &&
|
||||||
|
npm bin -g &&
|
||||||
|
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
||||||
|
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
|
||||||
|
echo 'cd /workspace' >> /root/.bashrc &&
|
||||||
|
echo 'OpenCode installation complete' &&
|
||||||
|
cd /workspace &&
|
||||||
|
exec tail -f /dev/null"
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
opencode_home:""",
|
||||||
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool_data in builtin_types:
|
for tool_data in builtin_types:
|
||||||
@@ -142,11 +226,27 @@ services:
|
|||||||
name=tool_data["name"],
|
name=tool_data["name"],
|
||||||
display_name=tool_data["display_name"],
|
display_name=tool_data["display_name"],
|
||||||
description=tool_data["description"],
|
description=tool_data["description"],
|
||||||
|
category=tool_data["category"],
|
||||||
|
interfaces=tool_data["interfaces"],
|
||||||
|
definition_type="compose",
|
||||||
compose_template=tool_data["compose_template"],
|
compose_template=tool_data["compose_template"],
|
||||||
required_variables=tool_data["required_variables"],
|
required_variables=tool_data["required_variables"],
|
||||||
|
default_port=tool_data.get("default_port"),
|
||||||
is_builtin=True,
|
is_builtin=True,
|
||||||
)
|
)
|
||||||
session.add(tool_type)
|
session.add(tool_type)
|
||||||
|
logger.info("Created built-in tool type: %s", tool_data["name"])
|
||||||
|
else:
|
||||||
|
# Update existing built-in tool types to reflect code changes
|
||||||
|
existing.display_name = tool_data["display_name"]
|
||||||
|
existing.description = tool_data["description"]
|
||||||
|
existing.category = tool_data["category"]
|
||||||
|
existing.interfaces = tool_data["interfaces"]
|
||||||
|
existing.definition_type = "compose"
|
||||||
|
existing.compose_template = tool_data["compose_template"]
|
||||||
|
existing.required_variables = tool_data["required_variables"]
|
||||||
|
existing.default_port = tool_data.get("default_port")
|
||||||
|
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info("Built-in tool types seeded successfully.")
|
logger.info("Built-in tool types seeded successfully.")
|
||||||
@@ -176,7 +276,10 @@ 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(config_folders_router)
|
||||||
app.include_router(tool_instances_router)
|
app.include_router(tool_instances_router)
|
||||||
|
app.include_router(tool_configs_router)
|
||||||
app.include_router(sessions_router)
|
app.include_router(sessions_router)
|
||||||
|
app.include_router(instance_proxy_router)
|
||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
|
from src.models.config_folder import ConfigFolder
|
||||||
|
from src.models.config_include import ConfigInclude
|
||||||
|
from src.models.config_mount import ConfigMount
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
@@ -7,4 +11,17 @@ 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", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"ConfigFolder",
|
||||||
|
"ConfigInclude",
|
||||||
|
"ConfigMount",
|
||||||
|
"ConfigProfile",
|
||||||
|
"GitRepository",
|
||||||
|
"Project",
|
||||||
|
"SSHKey",
|
||||||
|
"ToolInstance",
|
||||||
|
"ToolType",
|
||||||
|
"User",
|
||||||
|
"UserConfig",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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 ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_folders"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
files: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"relative/path": "content", ...}
|
||||||
|
project_overrides: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=True
|
||||||
|
) # {"project_id": {"mount_path": "...", "files": {...}}}
|
||||||
|
# DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time
|
||||||
|
# auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead.
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
|
||||||
|
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.config_profile import ConfigProfile
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_includes"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="includes",
|
||||||
|
)
|
||||||
|
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[included_profile_id],
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, 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.config_profile import ConfigProfile
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_mounts"
|
||||||
|
|
||||||
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="mounts",
|
||||||
|
)
|
||||||
|
source_profile: Mapped["ConfigProfile | None"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[source_profile_id],
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
|
||||||
|
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 ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_profiles"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship()
|
||||||
|
includes: Mapped[list["ConfigInclude"]] = relationship(
|
||||||
|
"ConfigInclude",
|
||||||
|
foreign_keys="ConfigInclude.profile_id",
|
||||||
|
back_populates="profile",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ConfigInclude.order_index",
|
||||||
|
)
|
||||||
|
mounts: Mapped[list["ConfigMount"]] = relationship(
|
||||||
|
"ConfigMount",
|
||||||
|
back_populates="profile",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ConfigMount.order_index",
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import 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.project import Project
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "tool_configs"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
tool_type_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("tool_types.id"), nullable=False
|
||||||
|
)
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("projects.id"), nullable=True
|
||||||
|
)
|
||||||
|
key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
config_type: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="env"
|
||||||
|
) # "env" or "file"
|
||||||
|
file_path: Mapped[str | None] = mapped_column(
|
||||||
|
String(1024), nullable=True
|
||||||
|
) # Only for file type
|
||||||
|
port_override: Mapped[int | None] = mapped_column(nullable=True)
|
||||||
|
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
environment_variables: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=True
|
||||||
|
)
|
||||||
|
volumes: Mapped[list[dict] | None] = mapped_column(
|
||||||
|
JSON, default=list, nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship()
|
||||||
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
|
project: Mapped["Project | None"] = relationship()
|
||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
@@ -38,12 +39,21 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
container_id: Mapped[str | None] = mapped_column(
|
container_id: Mapped[str | None] = mapped_column(
|
||||||
String(255), nullable=True
|
String(255), nullable=True
|
||||||
)
|
)
|
||||||
|
container_name: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True
|
||||||
|
)
|
||||||
compose_path: Mapped[str | None] = mapped_column(
|
compose_path: Mapped[str | None] = mapped_column(
|
||||||
String(1024), nullable=True
|
String(1024), nullable=True
|
||||||
)
|
)
|
||||||
url: Mapped[str | None] = mapped_column(
|
url: Mapped[str | None] = mapped_column(
|
||||||
String(1024), nullable=True
|
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(
|
port: Mapped[int | None] = mapped_column(
|
||||||
Integer, nullable=True
|
Integer, nullable=True
|
||||||
)
|
)
|
||||||
@@ -53,8 +63,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
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
|
||||||
)
|
)
|
||||||
|
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
project: Mapped["Project"] = relationship()
|
project: Mapped["Project"] = relationship()
|
||||||
owner: Mapped["User"] = relationship()
|
owner: Mapped["User"] = relationship()
|
||||||
|
selected_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||||
|
|||||||
@@ -17,7 +17,18 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
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)
|
||||||
compose_template: Mapped[str] = mapped_column(Text, nullable=False)
|
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||||
|
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||||
|
definition_type: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="compose"
|
||||||
|
) # "compose" or "dockerfile"
|
||||||
|
compose_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(
|
||||||
|
JSON, default=dict, nullable=True
|
||||||
|
)
|
||||||
|
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
|||||||
@@ -18,3 +18,23 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="user_config")
|
user: Mapped["User"] = relationship(back_populates="user_config")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_profile_id(self) -> uuid.UUID | None:
|
||||||
|
profile_id = self.config.get("default_profile_id")
|
||||||
|
return uuid.UUID(profile_id) if profile_id else None
|
||||||
|
|
||||||
|
@default_profile_id.setter
|
||||||
|
def default_profile_id(self, value: uuid.UUID | None) -> None:
|
||||||
|
if value is not None:
|
||||||
|
self.config["default_profile_id"] = str(value)
|
||||||
|
elif "default_profile_id" in self.config:
|
||||||
|
del self.config["default_profile_id"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_profiles(self) -> dict[str, str]:
|
||||||
|
return self.config.get("default_profiles", {})
|
||||||
|
|
||||||
|
@default_profiles.setter
|
||||||
|
def default_profiles(self, value: dict[str, str]) -> None:
|
||||||
|
self.config["default_profiles"] = value
|
||||||
|
|||||||
@@ -23,16 +23,19 @@ def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def ensure_instance_directory(instance_id: str, base_path: str = "data/instances") -> str:
|
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
||||||
"""Create and return the instance directory path.
|
"""Create and return the instance directory path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_id: Unique instance identifier
|
instance_id: Unique instance identifier
|
||||||
base_path: Base directory for all instances
|
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Absolute path to instance directory
|
Absolute path to instance directory
|
||||||
"""
|
"""
|
||||||
|
if base_path is None:
|
||||||
|
from src.config import Settings
|
||||||
|
base_path = Settings().instance_base_path
|
||||||
instance_dir = Path(base_path) / instance_id
|
instance_dir = Path(base_path) / instance_id
|
||||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return str(instance_dir.absolute())
|
return str(instance_dir.absolute())
|
||||||
@@ -53,8 +56,97 @@ def write_compose_file(instance_dir: str, content: str) -> str:
|
|||||||
return str(compose_path)
|
return str(compose_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||||
|
"""Write environment variables to a .env file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
env_vars: Dictionary of env var names to values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the env file
|
||||||
|
"""
|
||||||
|
env_path = Path(instance_dir) / ".env"
|
||||||
|
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||||
|
env_path.write_text("\n".join(lines) + "\n")
|
||||||
|
return str(env_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||||
|
"""Write config files to the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
files: Dictionary of file paths (relative to instance dir) to content
|
||||||
|
"""
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
for file_path, content in files.items():
|
||||||
|
# Ensure the path is within the instance directory (security)
|
||||||
|
full_path = instance_path / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||||
|
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
||||||
|
"""Write config folder files to the instance directory and return volume mounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
folders: List of ConfigFolder objects
|
||||||
|
project_id: Optional project ID for applying overrides
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
||||||
|
"""
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
volume_mounts = []
|
||||||
|
|
||||||
|
for folder in folders:
|
||||||
|
# Determine mount path (with project override if applicable)
|
||||||
|
mount_path = folder.mount_path
|
||||||
|
files = folder.files.copy()
|
||||||
|
|
||||||
|
if project_id and folder.project_overrides:
|
||||||
|
override = folder.project_overrides.get(str(project_id))
|
||||||
|
if override:
|
||||||
|
if override.get("mount_path"):
|
||||||
|
mount_path = override["mount_path"]
|
||||||
|
if override.get("files"):
|
||||||
|
files.update(override["files"])
|
||||||
|
|
||||||
|
# Write files to instance directory
|
||||||
|
folder_dir = instance_path / "volumes" / folder.name
|
||||||
|
folder_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for file_path, content in files.items():
|
||||||
|
# Security: ensure path doesn't escape folder_dir
|
||||||
|
full_path = folder_dir / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(folder_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Config folder file path escapes directory: %s", file_path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
# Add volume mount
|
||||||
|
volume_mounts.append({
|
||||||
|
"source": str(folder_dir),
|
||||||
|
"target": mount_path,
|
||||||
|
"type": "bind",
|
||||||
|
})
|
||||||
|
|
||||||
|
return volume_mounts
|
||||||
|
|
||||||
|
|
||||||
def execute_compose_command(
|
def execute_compose_command(
|
||||||
compose_path: str, action: str, timeout: int = 60
|
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||||
) -> tuple[int, str, str]:
|
) -> tuple[int, str, str]:
|
||||||
"""Execute a docker compose command.
|
"""Execute a docker compose command.
|
||||||
|
|
||||||
@@ -62,6 +154,7 @@ def execute_compose_command(
|
|||||||
compose_path: Path to docker-compose.yml
|
compose_path: Path to docker-compose.yml
|
||||||
action: The compose action (up, down, start, stop, restart)
|
action: The compose action (up, down, start, stop, restart)
|
||||||
timeout: Command timeout in seconds
|
timeout: Command timeout in seconds
|
||||||
|
env_file: Optional path to .env file for environment variables
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (returncode, stdout, stderr)
|
Tuple of (returncode, stdout, stderr)
|
||||||
@@ -69,6 +162,9 @@ 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:
|
||||||
|
cmd.extend(["--env-file", env_file])
|
||||||
|
|
||||||
if action == "up":
|
if action == "up":
|
||||||
cmd.extend(["up", "-d"])
|
cmd.extend(["up", "-d"])
|
||||||
@@ -110,6 +206,44 @@ def get_container_id(instance_name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_name(instance_name: str) -> str | None:
|
||||||
|
"""Get the full container name for a compose service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The service name in compose
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container name or None if not found
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip().split("\n")[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
||||||
|
"""Connect a Docker container to an existing network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name or ID of the container
|
||||||
|
network_name: Name of the Docker network (default: backend)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "network", "connect", network_name, container_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def get_container_status(container_id: str) -> str:
|
def get_container_status(container_id: str) -> str:
|
||||||
"""Get the status of a Docker container.
|
"""Get the status of a Docker container.
|
||||||
|
|
||||||
@@ -169,3 +303,154 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
|||||||
return port
|
return port
|
||||||
|
|
||||||
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(
|
||||||
|
container_name: str, port: int, timeout: int = 30
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Start a temporary Cloudflare tunnel for a container.
|
||||||
|
|
||||||
|
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
||||||
|
with a random trycloudflare.com URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name of the Docker container to tunnel to
|
||||||
|
port: Port number the container listens on
|
||||||
|
timeout: Maximum seconds to wait for tunnel URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# First verify the container is accessible
|
||||||
|
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||||
|
for attempt in range(10):
|
||||||
|
check = subprocess.run(
|
||||||
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
f"http://{container_name}:{port}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
||||||
|
if check.returncode == 0:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
||||||
|
|
||||||
|
# Run cloudflared in background, capture output
|
||||||
|
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for the URL to appear in output
|
||||||
|
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||||
|
start_time = time.time()
|
||||||
|
url = None
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
# Read available output
|
||||||
|
import select
|
||||||
|
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||||
|
if readable:
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if line:
|
||||||
|
match = url_pattern.search(line)
|
||||||
|
if match:
|
||||||
|
url = match.group(0)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to get tunnel URL within {timeout}s. "
|
||||||
|
f"cloudflared output may contain errors."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"url": url, "pid": str(proc.pid)}
|
||||||
|
|
||||||
|
|
||||||
|
def stop_cloudflared_tunnel(pid: str) -> None:
|
||||||
|
"""Stop a cloudflared tunnel process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid: Process ID of the cloudflared tunnel
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.kill(int(pid), signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass # Already stopped
|
||||||
|
|
||||||
|
|
||||||
|
def recreate_tunnel(
|
||||||
|
container_name: str, port: int, old_pid: str | None = None
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Recreate a temporary Cloudflare tunnel.
|
||||||
|
|
||||||
|
Stops the old tunnel (if pid provided) and starts a new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name of the Docker container to tunnel to
|
||||||
|
port: Port number the container listens on
|
||||||
|
old_pid: Optional PID of the old tunnel process to stop
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' and 'pid' for the new tunnel
|
||||||
|
"""
|
||||||
|
if old_pid:
|
||||||
|
stop_cloudflared_tunnel(old_pid)
|
||||||
|
|
||||||
|
return start_cloudflared_tunnel(container_name, port)
|
||||||
|
|
||||||
|
|
||||||
|
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||||
|
"""Check if a tunnel URL is healthy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The tunnel URL to check
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
"--max-time", str(timeout), url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout + 5,
|
||||||
|
)
|
||||||
|
status_code = int(result.stdout.strip())
|
||||||
|
return {
|
||||||
|
"healthy": 200 <= status_code < 400,
|
||||||
|
"status_code": status_code,
|
||||||
|
}
|
||||||
|
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"status_code": None,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Docker build service for building images from Dockerfiles."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dict | None = None) -> tuple[int, str, str]:
|
||||||
|
"""Build a Docker image from a Dockerfile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Directory containing the Dockerfile
|
||||||
|
dockerfile: Dockerfile content
|
||||||
|
tag: Image tag to apply
|
||||||
|
build_context: Optional build context files {path: content}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (returncode, stdout, stderr)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Write Dockerfile
|
||||||
|
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||||
|
dockerfile_path.write_text(dockerfile)
|
||||||
|
logger.info("Wrote Dockerfile to %s", dockerfile_path)
|
||||||
|
|
||||||
|
# Write build context files
|
||||||
|
if build_context:
|
||||||
|
for file_path, content in build_context.items():
|
||||||
|
full_path = Path(instance_dir) / file_path
|
||||||
|
# Security: ensure path doesn't escape instance_dir
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(Path(instance_dir).resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.error("Build context file path escapes instance directory: %s", file_path)
|
||||||
|
raise ValueError(f"Build context file path '{file_path}' escapes instance directory")
|
||||||
|
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
logger.info("Wrote build context file: %s", full_path)
|
||||||
|
|
||||||
|
# Build image
|
||||||
|
logger.info("Building Docker image with tag: %s", tag)
|
||||||
|
cmd = [
|
||||||
|
"docker", "build",
|
||||||
|
"-t", tag,
|
||||||
|
"-f", str(dockerfile_path),
|
||||||
|
instance_dir,
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300, # 5 minute timeout for builds
|
||||||
|
)
|
||||||
|
logger.info("Docker build completed: returncode=%d", result.returncode)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Docker build timed out after 300 seconds")
|
||||||
|
return 1, "", "Build timed out after 300 seconds"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Docker build failed: %s", exc)
|
||||||
|
return 1, "", str(exc)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Readiness probe service for checking if containers are ready."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_probe(
|
||||||
|
container_id: str,
|
||||||
|
command: str,
|
||||||
|
timeout: int = 30,
|
||||||
|
interval: int = 2,
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Execute a readiness probe command inside a container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name
|
||||||
|
command: Command to execute inside the container
|
||||||
|
timeout: Maximum total time to wait (seconds)
|
||||||
|
interval: Time between retries (seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, logs)
|
||||||
|
"""
|
||||||
|
logs = []
|
||||||
|
start_time = asyncio.get_event_loop().time()
|
||||||
|
attempt = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
|
elapsed = asyncio.get_event_loop().time() - start_time
|
||||||
|
|
||||||
|
if elapsed >= timeout:
|
||||||
|
logs.append(f"Probe timed out after {timeout}s ({attempt} attempts)")
|
||||||
|
return False, logs
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug("Probe attempt %d: %s", attempt, command)
|
||||||
|
|
||||||
|
# Execute command inside container
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "exec", container_id, "sh", "-c", command],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=interval, # Each attempt has its own timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
logs.append(f"Attempt {attempt}: Success")
|
||||||
|
if result.stdout:
|
||||||
|
logs.append(f"Output: {result.stdout.strip()}")
|
||||||
|
return True, logs
|
||||||
|
else:
|
||||||
|
logs.append(f"Attempt {attempt}: Failed (exit code {result.returncode})")
|
||||||
|
if result.stderr:
|
||||||
|
logs.append(f"Stderr: {result.stderr.strip()[:200]}")
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logs.append(f"Attempt {attempt}: Command timed out")
|
||||||
|
except Exception as exc:
|
||||||
|
logs.append(f"Attempt {attempt}: Error - {exc}")
|
||||||
|
|
||||||
|
# Wait before next attempt
|
||||||
|
await asyncio.sleep(interval)
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
"""Terminal session management for tool instances."""
|
"""Terminal session management for tool instances."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import select
|
||||||
|
import struct
|
||||||
|
import fcntl
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -14,61 +19,76 @@ class TerminalSession:
|
|||||||
self.container_id = container_id
|
self.container_id = container_id
|
||||||
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._slave_fd: int | None = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the docker exec process with a shell."""
|
"""Start the docker exec process with a shell using a PTY."""
|
||||||
|
# Create a pseudo-terminal on the host
|
||||||
|
self._master_fd, self._slave_fd = pty.openpty()
|
||||||
|
|
||||||
|
# Set the terminal size initially
|
||||||
|
self._set_terminal_size(80, 24)
|
||||||
|
|
||||||
|
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||||
|
# Using -it because the slave fd IS a TTY
|
||||||
self.process = await asyncio.create_subprocess_exec(
|
self.process = await asyncio.create_subprocess_exec(
|
||||||
"docker",
|
"docker",
|
||||||
"exec",
|
"exec",
|
||||||
"-i",
|
"-it",
|
||||||
|
"-e",
|
||||||
|
"TERM=xterm",
|
||||||
self.container_id,
|
self.container_id,
|
||||||
"/bin/sh",
|
"bash",
|
||||||
"-c",
|
"-il",
|
||||||
"exec bash -l || exec sh -l",
|
stdin=self._slave_fd,
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdout=self._slave_fd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stderr=self._slave_fd,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Close slave fd in parent process
|
||||||
|
os.close(self._slave_fd)
|
||||||
|
self._slave_fd = None
|
||||||
|
|
||||||
|
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||||
|
"""Set the terminal size using TIOCSWINSZ."""
|
||||||
|
if self._master_fd is None:
|
||||||
|
return
|
||||||
|
# TIOCSWINSZ = 0x5414 on Linux
|
||||||
|
TIOCSWINSZ = 0x5414
|
||||||
|
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||||
|
try:
|
||||||
|
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||||
|
except (OSError, IOError):
|
||||||
|
pass
|
||||||
|
|
||||||
async def read_output(self) -> bytes:
|
async def read_output(self) -> bytes:
|
||||||
"""Read output from the process."""
|
"""Read output from the PTY master."""
|
||||||
if self.process is None or self.process.stdout is None:
|
if self._master_fd is None or self._closed:
|
||||||
return b""
|
return b""
|
||||||
try:
|
try:
|
||||||
return await self.process.stdout.read(4096)
|
# Use select to check if data is available
|
||||||
except (asyncio.CancelledError, BrokenPipeError):
|
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
||||||
|
if readable:
|
||||||
|
return os.read(self._master_fd, 4096)
|
||||||
|
return b""
|
||||||
|
except (OSError, IOError, ValueError):
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
async def write_input(self, data: bytes) -> None:
|
async def write_input(self, data: bytes) -> None:
|
||||||
"""Write input to the process."""
|
"""Write input to the PTY master."""
|
||||||
if self.process is None or self.process.stdin is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self.process.stdin.write(data)
|
os.write(self._master_fd, data)
|
||||||
await self.process.stdin.drain()
|
except (OSError, IOError):
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def resize(self, cols: int, rows: int) -> None:
|
async def resize(self, cols: int, rows: int) -> None:
|
||||||
"""Resize the terminal."""
|
"""Resize the terminal."""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
try:
|
self._set_terminal_size(cols, rows)
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
"docker",
|
|
||||||
"exec",
|
|
||||||
self.container_id,
|
|
||||||
"stty",
|
|
||||||
"cols",
|
|
||||||
str(cols),
|
|
||||||
"rows",
|
|
||||||
str(rows),
|
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
await proc.wait()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Close the session and cleanup."""
|
"""Close the session and cleanup."""
|
||||||
@@ -76,6 +96,13 @@ class TerminalSession:
|
|||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
|
|
||||||
|
if self._master_fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(self._master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._master_fd = None
|
||||||
|
|
||||||
if self.process is not None:
|
if self.process is not None:
|
||||||
try:
|
try:
|
||||||
self.process.kill()
|
self.process.kill()
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ def get_status(repo_path: str) -> GitStatus:
|
|||||||
try:
|
try:
|
||||||
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
branch = "HEAD"
|
try:
|
||||||
|
branch = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||||
|
except RuntimeError:
|
||||||
|
branch = "HEAD"
|
||||||
|
|
||||||
status = GitStatus(branch=branch)
|
status = GitStatus(branch=branch)
|
||||||
|
|
||||||
@@ -118,6 +121,12 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If branch creation fails
|
RuntimeError: If branch creation fails
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
||||||
|
except RuntimeError:
|
||||||
|
_run_git_command(repo_path, "checkout", "--orphan", name)
|
||||||
|
return
|
||||||
|
|
||||||
_run_git_command(repo_path, "branch", name, base_branch)
|
_run_git_command(repo_path, "branch", name, base_branch)
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +224,8 @@ def pull(repo_path: str, branch: str | None = None) -> None:
|
|||||||
"""
|
"""
|
||||||
args = ["pull"]
|
args = ["pull"]
|
||||||
if branch:
|
if branch:
|
||||||
args.extend(["origin", branch])
|
args.append("origin")
|
||||||
|
args.append(branch)
|
||||||
_run_git_command(repo_path, *args)
|
_run_git_command(repo_path, *args)
|
||||||
|
|
||||||
|
|
||||||
@@ -279,4 +289,7 @@ def get_current_branch(repo_path: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Current branch name
|
Current branch name
|
||||||
"""
|
"""
|
||||||
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
try:
|
||||||
|
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||||
|
except RuntimeError:
|
||||||
|
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Git file utilities for browsing repository contents."""
|
"""Git file utilities for browsing repository contents."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -50,10 +51,31 @@ def _run_git_command(repo_path: str, *args: str) -> str:
|
|||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(f"Git command failed: {result.stderr}")
|
stderr = result.stderr
|
||||||
|
# Handle "dubious ownership" security error
|
||||||
|
if "dubious ownership" in stderr.lower():
|
||||||
|
logger.warning("Git ownership mismatch for %s, adding to safe.directory", repo_path)
|
||||||
|
# Add this directory to git's safe.directory list
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "--global", "--add", "safe.directory", repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
# Retry the command
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=repo_path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout
|
||||||
|
stderr = result.stderr
|
||||||
|
raise RuntimeError(f"Git command failed: {stderr}")
|
||||||
return result.stdout
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def list_tree(repo_path: str, branch: str = "main", path: str = "") -> list[FileTreeEntry]:
|
def list_tree(repo_path: str, branch: str = "main", path: str = "") -> list[FileTreeEntry]:
|
||||||
"""List files and directories in a repository path.
|
"""List files and directories in a repository path.
|
||||||
|
|
||||||
@@ -69,10 +91,20 @@ def list_tree(repo_path: str, branch: str = "main", path: str = "") -> list[File
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
|
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
|
||||||
except RuntimeError:
|
except RuntimeError as e:
|
||||||
|
logger.warning("git ls-tree failed for %s with branch '%s': %s", repo_path, tree_path, str(e))
|
||||||
# Try with HEAD if branch doesn't exist
|
# Try with HEAD if branch doesn't exist
|
||||||
tree_path = f"HEAD:{path}" if path else "HEAD"
|
tree_path = f"HEAD:{path}" if path else "HEAD"
|
||||||
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
|
try:
|
||||||
|
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error("git ls-tree failed for %s with HEAD: %s", repo_path, str(e))
|
||||||
|
# Check if this is an empty repository (no commits yet)
|
||||||
|
error_msg = str(e).lower()
|
||||||
|
if "not a valid object name" in error_msg or "does not exist" in error_msg:
|
||||||
|
# Empty repository - return empty list
|
||||||
|
return []
|
||||||
|
raise
|
||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
for line in output.strip().split("\n"):
|
for line in output.strip().split("\n"):
|
||||||
@@ -248,7 +280,11 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
|
|||||||
Tuple of (list of BranchInfo, default branch name)
|
Tuple of (list of BranchInfo, default branch name)
|
||||||
"""
|
"""
|
||||||
# Get all branches
|
# Get all branches
|
||||||
output = _run_git_command(repo_path, "branch", "-a", "--format=%(refname:short)")
|
try:
|
||||||
|
output = _run_git_command(repo_path, "branch", "-a", "--format=%(refname:short)")
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error("Failed to list branches for %s: %s", repo_path, str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
branches: list[BranchInfo] = []
|
branches: list[BranchInfo] = []
|
||||||
default_branch = "main"
|
default_branch = "main"
|
||||||
@@ -310,7 +346,20 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
|
|||||||
)
|
)
|
||||||
default_branch = branch_name
|
default_branch = branch_name
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
try:
|
||||||
|
output = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD")
|
||||||
|
branch_name = output.strip()
|
||||||
|
if branch_name:
|
||||||
|
branches.append(
|
||||||
|
BranchInfo(
|
||||||
|
name=branch_name,
|
||||||
|
is_default=True,
|
||||||
|
last_commit=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
default_branch = branch_name
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
return branches, default_branch
|
return branches, default_branch
|
||||||
|
|
||||||
|
|||||||
+131
-70
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from typing import AsyncGenerator, Generator
|
from typing import AsyncGenerator, Generator
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
@@ -11,82 +12,142 @@ 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
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
# Set test environment BEFORE importing app modules
|
||||||
|
os.environ["APP_ENV"] = "testing"
|
||||||
|
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||||
|
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings, build_database_url
|
||||||
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
|
||||||
|
|
||||||
# Unit test fixtures (SQLite in-memory)
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def sqlite_engine():
|
|
||||||
"""Create a SQLite in-memory engine for unit tests."""
|
|
||||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
yield engine
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sqlite_session(sqlite_engine) -> Generator:
|
|
||||||
"""Provide a SQLite session for unit tests."""
|
|
||||||
connection = sqlite_engine.connect()
|
|
||||||
transaction = connection.begin()
|
|
||||||
session = sessionmaker(bind=connection)()
|
|
||||||
|
|
||||||
yield session
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
transaction.rollback()
|
|
||||||
connection.close()
|
|
||||||
|
|
||||||
|
|
||||||
# Integration test fixtures (PostgreSQL)
|
|
||||||
|
|
||||||
TEST_DATABASE_URL = build_database_url(
|
|
||||||
user="headquarter",
|
|
||||||
password="headquarter",
|
|
||||||
host="localhost",
|
|
||||||
port=5432,
|
|
||||||
database="headquarter",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="session")
|
|
||||||
async def postgres_engine():
|
|
||||||
"""Create a PostgreSQL engine for integration tests."""
|
|
||||||
engine = create_async_engine(TEST_DATABASE_URL)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
yield engine
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def db_session(postgres_engine) -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
"""Provide a database session with transaction rollback."""
|
|
||||||
async with postgres_engine.connect() as connection:
|
|
||||||
transaction = await connection.begin_nested()
|
|
||||||
session_factory = async_sessionmaker(
|
|
||||||
connection, expire_on_commit=False, class_=AsyncSession
|
|
||||||
)
|
|
||||||
session = session_factory()
|
|
||||||
|
|
||||||
yield session
|
|
||||||
|
|
||||||
await session.close()
|
|
||||||
await transaction.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_client() -> Generator[TestClient, None, None]:
|
def test_client() -> Generator[TestClient, None, None]:
|
||||||
"""Provide a FastAPI test client."""
|
"""Provide a FastAPI test client with SQLite database."""
|
||||||
with TestClient(app) as client:
|
# Create a single engine for this test
|
||||||
yield client
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tables
|
||||||
|
async def init_db():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
asyncio.run(init_db())
|
||||||
|
|
||||||
|
async def override_get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
# Override the dependency
|
||||||
|
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||||
|
|
||||||
|
# Patch startup events to prevent PostgreSQL connection attempts
|
||||||
|
with patch("src.main.init_database") as mock_init, \
|
||||||
|
patch("src.main.seed_builtin_tool_types") as mock_seed:
|
||||||
|
mock_init.return_value = True
|
||||||
|
mock_seed.return_value = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield client
|
||||||
|
finally:
|
||||||
|
# Clean up overrides
|
||||||
|
app.dependency_overrides.pop(get_db_session, None)
|
||||||
|
asyncio.run(engine.dispose())
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture
|
||||||
def configure_test_env(monkeypatch):
|
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
"""Configure environment for testing."""
|
"""Provide an authenticated test client with a test user."""
|
||||||
monkeypatch.setenv("DATABASE_URL", TEST_DATABASE_URL)
|
import uuid
|
||||||
monkeypatch.setenv("APP_ENV", "testing")
|
from src.auth.session import create_session_cookie
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
# Create user in database using the same engine as test_client
|
||||||
|
# We need to access the engine from the test_client fixture
|
||||||
|
# Since we can't easily do that, we'll create the user via API call
|
||||||
|
# But we need the user to exist before any API calls
|
||||||
|
# So we need to create the user using the overridden dependency
|
||||||
|
|
||||||
|
async def create_test_user():
|
||||||
|
# Get the override function
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
user = User(
|
||||||
|
id=uuid.UUID(user_id),
|
||||||
|
email="test@headquarter.local",
|
||||||
|
name="Test User",
|
||||||
|
authentik_id=f"authentik-{user_id}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
|
||||||
|
asyncio.run(create_test_user())
|
||||||
|
|
||||||
|
# Create session cookie
|
||||||
|
session_cookie = create_session_cookie(
|
||||||
|
settings=settings,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set cookie on client
|
||||||
|
test_client.cookies.set("session", session_cookie)
|
||||||
|
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
|
"""Provide an authenticated test client with an admin user."""
|
||||||
|
import uuid
|
||||||
|
from src.auth.session import create_session_cookie
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
async def create_admin_user():
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
user = User(
|
||||||
|
id=uuid.UUID(user_id),
|
||||||
|
email="admin@headquarter.local",
|
||||||
|
name="Admin User",
|
||||||
|
authentik_id=f"authentik-admin-{user_id}",
|
||||||
|
avatar_url=None,
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
|
||||||
|
asyncio.run(create_admin_user())
|
||||||
|
|
||||||
|
# Create session cookie
|
||||||
|
session_cookie = create_session_cookie(
|
||||||
|
settings=settings,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set cookie on client
|
||||||
|
test_client.cookies.set("session", session_cookie)
|
||||||
|
|
||||||
|
yield test_client
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestConfigFoldersAPI:
|
||||||
|
"""Integration tests for config folders API."""
|
||||||
|
|
||||||
|
def test_list_config_folders_requires_authentication(self, test_client: TestClient) -> None:
|
||||||
|
"""Test that listing config folders requires authentication."""
|
||||||
|
response = test_client.get("/config-folders")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_list_config_folders_returns_user_folders(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that authenticated users can list their folders."""
|
||||||
|
response = authenticated_client.get("/config-folders")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert "folders" in data
|
||||||
|
assert isinstance(data["folders"], list)
|
||||||
|
|
||||||
|
def test_create_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a config folder."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "test-folder",
|
||||||
|
"description": "Test folder",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {"test.txt": "hello world"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "test-folder"
|
||||||
|
assert data["mount_path"] == "/home/user"
|
||||||
|
assert data["files"] == {"test.txt": "hello world"}
|
||||||
|
|
||||||
|
def test_create_config_folder_duplicate_name(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that duplicate folder names are rejected."""
|
||||||
|
# Create first folder
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "duplicate-folder",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
# Try to create second with same name
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "duplicate-folder",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
def test_create_config_folder_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that folders exceeding 10MB are rejected."""
|
||||||
|
large_content = "x" * (11 * 1024 * 1024) # 11MB
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "large-folder",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {"large.txt": large_content},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_config_folder_path_traversal_attack(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that path traversal in file paths is prevented."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "bad-folder",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {"../../../etc/passwd": "malicious"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_get_config_folder_by_id(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting a config folder by ID."""
|
||||||
|
# Create folder first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "get-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Get it back
|
||||||
|
response = authenticated_client.get(f"/config-folders/{folder_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "get-test"
|
||||||
|
|
||||||
|
def test_get_config_folder_not_found(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting a non-existent folder."""
|
||||||
|
response = authenticated_client.get(f"/config-folders/{uuid.uuid4()}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a config folder."""
|
||||||
|
# Create folder first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "update-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update it
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-folders/{folder_id}",
|
||||||
|
json={
|
||||||
|
"name": "updated-name",
|
||||||
|
"mount_path": "/workspace",
|
||||||
|
"files": {"new.txt": "content"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "updated-name"
|
||||||
|
assert data["mount_path"] == "/workspace"
|
||||||
|
|
||||||
|
def test_delete_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test deleting a config folder."""
|
||||||
|
# Create folder first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "delete-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Delete it
|
||||||
|
response = authenticated_client.delete(f"/config-folders/{folder_id}")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
get_response = authenticated_client.get(f"/config-folders/{folder_id}")
|
||||||
|
assert get_response.status_code == 404
|
||||||
|
|
||||||
|
def test_add_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test adding a project override."""
|
||||||
|
# Create folder first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "override-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {"global.txt": "global"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Add override
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/config-folders/{folder_id}/overrides",
|
||||||
|
json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"mount_path": "/workspace",
|
||||||
|
"files": {"project.txt": "project"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert project_id in data["project_overrides"]
|
||||||
|
|
||||||
|
def test_update_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a project override."""
|
||||||
|
# Create folder with override
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "update-override-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Add override
|
||||||
|
authenticated_client.post(
|
||||||
|
f"/config-folders/{folder_id}/overrides",
|
||||||
|
json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"mount_path": "/workspace",
|
||||||
|
"files": {"old.txt": "old"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update override
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-folders/{folder_id}/overrides/{project_id}",
|
||||||
|
json={
|
||||||
|
"mount_path": "/app",
|
||||||
|
"files": {"new.txt": "new"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["project_overrides"][project_id]["mount_path"] == "/app"
|
||||||
|
|
||||||
|
def test_delete_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test deleting a project override."""
|
||||||
|
# Create folder with override
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "delete-override-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Add override
|
||||||
|
authenticated_client.post(
|
||||||
|
f"/config-folders/{folder_id}/overrides",
|
||||||
|
json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"mount_path": "/workspace",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete override
|
||||||
|
response = authenticated_client.delete(
|
||||||
|
f"/config-folders/{folder_id}/overrides/{project_id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert project_id not in data["project_overrides"]
|
||||||
@@ -63,6 +63,13 @@ class TestGitStatus:
|
|||||||
assert "new.py" in status.untracked
|
assert "new.py" in status.untracked
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_current_branch_handles_unborn_main() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
os.system(f"git init -b main {tmpdir} >/dev/null 2>&1")
|
||||||
|
|
||||||
|
assert get_current_branch(tmpdir) == "main"
|
||||||
|
|
||||||
|
|
||||||
class TestBranchOperations:
|
class TestBranchOperations:
|
||||||
"""Tests for branch management functions."""
|
"""Tests for branch management functions."""
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from src.models import Base
|
|||||||
from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
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.refresh_token import RefreshToken
|
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
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
|
||||||
|
|
||||||
from src.auth.jwt_service import mint_access_token
|
from src.auth.session import create_session_cookie
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings, build_database_url
|
||||||
from src.models import Base
|
from src.models import Base
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
@@ -53,7 +53,7 @@ def _load_app():
|
|||||||
|
|
||||||
def _mint_token(user_id: str) -> str:
|
def _mint_token(user_id: str) -> str:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
return mint_access_token(
|
return create_session_cookie(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestToolConfigsAPIExtended:
|
||||||
|
"""Integration tests for tool configs API with new fields."""
|
||||||
|
|
||||||
|
def test_create_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a tool config with all new fields."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "config-test-tool",
|
||||||
|
"display_name": "Config Test Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Create config with new fields
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "ADVANCED_CONFIG",
|
||||||
|
"value": "test-value",
|
||||||
|
"config_type": "env",
|
||||||
|
"port_override": 9090,
|
||||||
|
"start_command": "python app.py",
|
||||||
|
"working_directory": "/app",
|
||||||
|
"environment_variables": {"DEBUG": "true", "LOG_LEVEL": "debug"},
|
||||||
|
"volumes": [
|
||||||
|
{"source": "data", "target": "/data", "type": "bind"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["key"] == "ADVANCED_CONFIG"
|
||||||
|
assert data["port_override"] == 9090
|
||||||
|
assert data["start_command"] == "python app.py"
|
||||||
|
assert data["working_directory"] == "/app"
|
||||||
|
assert data["environment_variables"] == {"DEBUG": "true", "LOG_LEVEL": "debug"}
|
||||||
|
assert data["volumes"] == [{"source": "data", "target": "/data", "type": "bind"}]
|
||||||
|
|
||||||
|
def test_create_tool_config_invalid_port(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid port numbers are rejected."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "port-test-tool",
|
||||||
|
"display_name": "Port Test Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Try to create config with invalid port
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "BAD_PORT",
|
||||||
|
"value": "test",
|
||||||
|
"config_type": "env",
|
||||||
|
"port_override": 99999,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_tool_config_invalid_volume_structure(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid volume structures are rejected."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "volume-test-tool",
|
||||||
|
"display_name": "Volume Test Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Try to create config with invalid volume
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "BAD_VOLUME",
|
||||||
|
"value": "test",
|
||||||
|
"config_type": "env",
|
||||||
|
"volumes": [{"invalid": "structure"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_update_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a tool config with new fields."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "update-config-tool",
|
||||||
|
"display_name": "Update Config Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Create config
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "UPDATE_TEST",
|
||||||
|
"value": "original",
|
||||||
|
"config_type": "env",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
config_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update with new fields
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/tool-configs/{config_id}",
|
||||||
|
json={
|
||||||
|
"value": "updated",
|
||||||
|
"port_override": 3000,
|
||||||
|
"start_command": "npm start",
|
||||||
|
"working_directory": "/workspace",
|
||||||
|
"environment_variables": {"NODE_ENV": "production"},
|
||||||
|
"volumes": [{"source": "src", "target": "/app/src", "type": "bind"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["value"] == "updated"
|
||||||
|
assert data["port_override"] == 3000
|
||||||
|
assert data["start_command"] == "npm start"
|
||||||
|
assert data["working_directory"] == "/workspace"
|
||||||
|
assert data["environment_variables"] == {"NODE_ENV": "production"}
|
||||||
|
|
||||||
|
def test_list_tool_configs_returns_new_fields(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that listing configs returns new fields."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "list-config-tool",
|
||||||
|
"display_name": "List Config Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Create config with new fields
|
||||||
|
authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "LIST_TEST",
|
||||||
|
"value": "test",
|
||||||
|
"config_type": "env",
|
||||||
|
"port_override": 5000,
|
||||||
|
"environment_variables": {"TEST": "true"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# List configs
|
||||||
|
response = authenticated_client.get("/tool-configs")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) > 0
|
||||||
|
config = data[0]
|
||||||
|
assert "port_override" in config
|
||||||
|
assert "start_command" in config
|
||||||
|
assert "working_directory" in config
|
||||||
|
assert "environment_variables" in config
|
||||||
|
assert "volumes" in config
|
||||||
|
|
||||||
|
def test_get_tool_config_defaults(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting tool config defaults."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "defaults-tool",
|
||||||
|
"display_name": "Defaults Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n volumes:\n - \"{{REPO_PATH}}:/workspace\"\n",
|
||||||
|
"required_variables": ["REPO_PATH"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Get defaults
|
||||||
|
response = authenticated_client.get(f"/tool-configs/defaults/{tool_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["tool_type_id"] == tool_id
|
||||||
|
assert "suggested_configs" in data
|
||||||
|
|
||||||
|
def test_tool_config_backward_compatibility(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that old configs without new fields still work."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "backward-compat-tool",
|
||||||
|
"display_name": "Backward Compat Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Create config without new fields (simulating old client)
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "OLD_STYLE",
|
||||||
|
"value": "value",
|
||||||
|
"config_type": "env",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["key"] == "OLD_STYLE"
|
||||||
|
# New fields should have default values
|
||||||
|
assert data["port_override"] is None
|
||||||
|
assert data["start_command"] is None
|
||||||
|
assert data["working_directory"] is None
|
||||||
|
assert data["environment_variables"] is None
|
||||||
|
assert data["volumes"] is None
|
||||||
@@ -7,7 +7,7 @@ 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
|
||||||
|
|
||||||
from src.auth.jwt_service import mint_access_token
|
from src.auth.session import create_session_cookie
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings, build_database_url
|
||||||
from src.models import Base
|
from src.models import Base
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
@@ -54,7 +54,7 @@ def _load_app():
|
|||||||
|
|
||||||
def _mint_token(user_id: str) -> str:
|
def _mint_token(user_id: str) -> str:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
return mint_access_token(
|
return create_session_cookie(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestToolTypesAPIExtended:
|
||||||
|
"""Integration tests for tool types API with new fields."""
|
||||||
|
|
||||||
|
def test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a tool type with dockerfile definition."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "dockerfile-tool",
|
||||||
|
"display_name": "Dockerfile Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interfaces": ["terminal"],
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "dockerfile-tool"
|
||||||
|
assert data["definition_type"] == "dockerfile"
|
||||||
|
assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask"
|
||||||
|
|
||||||
|
def test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a tool type with readiness probe."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "probed-tool",
|
||||||
|
"display_name": "Probed Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"readiness_probe": {
|
||||||
|
"command": "curl -f http://localhost:8080",
|
||||||
|
"timeout": 30,
|
||||||
|
"interval": 2,
|
||||||
|
},
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080"
|
||||||
|
assert data["readiness_probe"]["timeout"] == 30
|
||||||
|
|
||||||
|
def test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid definition types are rejected."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "invalid-tool",
|
||||||
|
"display_name": "Invalid Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "invalid",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that dockerfile type requires dockerfile_template."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "no-dockerfile",
|
||||||
|
"display_name": "No Dockerfile",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a tool type with new fields."""
|
||||||
|
# Create tool type first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "update-test-tool",
|
||||||
|
"display_name": "Update Test Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update it
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/tool-types/{tool_id}",
|
||||||
|
json={
|
||||||
|
"display_name": "Updated Name",
|
||||||
|
"readiness_probe": {
|
||||||
|
"command": "curl -f http://localhost:8080/health",
|
||||||
|
"timeout": 60,
|
||||||
|
"interval": 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["display_name"] == "Updated Name"
|
||||||
|
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health"
|
||||||
|
|
||||||
|
def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test validating compose template."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types/validate",
|
||||||
|
json={
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["valid"] is True
|
||||||
|
|
||||||
|
def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test validating invalid compose template."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types/validate",
|
||||||
|
json={
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "invalid: yaml: [",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["valid"] is False
|
||||||
|
assert "errors" in data
|
||||||
|
|
||||||
|
def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test validating dockerfile template."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types/validate",
|
||||||
|
json={
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["valid"] is True
|
||||||
|
|
||||||
|
def test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that GET returns new fields."""
|
||||||
|
# Create tool type with all fields
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "full-tool",
|
||||||
|
"display_name": "Full Tool",
|
||||||
|
"category": "editor",
|
||||||
|
"interfaces": ["web", "terminal"],
|
||||||
|
"default_port": 8443,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
||||||
|
"readiness_probe": {
|
||||||
|
"command": "curl -f http://localhost:8443",
|
||||||
|
"timeout": 30,
|
||||||
|
"interval": 2,
|
||||||
|
},
|
||||||
|
"required_variables": ["REPO_PATH"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Get it
|
||||||
|
response = authenticated_client.get(f"/tool-types/{tool_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["definition_type"] == "compose"
|
||||||
|
assert data["category"] == "editor"
|
||||||
|
assert data["interfaces"] == ["web", "terminal"]
|
||||||
|
assert "readiness_probe" in data
|
||||||
@@ -8,7 +8,7 @@ import pytest
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from src.auth.jwt_service import mint_access_token
|
from src.auth.session import create_session_cookie
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings, build_database_url
|
||||||
from src.models import Base
|
from src.models import Base
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
@@ -78,7 +78,7 @@ def _insert_test_user(user_id: str) -> None:
|
|||||||
|
|
||||||
def _create_auth_cookie(user_id: str) -> str:
|
def _create_auth_cookie(user_id: str) -> str:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
return mint_access_token(
|
return create_session_cookie(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Unit tests for docker build service."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.docker_build import build_image
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildImage:
|
||||||
|
"""Tests for build_image function."""
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_builds_image_successfully(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="Successfully built abc123",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
result = build_image(tmpdir, "FROM python:3.11", "test-image:latest")
|
||||||
|
|
||||||
|
assert result[0] == 0
|
||||||
|
assert "Successfully built" in result[1]
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
call_args = mock_run.call_args
|
||||||
|
assert "test-image:latest" in call_args[0][0]
|
||||||
|
assert "build" in call_args[0][0]
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_fails(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=1,
|
||||||
|
stdout="",
|
||||||
|
stderr="Error: failed to build",
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
result = build_image(tmpdir, "FROM invalid:image", "test-image:latest")
|
||||||
|
|
||||||
|
assert result[0] == 1
|
||||||
|
assert "failed to build" in result[2]
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_with_tag(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
build_image(tmpdir, "FROM python:3.11", "my-registry/tool:v1.0")
|
||||||
|
|
||||||
|
call_args = mock_run.call_args[0][0]
|
||||||
|
assert "my-registry/tool:v1.0" in call_args
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_command_structure(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
build_image(tmpdir, "FROM python:3.11", "test:latest")
|
||||||
|
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert cmd[0] == "docker"
|
||||||
|
assert cmd[1] == "build"
|
||||||
|
assert "-t" in cmd
|
||||||
|
assert "test:latest" in cmd
|
||||||
|
assert tmpdir in cmd
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_writes_dockerfile(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
dockerfile_content = "FROM python:3.11\\nRUN pip install flask"
|
||||||
|
build_image(tmpdir, dockerfile_content, "test:latest")
|
||||||
|
|
||||||
|
dockerfile_path = Path(tmpdir) / "Dockerfile"
|
||||||
|
assert dockerfile_path.exists()
|
||||||
|
assert dockerfile_path.read_text() == dockerfile_content
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_writes_context_files(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
build_context = {
|
||||||
|
"requirements.txt": "flask==2.0\\nnumpy==1.21",
|
||||||
|
"app.py": "from flask import Flask\\napp = Flask(__name__)",
|
||||||
|
}
|
||||||
|
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
|
||||||
|
|
||||||
|
req_path = Path(tmpdir) / "requirements.txt"
|
||||||
|
app_path = Path(tmpdir) / "app.py"
|
||||||
|
assert req_path.exists()
|
||||||
|
assert req_path.read_text() == "flask==2.0\\nnumpy==1.21"
|
||||||
|
assert app_path.exists()
|
||||||
|
assert app_path.read_text() == "from flask import Flask\\napp = Flask(__name__)"
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_creates_nested_directories(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
build_context = {
|
||||||
|
"src/app.py": "print('hello')",
|
||||||
|
}
|
||||||
|
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
|
||||||
|
|
||||||
|
app_path = Path(tmpdir) / "src" / "app.py"
|
||||||
|
assert app_path.exists()
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_prevents_path_traversal(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
build_context = {
|
||||||
|
"../../../etc/passwd": "root:x:0:0",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="escapes instance directory"):
|
||||||
|
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
|
||||||
|
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_timeout(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["docker", "build"], timeout=300)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
|
||||||
|
|
||||||
|
assert result[0] == 1
|
||||||
|
assert "timed out" in result[2].lower()
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_exception(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = OSError("Docker not available")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
|
||||||
|
|
||||||
|
assert result[0] == 1
|
||||||
|
assert "Docker not available" in result[2]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.api.git_repositories import _build_provider_clone_url, _preflight_remote_repository
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_provider_clone_url_uses_fixed_host() -> None:
|
||||||
|
assert _build_provider_clone_url("alice", "demo") == "git@git.commumedia.org:alice/demo.git"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_remote_repository_allows_accessible_repo() -> None:
|
||||||
|
completed = Mock(returncode=0)
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock:
|
||||||
|
_preflight_remote_repository("git@git.commumedia.org:alice/demo.git")
|
||||||
|
|
||||||
|
run_mock.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_remote_repository_rejects_missing_repo() -> None:
|
||||||
|
completed = Mock(returncode=128)
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", return_value=completed):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
_preflight_remote_repository("git@git.commumedia.org:alice/missing.git")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert exc_info.value.detail == "repository not found or inaccessible"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.api.git_repositories import _clone_working_repository, _init_working_repository
|
||||||
|
from src.utils.git_control import create_branch
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_working_repository_uses_normal_clone() -> None:
|
||||||
|
completed = Mock(returncode=0, stderr="")
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock:
|
||||||
|
_clone_working_repository("git@git.commumedia.org:alice/demo.git", "/tmp/demo.git")
|
||||||
|
|
||||||
|
run_mock.assert_called_once()
|
||||||
|
assert run_mock.call_args.args[0] == ["git", "clone", "git@git.commumedia.org:alice/demo.git", "/tmp/demo.git"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_working_repository_raises_on_failure() -> None:
|
||||||
|
completed = Mock(returncode=128, stderr="fatal: repository not found")
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", return_value=completed):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
_clone_working_repository("git@git.commumedia.org:alice/missing.git", "/tmp/missing.git")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert "failed to clone repository" in exc_info.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_working_repository_prefers_init_b() -> None:
|
||||||
|
init_b = Mock(returncode=0, stderr="")
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", return_value=init_b) as run_mock:
|
||||||
|
_init_working_repository("/tmp/new-repo")
|
||||||
|
|
||||||
|
assert run_mock.call_args.args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_working_repository_falls_back_to_symbolic_ref() -> None:
|
||||||
|
init_b = Mock(returncode=1, stderr="unknown switch `b'")
|
||||||
|
init_ok = Mock(returncode=0, stderr="")
|
||||||
|
symbolic_ref = Mock(returncode=0, stderr="")
|
||||||
|
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", side_effect=[init_b, init_ok, symbolic_ref]) as run_mock:
|
||||||
|
_init_working_repository("/tmp/new-repo")
|
||||||
|
|
||||||
|
assert run_mock.call_args_list[0].args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"]
|
||||||
|
assert run_mock.call_args_list[1].args[0] == ["git", "init", "/tmp/new-repo"]
|
||||||
|
assert run_mock.call_args_list[2].args[0] == ["git", "-C", "/tmp/new-repo", "symbolic-ref", "HEAD", "refs/heads/main"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_branch_uses_orphan_checkout_when_head_is_unborn() -> None:
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def mock_run(repo_path: str, *args: str) -> str:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
raise RuntimeError("fatal: Needed a single revision")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
with patch("src.utils.git_control._run_git_command", side_effect=mock_run) as run_mock:
|
||||||
|
create_branch("/tmp/new-repo", "feature/test")
|
||||||
|
|
||||||
|
assert run_mock.call_args_list[0].args[1:] == ("rev-parse", "--verify", "HEAD^{commit}")
|
||||||
|
assert run_mock.call_args_list[1].args[1:] == ("checkout", "--orphan", "feature/test")
|
||||||
@@ -39,3 +39,18 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
|
|||||||
|
|
||||||
assert module.revision == "0002_refresh_tokens"
|
assert module.revision == "0002_refresh_tokens"
|
||||||
assert module.down_revision == "0001_initial_schema"
|
assert module.down_revision == "0001_initial_schema"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_config_profiles_migration_has_expected_revision_chain() -> None:
|
||||||
|
migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py"
|
||||||
|
spec = spec_from_file_location("add_config_profiles", migration_path)
|
||||||
|
|
||||||
|
assert spec is not None
|
||||||
|
assert spec.loader is not None
|
||||||
|
|
||||||
|
module = module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
assert module.revision == "0013_add_config_profiles"
|
||||||
|
assert module.down_revision == "0012_default_port_req"
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Unit tests for readiness probe service."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.readiness_probe import execute_probe
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteProbe:
|
||||||
|
"""Tests for execute_probe function."""
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_succeeds_first_attempt(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="healthy",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert any("Success" in log for log in logs)
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["docker", "exec", "container-123", "sh", "-c", "curl -f http://localhost:8080"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_fails_then_succeeds(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
|
||||||
|
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
|
||||||
|
MagicMock(returncode=0, stdout="healthy", stderr=""),
|
||||||
|
]
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=10, interval=0.1)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_run.call_count == 3
|
||||||
|
assert any("Attempt 1: Failed" in log for log in logs)
|
||||||
|
assert any("Attempt 3: Success" in log for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_times_out(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=1,
|
||||||
|
stdout="",
|
||||||
|
stderr="Connection refused",
|
||||||
|
)
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=0.5, interval=0.2)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert any("timed out" in log.lower() for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_command_not_found(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=127,
|
||||||
|
stdout="",
|
||||||
|
stderr="command not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "nonexistent-command", timeout=1, interval=0.3)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert any("exit code 127" in log for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_exception(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = OSError("Docker not available")
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "curl http://localhost", timeout=1, interval=0.3)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert any("Error" in log for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_with_special_characters(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = "bash -c 'echo \"hello world\" && exit 0'"
|
||||||
|
await execute_probe("container-123", cmd)
|
||||||
|
|
||||||
|
call_args = mock_run.call_args
|
||||||
|
assert cmd in call_args[0][0]
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_captures_stdout(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="Server is ready\\nVersion: 1.0",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "cat /app/status")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert any("Server is ready" in log for log in logs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegrationScenarios:
|
||||||
|
"""Integration-style tests with realistic scenarios."""
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_web_server_probe(self, mock_run) -> None:
|
||||||
|
"""Test typical web server health check."""
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(returncode=1, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=1, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=1, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="OK", stderr=""),
|
||||||
|
]
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"web-container",
|
||||||
|
"curl -f http://localhost:8080/health",
|
||||||
|
timeout=10,
|
||||||
|
interval=0.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_run.call_count == 4
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_command_probe(self, mock_run) -> None:
|
||||||
|
"""Test command availability check."""
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="opencode 1.0.0",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"tool-container",
|
||||||
|
"which opencode && opencode --version",
|
||||||
|
timeout=30,
|
||||||
|
interval=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert any("opencode 1.0.0" in log for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_database_probe(self, mock_run) -> None:
|
||||||
|
"""Test database readiness check."""
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(returncode=1, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=1, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="/var/run/postgresql:5432 - accepting connections", stderr=""),
|
||||||
|
]
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"db-container",
|
||||||
|
"pg_isready -U postgres",
|
||||||
|
timeout=10,
|
||||||
|
interval=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_run.call_count == 3
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_file_probe(self, mock_run) -> None:
|
||||||
|
"""Test file existence check."""
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"app-container",
|
||||||
|
"[ -f /app/ready ]",
|
||||||
|
timeout=10,
|
||||||
|
interval=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_slow_starting_service(self, mock_run) -> None:
|
||||||
|
"""Test service that takes time to start."""
|
||||||
|
# Simulate 5 failures before success
|
||||||
|
side_effects = [MagicMock(returncode=1, stdout="", stderr="")] * 5
|
||||||
|
side_effects.append(MagicMock(returncode=0, stdout="Ready", stderr=""))
|
||||||
|
mock_run.side_effect = side_effects
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"slow-container",
|
||||||
|
"curl -f http://localhost:8080",
|
||||||
|
timeout=10,
|
||||||
|
interval=0.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_run.call_count == 6
|
||||||
|
assert any("Attempt 6: Success" in log for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_zero_timeout_immediate_return(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"container",
|
||||||
|
"test",
|
||||||
|
timeout=0,
|
||||||
|
interval=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert any("timed out" in log.lower() for log in logs)
|
||||||
Generated
+1371
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Headquarter</title>
|
<title>Headquarter</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,796 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Headquarter - UI Preview</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4f1ea;
|
||||||
|
--panel: #fffef9;
|
||||||
|
--ink: #1d1d1b;
|
||||||
|
--muted: #5f5b55;
|
||||||
|
--brand: #275d4b;
|
||||||
|
--brand-strong: #154236;
|
||||||
|
--border: #d8d0c5;
|
||||||
|
--primary: #275d4b;
|
||||||
|
--primary-fg: #fffef9;
|
||||||
|
--color-primary: #275d4b;
|
||||||
|
--success: #2f8f62;
|
||||||
|
--success-light: rgba(47, 143, 98, 0.14);
|
||||||
|
--warning: #c08a1e;
|
||||||
|
--warning-light: rgba(192, 138, 30, 0.14);
|
||||||
|
--danger: #b94a3c;
|
||||||
|
--danger-light: rgba(185, 74, 60, 0.14);
|
||||||
|
--info: #4f7fb8;
|
||||||
|
--info-light: rgba(79, 127, 184, 0.14);
|
||||||
|
--space-1: 0.25rem;
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-5: 1.5rem;
|
||||||
|
--space-6: 2rem;
|
||||||
|
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
|
||||||
|
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
|
||||||
|
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
|
||||||
|
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
|
||||||
|
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App Shell */
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
|
backdrop-filter: blur(7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.58rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 230px 1fr;
|
||||||
|
min-height: calc(100vh - 57px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: #ece7df;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: #f7fff7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section-title {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status.running {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-content {
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Common Components */
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-sm {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.58rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover {
|
||||||
|
background: var(--brand-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.58rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Home Page */
|
||||||
|
.home-page {
|
||||||
|
max-width: 1240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-card .card-label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-card .card-value {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section h2,
|
||||||
|
.home-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-session-grid,
|
||||||
|
.home-project-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.running {
|
||||||
|
background: var(--success-light);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.building {
|
||||||
|
background: var(--warning-light);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pending {
|
||||||
|
background: var(--info-light);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-sessions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-form .form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input,
|
||||||
|
.form-field select,
|
||||||
|
.form-field textarea {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Page */
|
||||||
|
.settings-page {
|
||||||
|
max-width: 1240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview Switcher */
|
||||||
|
.preview-switcher {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-switcher button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-switcher button.active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-preview {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-preview.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.shell-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.shell-nav {
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.home-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<header class="shell-header">
|
||||||
|
<a href="#" class="brand">Headquarter</a>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="#" class="user-chip">User</a>
|
||||||
|
<button class="ghost-button">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="shell-body">
|
||||||
|
<aside class="shell-nav" aria-label="Primary navigation">
|
||||||
|
<a href="#" class="nav-item nav-item-active">
|
||||||
|
<span>🏠</span> Home
|
||||||
|
<span class="nav-badge">3</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item">
|
||||||
|
<span>📁</span> Projects
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item">
|
||||||
|
<span>⚙️</span> Settings
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-divider"></div>
|
||||||
|
<div class="nav-section-title">Live sessions</div>
|
||||||
|
<a href="#" class="nav-item session-item">
|
||||||
|
<span class="session-status running"></span>
|
||||||
|
<span>Dev Environment</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item session-item">
|
||||||
|
<span class="session-status running"></span>
|
||||||
|
<span>Jupyter Lab</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item session-item">
|
||||||
|
<span class="session-status"></span>
|
||||||
|
<span>Code Server</span>
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="shell-content">
|
||||||
|
<!-- HOME PAGE PREVIEW -->
|
||||||
|
<div id="home-preview" class="page-preview active">
|
||||||
|
<section class="stack home-page">
|
||||||
|
<header class="home-hero card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<p class="eyebrow">Workspace overview</p>
|
||||||
|
<h1>Home</h1>
|
||||||
|
<p class="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||||
|
</div>
|
||||||
|
<div class="home-hero-actions">
|
||||||
|
<button class="primary-button">New Project</button>
|
||||||
|
<button class="secondary-button">Settings</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="home-summary-grid">
|
||||||
|
<article class="card home-summary-card">
|
||||||
|
<p class="card-label">Open sessions</p>
|
||||||
|
<p class="card-value">3</p>
|
||||||
|
</article>
|
||||||
|
<article class="card home-summary-card">
|
||||||
|
<p class="card-label">Projects</p>
|
||||||
|
<p class="card-value">5</p>
|
||||||
|
</article>
|
||||||
|
<article class="card home-summary-card">
|
||||||
|
<p class="card-label">Repositories</p>
|
||||||
|
<p class="card-value">12</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Open sessions</p>
|
||||||
|
<h2>3</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="home-session-grid">
|
||||||
|
<article class="card session-card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<h3>Dev Environment</h3>
|
||||||
|
<span class="status-badge running">running</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Acme Corp · main</p>
|
||||||
|
<p class="muted">VS Code Server</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card session-card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<h3>Jupyter Lab</h3>
|
||||||
|
<span class="status-badge running">running</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Data Science · experiments</p>
|
||||||
|
<p class="muted">Jupyter Notebook</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card session-card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<h3>Database Console</h3>
|
||||||
|
<span class="status-badge building">building</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Backend API · staging</p>
|
||||||
|
<p class="muted">PostgreSQL Client</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Available projects</p>
|
||||||
|
<h2>5</h2>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-button">View all</button>
|
||||||
|
</div>
|
||||||
|
<div class="home-project-grid">
|
||||||
|
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<h3>Acme Corp</h3>
|
||||||
|
<p class="muted">Main product development</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<h3>Data Science</h3>
|
||||||
|
<p class="muted">ML experiments and notebooks</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<h3>Backend API</h3>
|
||||||
|
<p class="muted">REST API services</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Quick create</p>
|
||||||
|
<h2>Start a session</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="stack create-session-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="form-field">
|
||||||
|
Project
|
||||||
|
<select>
|
||||||
|
<option>Select project...</option>
|
||||||
|
<option>Acme Corp</option>
|
||||||
|
<option>Data Science</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Repository
|
||||||
|
<select disabled>
|
||||||
|
<option>Select repository...</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Tool type
|
||||||
|
<select>
|
||||||
|
<option>Select tool...</option>
|
||||||
|
<option>VS Code Server</option>
|
||||||
|
<option>Jupyter Lab</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="form-field">
|
||||||
|
Display name
|
||||||
|
<input type="text" placeholder="My Development Environment">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="primary-button" type="submit">Create Session</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Recent sessions</p>
|
||||||
|
<h2>2</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recent-sessions-list">
|
||||||
|
<article class="recent-session-item">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||||
|
<span class="recent-session-name">Old Dev Box</span>
|
||||||
|
<span class="muted">Acme Corp · VS Code Server</span>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
</article>
|
||||||
|
<article class="recent-session-item">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||||
|
<span class="recent-session-name">ML Training</span>
|
||||||
|
<span class="muted">Data Science · Jupyter Lab</span>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SETTINGS PAGE PREVIEW -->
|
||||||
|
<div id="settings-preview" class="page-preview">
|
||||||
|
<section class="stack settings-page">
|
||||||
|
<header class="settings-header card stack-sm">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Configuration</p>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="settings-tabs" aria-label="Settings sections">
|
||||||
|
<a href="#" class="settings-tab active">General</a>
|
||||||
|
<a href="#" class="settings-tab">SSH Keys</a>
|
||||||
|
<a href="#" class="settings-tab">Tool Types</a>
|
||||||
|
<a href="#" class="settings-tab">Tool Configs</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="settings-panel card">
|
||||||
|
<div class="stack">
|
||||||
|
<h2>General</h2>
|
||||||
|
<label class="form-field">
|
||||||
|
Theme
|
||||||
|
<select>
|
||||||
|
<option>System</option>
|
||||||
|
<option>Light</option>
|
||||||
|
<option>Dark</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Git user name
|
||||||
|
<input type="text" placeholder="Your git commit name" value="John Doe">
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Git user email
|
||||||
|
<input type="email" placeholder="your.email@example.com" value="john@example.com">
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Default editor
|
||||||
|
<input type="text" placeholder="e.g., vscode, vim, cursor" value="vscode">
|
||||||
|
</label>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button class="primary-button">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-switcher">
|
||||||
|
<button class="active" onclick="showPage('home')">Home</button>
|
||||||
|
<button onclick="showPage('settings')">Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showPage(page) {
|
||||||
|
document.querySelectorAll('.page-preview').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.preview-switcher button').forEach(b => b.classList.remove('active'));
|
||||||
|
document.getElementById(page + '-preview').classList.add('active');
|
||||||
|
event.target.classList.add('active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createConfigFolder,
|
||||||
|
deleteConfigFolder,
|
||||||
|
listConfigFolders,
|
||||||
|
updateConfigFolder,
|
||||||
|
} from "../api/config_folders";
|
||||||
|
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
const mockPut = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
interceptors: {
|
||||||
|
response: {
|
||||||
|
use: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shouldSkipAuthRedirect: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("config_folders API", () => {
|
||||||
|
describe("listConfigFolders", () => {
|
||||||
|
it("returns folders with files and overrides", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "folder-1",
|
||||||
|
name: "my-dotfiles",
|
||||||
|
description: "My personal config files",
|
||||||
|
mount_path: "/home/user",
|
||||||
|
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
||||||
|
project_overrides: {},
|
||||||
|
is_active: true,
|
||||||
|
user_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await listConfigFolders();
|
||||||
|
|
||||||
|
expect(result[0].name).toBe("my-dotfiles");
|
||||||
|
expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" });
|
||||||
|
expect(mockGet).toHaveBeenCalledWith("/config-folders");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createConfigFolder", () => {
|
||||||
|
it("creates folder with files", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: "folder-new",
|
||||||
|
name: "new-folder",
|
||||||
|
mount_path: "/workspace",
|
||||||
|
files: { ".env": "API_URL=http://localhost" },
|
||||||
|
is_active: true,
|
||||||
|
user_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockPost.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await createConfigFolder({
|
||||||
|
name: "new-folder",
|
||||||
|
mount_path: "/workspace",
|
||||||
|
files: { ".env": "API_URL=http://localhost" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.name).toBe("new-folder");
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
"/config-folders",
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "new-folder",
|
||||||
|
mount_path: "/workspace",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateConfigFolder", () => {
|
||||||
|
it("updates folder files", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: "folder-1",
|
||||||
|
name: "updated-folder",
|
||||||
|
mount_path: "/home/user",
|
||||||
|
files: { ".bashrc": "alias ll='ls -la'" },
|
||||||
|
is_active: true,
|
||||||
|
user_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockPut.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await updateConfigFolder("folder-1", {
|
||||||
|
files: { ".bashrc": "alias ll='ls -la'" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" });
|
||||||
|
expect(mockPut).toHaveBeenCalledWith(
|
||||||
|
"/config-folders/folder-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
files: { ".bashrc": "alias ll='ls -la'" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteConfigFolder", () => {
|
||||||
|
it("deletes folder", async () => {
|
||||||
|
mockDelete.mockResolvedValue({ data: undefined });
|
||||||
|
|
||||||
|
await deleteConfigFolder("folder-1");
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ConfigFolder {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
mount_path: string;
|
||||||
|
files: Record<string, string>;
|
||||||
|
project_overrides: Record<string, { mount_path?: string; files?: Record<string, string> }> | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateConfigFolderRequest {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
mount_path: string;
|
||||||
|
files?: Record<string, string>;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateConfigFolderRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
mount_path?: string;
|
||||||
|
files?: Record<string, string>;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectOverrideRequest {
|
||||||
|
mount_path?: string;
|
||||||
|
files?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
|
||||||
|
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConfigFolder = async (
|
||||||
|
data: CreateConfigFolderRequest
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateConfigFolder = async (
|
||||||
|
id: string,
|
||||||
|
data: UpdateConfigFolderRequest
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.put<ConfigFolder>(`/config-folders/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteConfigFolder = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/config-folders/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addProjectOverride = async (
|
||||||
|
id: string,
|
||||||
|
projectId: string,
|
||||||
|
data: ProjectOverrideRequest
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.post<ConfigFolder>(
|
||||||
|
`/config-folders/${id}/overrides/${projectId}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProjectOverride = async (
|
||||||
|
id: string,
|
||||||
|
projectId: string,
|
||||||
|
data: ProjectOverrideRequest
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.put<ConfigFolder>(
|
||||||
|
`/config-folders/${id}/overrides/${projectId}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteProjectOverride = async (
|
||||||
|
id: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ export interface ToolInstance {
|
|||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
tool_type_id: string;
|
tool_type_id: string;
|
||||||
|
tool_type_name: string;
|
||||||
|
tool_type_interfaces: string[];
|
||||||
status: string;
|
status: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
port: number | null;
|
port: number | null;
|
||||||
@@ -16,8 +18,11 @@ export interface Session {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
tool_type_name: string;
|
tool_type_name: string;
|
||||||
tool_icon: string;
|
tool_icon: string;
|
||||||
|
tool_type_interfaces: string[];
|
||||||
repository_name: string;
|
repository_name: string;
|
||||||
|
repository_id: string;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
|
project_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
}
|
}
|
||||||
@@ -95,3 +100,25 @@ export async function getUserSessions(): Promise<Session[]> {
|
|||||||
const response = await apiClient.get("/users/me/sessions");
|
const response = await apiClient.get("/users/me/sessions");
|
||||||
return response.data.sessions;
|
return response.data.sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkInstanceHealth(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
instanceId: string
|
||||||
|
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recreateInstanceTunnel(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
instanceId: string
|
||||||
|
): Promise<{ status: string; url?: string }> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ export interface UserConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UserConfigUpdate {
|
export interface UserConfigUpdate {
|
||||||
default_editor?: string;
|
default_editor?: string | null;
|
||||||
theme?: string;
|
theme?: string | null;
|
||||||
git_user_name?: string;
|
git_user_name?: string | null;
|
||||||
git_user_email?: string;
|
git_user_email?: string | null;
|
||||||
last_session_id?: string;
|
last_session_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUserConfig = async (): Promise<UserConfig> => {
|
export const getUserConfig = async (): Promise<UserConfig> => {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ToolConfig {
|
||||||
|
id: string;
|
||||||
|
tool_type_id: string;
|
||||||
|
project_id: string | null;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
config_type: string;
|
||||||
|
file_path: string | null;
|
||||||
|
port_override: number | null;
|
||||||
|
start_command: string | null;
|
||||||
|
working_directory: string | null;
|
||||||
|
environment_variables: Record<string, string> | null;
|
||||||
|
volumes: Array<{ source: string; target: string; type?: string }> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateToolConfigRequest {
|
||||||
|
tool_type_id: string;
|
||||||
|
project_id?: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
config_type?: string;
|
||||||
|
file_path?: string;
|
||||||
|
port_override?: number;
|
||||||
|
start_command?: string;
|
||||||
|
working_directory?: string;
|
||||||
|
environment_variables?: Record<string, string>;
|
||||||
|
volumes?: Array<{ source: string; target: string; type?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listToolConfigs = async (
|
||||||
|
tool_type_id?: string,
|
||||||
|
project_id?: string
|
||||||
|
): Promise<ToolConfig[]> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (tool_type_id) params.append("tool_type_id", tool_type_id);
|
||||||
|
if (project_id) params.append("project_id", project_id);
|
||||||
|
|
||||||
|
const response = await apiClient.get<{ configs: ToolConfig[] }>(
|
||||||
|
`/tool-configs?${params.toString()}`
|
||||||
|
);
|
||||||
|
return response.data.configs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createToolConfig = async (
|
||||||
|
data: CreateToolConfigRequest
|
||||||
|
): Promise<ToolConfig> => {
|
||||||
|
const response = await apiClient.post<{ configs: ToolConfig[] }>("/tool-configs", data);
|
||||||
|
return response.data.configs[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateToolConfig = async (
|
||||||
|
id: string,
|
||||||
|
data: CreateToolConfigRequest
|
||||||
|
): Promise<ToolConfig> => {
|
||||||
|
const response = await apiClient.put<{ configs: ToolConfig[] }>(
|
||||||
|
`/tool-configs/${id}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data.configs[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteToolConfig = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/tool-configs/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getToolConfigDefaults = async (
|
||||||
|
toolTypeId: string
|
||||||
|
): Promise<ToolConfig> => {
|
||||||
|
const response = await apiClient.get<ToolConfig>(
|
||||||
|
`/tool-configs/defaults/${toolTypeId}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createToolType,
|
||||||
|
deleteToolType,
|
||||||
|
listToolTypes,
|
||||||
|
updateToolType,
|
||||||
|
validateToolType,
|
||||||
|
} from "../api/tool_types";
|
||||||
|
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
const mockPut = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
interceptors: {
|
||||||
|
response: {
|
||||||
|
use: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shouldSkipAuthRedirect: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("tool_types API", () => {
|
||||||
|
describe("listToolTypes", () => {
|
||||||
|
it("returns tool types with new fields", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "type-1",
|
||||||
|
name: "custom-tool",
|
||||||
|
display_name: "Custom Tool",
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM python:3.11",
|
||||||
|
readiness_probe: {
|
||||||
|
command: "python --version",
|
||||||
|
timeout: 30,
|
||||||
|
interval: 2,
|
||||||
|
},
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await listToolTypes();
|
||||||
|
|
||||||
|
expect(result[0].definition_type).toBe("dockerfile");
|
||||||
|
expect(result[0].dockerfile_template).toBe("FROM python:3.11");
|
||||||
|
expect(result[0].readiness_probe).toEqual({
|
||||||
|
command: "python --version",
|
||||||
|
timeout: 30,
|
||||||
|
interval: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns compose tool types", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "type-1",
|
||||||
|
name: "code-server",
|
||||||
|
definition_type: "compose",
|
||||||
|
compose_template: "version: '3.8'",
|
||||||
|
dockerfile_template: null,
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await listToolTypes();
|
||||||
|
|
||||||
|
expect(result[0].definition_type).toBe("compose");
|
||||||
|
expect(result[0].dockerfile_template).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createToolType", () => {
|
||||||
|
it("creates tool type with dockerfile", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: "new-type",
|
||||||
|
name: "docker-tool",
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM node:18",
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockPost.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await createToolType({
|
||||||
|
name: "docker-tool",
|
||||||
|
display_name: "Docker Tool",
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM node:18",
|
||||||
|
default_port: 3000,
|
||||||
|
required_variables: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.definition_type).toBe("dockerfile");
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
"/tool-types",
|
||||||
|
expect.objectContaining({
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM node:18",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates tool type with readiness probe", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: "new-type",
|
||||||
|
name: "probed-tool",
|
||||||
|
readiness_probe: {
|
||||||
|
command: "curl -f http://localhost:8080",
|
||||||
|
timeout: 60,
|
||||||
|
interval: 3,
|
||||||
|
},
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockPost.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await createToolType({
|
||||||
|
name: "probed-tool",
|
||||||
|
display_name: "Probed Tool",
|
||||||
|
compose_template: "version: '3.8'",
|
||||||
|
default_port: 8080,
|
||||||
|
required_variables: [],
|
||||||
|
readiness_probe: {
|
||||||
|
command: "curl -f http://localhost:8080",
|
||||||
|
timeout: 60,
|
||||||
|
interval: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.readiness_probe).toEqual({
|
||||||
|
command: "curl -f http://localhost:8080",
|
||||||
|
timeout: 60,
|
||||||
|
interval: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateToolType", () => {
|
||||||
|
it("validates tool type by id", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: { valid: true, errors: [] },
|
||||||
|
};
|
||||||
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await validateToolType("type-1");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(mockGet).toHaveBeenCalledWith("/tool-types/type-1/validate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns validation errors", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: { valid: false, errors: ["Invalid YAML"] },
|
||||||
|
};
|
||||||
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await validateToolType("type-1");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain("Invalid YAML");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateToolType", () => {
|
||||||
|
it("updates tool type with new fields", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: "type-1",
|
||||||
|
name: "updated-tool",
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM python:3.11",
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockPut.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await updateToolType("type-1", {
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM python:3.11",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.definition_type).toBe("dockerfile");
|
||||||
|
expect(mockPut).toHaveBeenCalledWith(
|
||||||
|
"/tool-types/type-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteToolType", () => {
|
||||||
|
it("deletes tool type", async () => {
|
||||||
|
mockDelete.mockResolvedValue({ data: undefined });
|
||||||
|
|
||||||
|
await deleteToolType("type-1");
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith("/tool-types/type-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ReadinessProbe {
|
||||||
|
command: string;
|
||||||
|
timeout: 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;
|
||||||
compose_template: string;
|
category: string;
|
||||||
|
interfaces: string[];
|
||||||
|
default_port: number | null;
|
||||||
|
definition_type: 'compose' | 'dockerfile';
|
||||||
|
compose_template: string | null;
|
||||||
|
dockerfile_template: string | null;
|
||||||
|
build_context: Record<string, string> | null;
|
||||||
|
readiness_probe: ReadinessProbe | null;
|
||||||
required_variables: string[];
|
required_variables: string[];
|
||||||
is_builtin: boolean;
|
is_builtin: boolean;
|
||||||
created_by_id: string | null;
|
created_by_id: string | null;
|
||||||
@@ -17,14 +30,28 @@ export interface CreateToolTypeRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
compose_template: string;
|
category?: string;
|
||||||
|
interfaces?: string[];
|
||||||
|
default_port: number;
|
||||||
|
definition_type?: 'compose' | 'dockerfile';
|
||||||
|
compose_template?: string;
|
||||||
|
dockerfile_template?: string;
|
||||||
|
build_context?: Record<string, string>;
|
||||||
|
readiness_probe?: ReadinessProbe;
|
||||||
required_variables: string[];
|
required_variables: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateToolTypeRequest {
|
export interface UpdateToolTypeRequest {
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
interfaces?: string[];
|
||||||
|
default_port?: number;
|
||||||
|
definition_type?: 'compose' | 'dockerfile';
|
||||||
compose_template?: string;
|
compose_template?: string;
|
||||||
|
dockerfile_template?: string;
|
||||||
|
build_context?: Record<string, string>;
|
||||||
|
readiness_probe?: ReadinessProbe;
|
||||||
required_variables?: string[];
|
required_variables?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,3 +78,8 @@ export const updateToolType = async (id: string, data: UpdateToolTypeRequest): P
|
|||||||
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[] }> => {
|
||||||
|
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(`/tool-types/${id}/validate`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,22 +10,20 @@ import { Icon } from "./icon";
|
|||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
||||||
{ to: "/", label: "Dashboard", icon: "dashboard" },
|
{ to: "/", label: "Home", icon: "dashboard" },
|
||||||
{ to: "/sessions", label: "Sessions", icon: "terminal" },
|
|
||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
|
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||||
{ to: "/tool-types", label: "Tool Types", icon: "code" },
|
|
||||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const SessionItem = ({ session }: { session: Session }) => {
|
const SessionItem = ({ session }: { session: Session }) => {
|
||||||
const isRunning = session.status === "running";
|
const isRunning = session.status === "running";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={session.url || "#"}
|
href={session.url ?? `/projects/${session.project_id}`}
|
||||||
target="_blank"
|
target={session.url ? "_blank" : undefined}
|
||||||
rel="noopener noreferrer"
|
rel={session.url ? "noopener noreferrer" : undefined}
|
||||||
className="nav-item session-item"
|
className="nav-item session-item"
|
||||||
title={`${session.display_name} (${session.status})`}
|
title={`${session.display_name} (${session.status})`}
|
||||||
>
|
>
|
||||||
@@ -85,7 +83,7 @@ export const AppShell = () => {
|
|||||||
<div className="shell-body">
|
<div className="shell-body">
|
||||||
<aside className="shell-nav" aria-label="Primary navigation">
|
<aside className="shell-nav" aria-label="Primary navigation">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const isSessions = item.to === "/sessions";
|
const isHome = item.to === "/";
|
||||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -96,7 +94,7 @@ export const AppShell = () => {
|
|||||||
>
|
>
|
||||||
<Icon name={item.icon} size="sm" />
|
<Icon name={item.icon} size="sm" />
|
||||||
{item.label}
|
{item.label}
|
||||||
{isSessions && activeCount > 0 && (
|
{isHome && activeCount > 0 && (
|
||||||
<span className="nav-badge">{activeCount}</span>
|
<span className="nav-badge">{activeCount}</span>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -106,7 +104,7 @@ export const AppShell = () => {
|
|||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="nav-divider" />
|
<div className="nav-divider" />
|
||||||
<div className="nav-section-title">Sessions</div>
|
<div className="nav-section-title">Live sessions</div>
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<SessionItem key={session.id} session={session} />
|
<SessionItem key={session.id} session={session} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface GitToolbarProps {
|
|||||||
repoId: string;
|
repoId: string;
|
||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
branches: string[];
|
branches: string[];
|
||||||
|
hasRemote: boolean;
|
||||||
onBranchChange: (branch: string) => void;
|
onBranchChange: (branch: string) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@ export const GitToolbar = ({
|
|||||||
repoId,
|
repoId,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
branches,
|
branches,
|
||||||
|
hasRemote,
|
||||||
onBranchChange,
|
onBranchChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: GitToolbarProps) => {
|
}: GitToolbarProps) => {
|
||||||
@@ -55,6 +57,7 @@ export const GitToolbar = ({
|
|||||||
}, [loadStatus]);
|
}, [loadStatus]);
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const handleFetch = async () => {
|
||||||
|
if (!hasRemote) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await fetchRepository(projectId, repoId);
|
await fetchRepository(projectId, repoId);
|
||||||
@@ -67,9 +70,10 @@ export const GitToolbar = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePull = async () => {
|
const handlePull = async () => {
|
||||||
|
if (!hasRemote) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await pullRepository(projectId, repoId, currentBranch);
|
await pullRepository(projectId, repoId, currentBranch || undefined);
|
||||||
await loadStatus();
|
await loadStatus();
|
||||||
onRefresh();
|
onRefresh();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -108,7 +112,7 @@ export const GitToolbar = ({
|
|||||||
if (!newBranchName.trim()) return;
|
if (!newBranchName.trim()) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await createBranch(projectId, repoId, newBranchName, newBranchBase || "HEAD");
|
await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD");
|
||||||
setShowNewBranch(false);
|
setShowNewBranch(false);
|
||||||
setNewBranchName("");
|
setNewBranchName("");
|
||||||
setNewBranchBase("");
|
setNewBranchBase("");
|
||||||
@@ -127,6 +131,8 @@ export const GitToolbar = ({
|
|||||||
status.untracked.length > 0
|
status.untracked.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const canSync = hasRemote;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-toolbar">
|
<div className="git-toolbar">
|
||||||
{error && <div className="toolbar-error">{error}</div>}
|
{error && <div className="toolbar-error">{error}</div>}
|
||||||
@@ -162,10 +168,10 @@ export const GitToolbar = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className="toolbar-button"
|
||||||
onClick={handleFetch}
|
onClick={handleFetch}
|
||||||
disabled={loading}
|
disabled={loading || !canSync}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="fetch" size="sm" /> Fetch
|
<Icon name="fetch" size="sm" /> Fetch
|
||||||
@@ -173,7 +179,7 @@ export const GitToolbar = ({
|
|||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className="toolbar-button"
|
||||||
onClick={handlePull}
|
onClick={handlePull}
|
||||||
disabled={loading}
|
disabled={loading || !canSync}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="pull" size="sm" /> Pull
|
<Icon name="pull" size="sm" /> Pull
|
||||||
@@ -182,7 +188,7 @@ export const GitToolbar = ({
|
|||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className="toolbar-button"
|
||||||
onClick={handlePush}
|
onClick={handlePush}
|
||||||
disabled={loading || !status?.ahead}
|
disabled={loading || !canSync || !status?.ahead}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="push" size="sm" /> Push
|
<Icon name="push" size="sm" /> Push
|
||||||
|
|||||||
@@ -3,15 +3,19 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { ToolInstance } from "../api/sessions";
|
import type { ToolInstance } from "../api/sessions";
|
||||||
import {
|
import {
|
||||||
|
checkInstanceHealth,
|
||||||
createInstance,
|
createInstance,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
listInstances,
|
listInstances,
|
||||||
|
recreateInstanceTunnel,
|
||||||
restartInstance,
|
restartInstance,
|
||||||
startInstance,
|
startInstance,
|
||||||
stopInstance,
|
stopInstance,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
interface InstanceListProps {
|
interface InstanceListProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
@@ -26,6 +30,12 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
const [selectedToolType, setSelectedToolType] = useState("");
|
const [selectedToolType, setSelectedToolType] = useState("");
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Stop confirmation
|
||||||
|
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Health check state
|
||||||
|
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
|
||||||
|
|
||||||
const loadInstances = useCallback(async () => {
|
const loadInstances = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -43,6 +53,36 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
void loadInstances();
|
void loadInstances();
|
||||||
}, [loadInstances]);
|
}, [loadInstances]);
|
||||||
|
|
||||||
|
// Health check polling
|
||||||
|
useEffect(() => {
|
||||||
|
const runningInstances = instances.filter(i => i.status === "running" && i.url?.startsWith("http"));
|
||||||
|
if (runningInstances.length === 0) return;
|
||||||
|
|
||||||
|
const checkHealth = async () => {
|
||||||
|
for (const instance of runningInstances) {
|
||||||
|
try {
|
||||||
|
const health = await checkInstanceHealth(projectId, repoId, instance.id);
|
||||||
|
setHealthStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() }
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setHealthStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
[instance.id]: { healthy: false, lastCheck: Date.now() }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately
|
||||||
|
void checkHealth();
|
||||||
|
|
||||||
|
// Then every 30 seconds
|
||||||
|
const interval = setInterval(() => void checkHealth(), 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [instances, projectId, repoId]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!selectedToolType) return;
|
if (!selectedToolType) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -69,6 +109,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
const handleStop = async (instanceId: string) => {
|
const handleStop = async (instanceId: string) => {
|
||||||
try {
|
try {
|
||||||
await stopInstance(projectId, repoId, instanceId);
|
await stopInstance(projectId, repoId, instanceId);
|
||||||
|
setStopConfirmId(null);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to stop instance");
|
setError("Failed to stop instance");
|
||||||
@@ -88,12 +129,22 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
if (!confirm("Are you sure you want to delete this instance?")) return;
|
if (!confirm("Are you sure you want to delete this instance?")) return;
|
||||||
try {
|
try {
|
||||||
await deleteInstance(projectId, repoId, instanceId);
|
await deleteInstance(projectId, repoId, instanceId);
|
||||||
await loadInstances();
|
// Update state immediately instead of reloading
|
||||||
|
setInstances(prev => prev.filter(i => i.id !== instanceId));
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to delete instance");
|
setError("Failed to delete instance");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRecreateTunnel = async (instanceId: string) => {
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(projectId, repoId, instanceId);
|
||||||
|
await loadInstances();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to recreate tunnel");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "running":
|
case "running":
|
||||||
@@ -108,6 +159,14 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isTunnelUnhealthy = (instance: ToolInstance) => {
|
||||||
|
if (instance.status !== "running") return false;
|
||||||
|
if (!instance.url?.startsWith("http")) return false;
|
||||||
|
const health = healthStatus[instance.id];
|
||||||
|
if (!health) return false;
|
||||||
|
return !health.healthy;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="instance-list">
|
<div className="instance-list">
|
||||||
<div className="instance-list-header">
|
<div className="instance-list-header">
|
||||||
@@ -142,21 +201,40 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
style={{ backgroundColor: getStatusColor(instance.status) }}
|
style={{ backgroundColor: getStatusColor(instance.status) }}
|
||||||
/>
|
/>
|
||||||
{instance.status}
|
{instance.status}
|
||||||
|
{isTunnelUnhealthy(instance) && (
|
||||||
|
<span className="error-badge" title="Tunnel unreachable">
|
||||||
|
<Icon name="warning" size="sm" />
|
||||||
|
tunnel error
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="instance-actions">
|
<div className="instance-actions">
|
||||||
{instance.status === "running" && instance.url && (
|
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
||||||
<a
|
<>
|
||||||
href={instance.url}
|
<a
|
||||||
target="_blank"
|
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="secondary-button small"
|
rel="noopener noreferrer"
|
||||||
>
|
className="secondary-button small"
|
||||||
<Icon name="external" size="sm" />
|
>
|
||||||
Open
|
<Icon name="external" size="sm" />
|
||||||
</a>
|
Open
|
||||||
|
</a>
|
||||||
|
{isTunnelUnhealthy(instance) && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small warning"
|
||||||
|
onClick={() => void handleRecreateTunnel(instance.id)}
|
||||||
|
type="button"
|
||||||
|
title="Recreate tunnel"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Fix Tunnel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{instance.status === "running" && (
|
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
|
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
|
||||||
@@ -178,13 +256,33 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
)}
|
)}
|
||||||
{instance.status === "running" && (
|
{instance.status === "running" && (
|
||||||
<>
|
<>
|
||||||
<button
|
{stopConfirmId === instance.id ? (
|
||||||
className="ghost-button small"
|
<div className="inline-confirm">
|
||||||
onClick={() => void handleStop(instance.id)}
|
<span>Stop?</span>
|
||||||
type="button"
|
<button
|
||||||
>
|
className="ghost-button small danger-text"
|
||||||
<Icon name="stop" size="sm" />
|
onClick={() => void handleStop(instance.id)}
|
||||||
</button>
|
type="button"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => setStopConfirmId(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => setStopConfirmId(instance.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="ghost-button small"
|
className="ghost-button small"
|
||||||
onClick={() => void handleRestart(instance.id)}
|
onClick={() => void handleRestart(instance.id)}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
|
||||||
|
import * as gitRepositoriesApi from "../api/git_repositories";
|
||||||
|
|
||||||
|
const mockRepositories = [
|
||||||
|
{
|
||||||
|
id: "repo-1",
|
||||||
|
name: "Main Repo",
|
||||||
|
path: "/repos/main",
|
||||||
|
project_id: "proj-1",
|
||||||
|
owner_id: "user-1",
|
||||||
|
is_mirror: false,
|
||||||
|
remote_url: null,
|
||||||
|
last_push: null,
|
||||||
|
created_at: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useParams: () => ({ projectId: "proj-1" }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RepositoriesSettingsTab", () => {
|
||||||
|
it("opens create dialog and clones an existing repository", async () => {
|
||||||
|
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||||
|
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
||||||
|
|
||||||
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
|
target: { value: "New Repo" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
||||||
|
target: { value: "alice" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
|
||||||
|
target: { value: "demo" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||||
|
name: "New Repo",
|
||||||
|
remote_url: "git@git.commumedia.org:alice/demo.git",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses advanced url fallback when requested", async () => {
|
||||||
|
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||||
|
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
||||||
|
|
||||||
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
|
target: { value: "New Repo" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /use full url instead/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), {
|
||||||
|
target: { value: "https://github.com/user/repo.git" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||||
|
name: "New Repo",
|
||||||
|
remote_url: "https://github.com/user/repo.git",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows validation when cloning without a remote url", async () => {
|
||||||
|
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||||
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
|
target: { value: "New Repo" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
||||||
|
target: { value: "" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,37 +1,45 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { apiClient } from "../api/client";
|
|
||||||
import { GitRepository } from "../api/git_repositories";
|
import { deleteRepository, listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
|
import { RepositoryCreateDialog } from "./repository-create-dialog";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
export const RepositoriesSettingsTab: React.FC = () => {
|
export const RepositoriesSettingsTab: React.FC = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(">");
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
const loadRepositories = useCallback(async () => {
|
||||||
const fetchRepositories = async () => {
|
if (!projectId) {
|
||||||
try {
|
setLoading(false);
|
||||||
const response = await apiClient.get(
|
return;
|
||||||
`/projects/${projectId}/repositories`
|
}
|
||||||
);
|
|
||||||
setRepositories(response.data);
|
|
||||||
} catch (err) {
|
|
||||||
setError("Failed to load repositories");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchRepositories();
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await listRepositories(projectId);
|
||||||
|
setRepositories(data);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load repositories");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRepositories();
|
||||||
|
}, [loadRepositories]);
|
||||||
|
|
||||||
const handleDelete = async (repoId: string) => {
|
const handleDelete = async (repoId: string) => {
|
||||||
|
if (!projectId) return;
|
||||||
if (!window.confirm("Are you sure you want to delete this repository?")) return;
|
if (!window.confirm("Are you sure you want to delete this repository?")) return;
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
await deleteRepository(projectId, repoId);
|
||||||
setRepositories(repositories.filter((r) => r.id !== repoId));
|
setRepositories((current) => current.filter((r) => r.id !== repoId));
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError("Failed to delete repository");
|
setError("Failed to delete repository");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -40,7 +48,17 @@ export const RepositoriesSettingsTab: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="repositories-settings-tab">
|
<div className="repositories-settings-tab">
|
||||||
<h2>Repositories</h2>
|
<div className="page-header">
|
||||||
|
<h2>Repositories</h2>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add Repository
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
<div className="repositories-list">
|
<div className="repositories-list">
|
||||||
@@ -66,6 +84,16 @@ export const RepositoriesSettingsTab: React.FC = () => {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<RepositoryCreateDialog
|
||||||
|
projectId={projectId!}
|
||||||
|
open={showCreate}
|
||||||
|
title="Add Repository"
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onCreated={loadRepositories}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
type CreateMode = "clone" | "blank";
|
||||||
|
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
|
||||||
|
|
||||||
|
interface RepositoryCreateDialogProps {
|
||||||
|
projectId: string;
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
|
||||||
|
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||||
|
const [formName, setFormName] = useState("");
|
||||||
|
const [owner, setOwner] = useState("");
|
||||||
|
const [repoName, setRepoName] = useState("");
|
||||||
|
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||||
|
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [urlValidation, setUrlValidation] = useState<{
|
||||||
|
status: UrlValidationStatus;
|
||||||
|
result: URLParseResult | null;
|
||||||
|
}>({ status: "idle", result: null });
|
||||||
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open && debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
debounceTimer.current = null;
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (!useAdvancedUrl) {
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!advancedUrl.trim()) {
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrlValidation({ status: "validating", result: null });
|
||||||
|
|
||||||
|
debounceTimer.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await parseGitUrl(advancedUrl.trim());
|
||||||
|
if (result.is_valid_clone_url) {
|
||||||
|
setUrlValidation({ status: "valid", result });
|
||||||
|
} else if (result.needs_parsing) {
|
||||||
|
setUrlValidation({ status: "needs-parsing", result });
|
||||||
|
} else {
|
||||||
|
setUrlValidation({ status: "invalid", result });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUrlValidation({ status: "invalid", result: null });
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [advancedUrl, open, useAdvancedUrl]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setCreateMode("clone");
|
||||||
|
setFormName("");
|
||||||
|
setOwner("");
|
||||||
|
setRepoName("");
|
||||||
|
setAdvancedUrl("");
|
||||||
|
setUseAdvancedUrl(false);
|
||||||
|
setFormError(null);
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
if (!formName.trim()) {
|
||||||
|
setFormError("Repository name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input: GitRepositoryCreate = {
|
||||||
|
name: formName.trim(),
|
||||||
|
remote_url: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createMode === "clone") {
|
||||||
|
if (useAdvancedUrl) {
|
||||||
|
if (!advancedUrl.trim()) {
|
||||||
|
setFormError("Remote URL is required for advanced cloning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.remote_url = advancedUrl.trim();
|
||||||
|
} else {
|
||||||
|
if (!owner.trim() || !repoName.trim()) {
|
||||||
|
setFormError("Owner and repository name are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await createRepository(projectId, input);
|
||||||
|
handleClose();
|
||||||
|
await onCreated();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const response = error as { response?: { data?: { detail?: string } } };
|
||||||
|
const detail = response.response?.data?.detail;
|
||||||
|
setFormError(typeof detail === "string" ? detail : "Failed to create repository");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseSuggestedUrl = () => {
|
||||||
|
if (urlValidation.result?.base_url) {
|
||||||
|
setAdvancedUrl(urlValidation.result.base_url);
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUrlInputClass = () => {
|
||||||
|
switch (urlValidation.status) {
|
||||||
|
case "valid":
|
||||||
|
return "valid-url";
|
||||||
|
case "needs-parsing":
|
||||||
|
return "needs-parsing-url";
|
||||||
|
case "invalid":
|
||||||
|
return "invalid-url";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="dialog">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Clone an existing repository from git.commumedia.org, or create a blank bare repo here.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="stack">
|
||||||
|
<div className="form-field">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="repository-mode"
|
||||||
|
checked={createMode === "clone"}
|
||||||
|
onChange={() => setCreateMode("clone")}
|
||||||
|
/>
|
||||||
|
Clone existing repository
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="repository-mode"
|
||||||
|
checked={createMode === "blank"}
|
||||||
|
onChange={() => setCreateMode("blank")}
|
||||||
|
/>
|
||||||
|
Create blank repository
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="form-field">
|
||||||
|
Repository name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formName}
|
||||||
|
onChange={(event) => setFormName(event.target.value)}
|
||||||
|
placeholder="repository-name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{createMode === "clone" && !useAdvancedUrl && (
|
||||||
|
<>
|
||||||
|
<label className="form-field">
|
||||||
|
Owner
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={owner}
|
||||||
|
onChange={(event) => setOwner(event.target.value)}
|
||||||
|
placeholder="owner"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={repoName}
|
||||||
|
onChange={(event) => setRepoName(event.target.value)}
|
||||||
|
placeholder="repo-name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => setUseAdvancedUrl(true)}
|
||||||
|
>
|
||||||
|
Use full URL instead
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{createMode === "clone" && useAdvancedUrl && (
|
||||||
|
<label className="form-field">
|
||||||
|
Remote URL
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={advancedUrl}
|
||||||
|
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||||
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
className={getUrlInputClass()}
|
||||||
|
/>
|
||||||
|
{urlValidation.status === "validating" && (
|
||||||
|
<span className="validation-status validating">Validating...</span>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "valid" && (
|
||||||
|
<span className="validation-status valid">
|
||||||
|
<Icon name="success" size="sm" /> Valid git URL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
||||||
|
<div className="url-suggestion">
|
||||||
|
<span className="validation-status warning">
|
||||||
|
<Icon name="warning" size="sm" /> This looks like a browser URL
|
||||||
|
</span>
|
||||||
|
<div className="suggestion-actions">
|
||||||
|
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={handleUseSuggestedUrl}
|
||||||
|
>
|
||||||
|
Use Suggested
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "invalid" && (
|
||||||
|
<span className="validation-status invalid">
|
||||||
|
<Icon name="error" size="sm" /> Invalid URL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => setUseAdvancedUrl(false)}
|
||||||
|
>
|
||||||
|
Use owner/repo instead
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{formError && (
|
||||||
|
<div className="error-message">
|
||||||
|
<p className="error-text">{formError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="secondary-button" onClick={handleClose} type="button">
|
||||||
|
<Icon name="cancel" size="sm" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,54 +1,81 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { DashboardPage } from "./dashboard";
|
import { HomePage } from "./dashboard";
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
const mockDashboard = vi.fn();
|
||||||
|
const mockSessions = vi.fn();
|
||||||
|
const mockProjects = vi.fn();
|
||||||
|
const mockRepos = vi.fn();
|
||||||
|
|
||||||
vi.mock("../api/dashboard", () => ({
|
vi.mock("../api/dashboard", () => ({
|
||||||
getDashboardSummary: (...args: unknown[]) => mockGet(...args)
|
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("DashboardPage", () => {
|
vi.mock("../api/sessions", () => ({
|
||||||
|
getUserSessions: (...args: unknown[]) => mockSessions(...args),
|
||||||
|
createInstance: vi.fn(),
|
||||||
|
startInstance: vi.fn(),
|
||||||
|
stopInstance: vi.fn(),
|
||||||
|
deleteInstance: vi.fn(),
|
||||||
|
recreateInstanceTunnel: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/projects", () => ({
|
||||||
|
listProjects: (...args: unknown[]) => mockProjects(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/git_repositories", () => ({
|
||||||
|
listRepositories: (...args: unknown[]) => mockRepos(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/tool_types", () => ({
|
||||||
|
listToolTypes: vi.fn().mockResolvedValue([])
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("HomePage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGet.mockReset();
|
mockDashboard.mockReset();
|
||||||
|
mockSessions.mockReset();
|
||||||
|
mockProjects.mockReset();
|
||||||
|
mockRepos.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows loading then empty state when summary has no data", async () => {
|
it("shows overview sections", async () => {
|
||||||
mockGet.mockResolvedValue({
|
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
|
||||||
projects: 0,
|
mockSessions.mockResolvedValue([]);
|
||||||
repositories: 0,
|
mockProjects.mockResolvedValue([]);
|
||||||
sshKeys: 0,
|
mockRepos.mockResolvedValue([]);
|
||||||
recentActivity: []
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
|
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("No activity yet")).toBeInTheDocument();
|
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("Available projects")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows retry action when summary request fails", async () => {
|
it("shows retry action when home load fails", async () => {
|
||||||
mockGet.mockRejectedValueOnce(new Error("failed"));
|
mockDashboard.mockRejectedValueOnce(new Error("failed"));
|
||||||
mockGet.mockResolvedValueOnce({
|
mockSessions.mockRejectedValueOnce(new Error("failed"));
|
||||||
projects: 2,
|
mockProjects.mockRejectedValueOnce(new Error("failed"));
|
||||||
repositories: 5,
|
|
||||||
sshKeys: 1,
|
|
||||||
recentActivity: ["Created repo"]
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Dashboard is unavailable")).toBeInTheDocument();
|
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("2")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,83 +1,338 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||||
|
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
|
||||||
|
import { listProjects } from "../api/projects";
|
||||||
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
|
import { updateUserConfig } from "../api/settings";
|
||||||
|
import type { Project } from "../types";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
const CARDS = [
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
const summaryCards = [
|
||||||
|
{ label: "Open sessions", key: "openSessions" },
|
||||||
{ label: "Projects", key: "projects" },
|
{ label: "Projects", key: "projects" },
|
||||||
{ label: "Repositories", key: "repositories" },
|
{ label: "Repositories", key: "repositories" },
|
||||||
{ label: "SSH Keys", key: "sshKeys" }
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type DashboardStatus = "loading" | "ready" | "error";
|
type SessionView = SessionApi;
|
||||||
|
|
||||||
export const DashboardPage = () => {
|
export const HomePage = () => {
|
||||||
const [status, setStatus] = useState<DashboardStatus>("loading");
|
const navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||||
|
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState("");
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||||
|
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||||
|
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||||
|
|
||||||
const loadSummary = useCallback(async () => {
|
const loadHome = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
const data = await getDashboardSummary();
|
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
|
||||||
setSummary(data);
|
getDashboardSummary(),
|
||||||
|
getUserSessions(),
|
||||||
|
listProjects(),
|
||||||
|
listToolTypes(),
|
||||||
|
]);
|
||||||
|
setSummary(dashboard);
|
||||||
|
setSessions(sessionData as SessionView[]);
|
||||||
|
setProjects(projectData);
|
||||||
|
setToolTypes(toolTypeData);
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setSummary(null);
|
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSummary();
|
void loadHome();
|
||||||
}, [loadSummary]);
|
}, [loadHome]);
|
||||||
|
|
||||||
const cards = useMemo(() => CARDS, []);
|
useEffect(() => {
|
||||||
const isEmpty =
|
if (!selectedProject) {
|
||||||
status === "ready" &&
|
setRepositories([]);
|
||||||
summary !== null &&
|
return;
|
||||||
summary.projects === 0 &&
|
}
|
||||||
summary.repositories === 0 &&
|
|
||||||
summary.sshKeys === 0 &&
|
const loadRepos = async () => {
|
||||||
summary.recentActivity.length === 0;
|
try {
|
||||||
|
const data = await listRepositories(selectedProject);
|
||||||
|
setRepositories(data);
|
||||||
|
} catch {
|
||||||
|
setRepositories([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadRepos();
|
||||||
|
}, [selectedProject]);
|
||||||
|
|
||||||
|
const activeSessions = useMemo(
|
||||||
|
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
|
||||||
|
[safeSessions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentSessions = useMemo(
|
||||||
|
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
|
||||||
|
[safeSessions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||||
|
|
||||||
|
setSaveState("saving");
|
||||||
|
try {
|
||||||
|
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
|
||||||
|
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||||
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
|
setDisplayName("");
|
||||||
|
setSelectedProject("");
|
||||||
|
setSelectedRepo("");
|
||||||
|
setSelectedToolType("");
|
||||||
|
setSaveState("idle");
|
||||||
|
await loadHome();
|
||||||
|
} catch {
|
||||||
|
setSaveState("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
setActionBusy(session.id);
|
||||||
|
try {
|
||||||
|
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
await loadHome();
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (session: SessionView) => {
|
||||||
|
setActionBusy(session.id);
|
||||||
|
try {
|
||||||
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
await loadHome();
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecreateTunnel = async (session: SessionView) => {
|
||||||
|
setActionBusy(session.id);
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||||
|
await loadHome();
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack home-page">
|
||||||
<h1>Dashboard</h1>
|
<header className="home-hero card">
|
||||||
<p className="muted">Your workspace overview will appear here.</p>
|
<div className="stack-sm">
|
||||||
|
<p className="eyebrow">Workspace overview</p>
|
||||||
|
<h1>Home</h1>
|
||||||
|
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||||
|
</div>
|
||||||
|
<div className="home-hero-actions">
|
||||||
|
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading dashboard...</p>}
|
{status === "loading" && <p className="muted">Loading overview...</p>}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="card stack">
|
<div className="card stack">
|
||||||
<p>Dashboard is unavailable</p>
|
<p>Unable to load your workspace overview.</p>
|
||||||
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
|
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
||||||
<Icon name="refresh" size="sm" />
|
<Icon name="refresh" size="sm" />
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card-grid">
|
{status === "ready" && summary && (
|
||||||
{cards.map((card) => (
|
<>
|
||||||
<article className="card" key={card.label}>
|
<div className="home-summary-grid">
|
||||||
<p className="card-label">{card.label}</p>
|
{summaryCards.map((card) => (
|
||||||
<p className="card-value">{summary ? String(summary[card.key]) : "-"}</p>
|
<article className="card home-summary-card" key={card.label}>
|
||||||
</article>
|
<p className="card-label">{card.label}</p>
|
||||||
))}
|
<p className="card-value">
|
||||||
</div>
|
{card.key === "openSessions"
|
||||||
|
? activeSessions.length
|
||||||
|
: card.key === "projects"
|
||||||
|
? summary.projects
|
||||||
|
: summary.repositories}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isEmpty && <p className="muted">No activity yet</p>}
|
<section className="card stack home-section">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Open sessions</p>
|
||||||
|
<h2>{activeSessions.length}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activeSessions.length === 0 ? (
|
||||||
|
<p className="muted">No active sessions right now.</p>
|
||||||
|
) : (
|
||||||
|
<div className="home-session-grid">
|
||||||
|
{activeSessions.map((session) => (
|
||||||
|
<article className="card session-card" key={session.id}>
|
||||||
|
<div className="stack-sm">
|
||||||
|
<div className="row row-tight">
|
||||||
|
<h3>{session.display_name}</h3>
|
||||||
|
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
||||||
|
<p className="muted">{session.tool_type_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="session-actions">
|
||||||
|
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Tunnel
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="quick-actions">
|
<section className="card stack home-section">
|
||||||
<button className="primary-button" type="button">
|
<div className="page-header">
|
||||||
<Icon name="add" size="sm" />
|
<div>
|
||||||
New Project
|
<p className="eyebrow">Available projects</p>
|
||||||
</button>
|
<h2>{projects.length}</h2>
|
||||||
<button className="secondary-button" type="button">
|
</div>
|
||||||
<Icon name="add" size="sm" />
|
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||||
Add Repository
|
</div>
|
||||||
</button>
|
{projects.length === 0 ? (
|
||||||
</div>
|
<p className="muted">No projects yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="home-project-grid">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<article className="card project-card home-project-card" key={project.id}>
|
||||||
|
<div className="stack-sm">
|
||||||
|
<h3>{project.name}</h3>
|
||||||
|
{project.description && <p className="muted">{project.description}</p>}
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
|
||||||
|
Open Workspace
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card stack home-section">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Quick create</p>
|
||||||
|
<h2>Start a session</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="stack create-session-form" onSubmit={handleCreate}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
Project
|
||||||
|
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
|
||||||
|
<option value="">Select repository...</option>
|
||||||
|
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Tool type
|
||||||
|
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
|
||||||
|
<option value="">Select tool...</option>
|
||||||
|
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="form-field">
|
||||||
|
Display name
|
||||||
|
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
|
||||||
|
</label>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
|
||||||
|
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
|
||||||
|
</button>
|
||||||
|
{saveState === "error" && <span className="error-text">Failed to create session</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{recentSessions.length > 0 && (
|
||||||
|
<section className="card stack home-section">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Recent sessions</p>
|
||||||
|
<h2>{recentSessions.length}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="recent-sessions-list">
|
||||||
|
{recentSessions.map((session) => (
|
||||||
|
<article className="recent-session-item" key={session.id}>
|
||||||
|
<div className="recent-session-info">
|
||||||
|
<span className="recent-session-name">{session.display_name}</span>
|
||||||
|
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { HomePage as DashboardPage };
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createRepository,
|
|
||||||
deleteRepository,
|
deleteRepository,
|
||||||
listRepositories,
|
listRepositories,
|
||||||
parseGitUrl,
|
|
||||||
type GitRepositoryCreate,
|
|
||||||
type URLParseResult,
|
|
||||||
} from "../api/git_repositories";
|
} from "../api/git_repositories";
|
||||||
import type { GitRepository } from "../api/git_repositories";
|
import type { GitRepository } from "../api/git_repositories";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
|
||||||
|
|
||||||
type RepoStatus = "loading" | "ready" | "error";
|
type RepoStatus = "loading" | "ready" | "error";
|
||||||
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
|
|
||||||
|
|
||||||
export const GitRepositoriesPage = () => {
|
export const GitRepositoriesPage = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
@@ -21,19 +17,8 @@ export const GitRepositoriesPage = () => {
|
|||||||
const [status, setStatus] = useState<RepoStatus>("loading");
|
const [status, setStatus] = useState<RepoStatus>("loading");
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [formName, setFormName] = useState("");
|
|
||||||
const [formRemoteUrl, setFormRemoteUrl] = useState("");
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
// URL validation state
|
|
||||||
const [urlValidation, setUrlValidation] = useState<{
|
|
||||||
status: UrlValidationStatus;
|
|
||||||
result: URLParseResult | null;
|
|
||||||
}>({ status: "idle", result: null });
|
|
||||||
|
|
||||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const loadRepositories = useCallback(async () => {
|
const loadRepositories = useCallback(async () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -51,98 +36,6 @@ export const GitRepositoriesPage = () => {
|
|||||||
void loadRepositories();
|
void loadRepositories();
|
||||||
}, [loadRepositories]);
|
}, [loadRepositories]);
|
||||||
|
|
||||||
// Validate URL with debounce
|
|
||||||
useEffect(() => {
|
|
||||||
if (debounceTimer.current) {
|
|
||||||
clearTimeout(debounceTimer.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formRemoteUrl.trim()) {
|
|
||||||
setUrlValidation({ status: "idle", result: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUrlValidation({ status: "validating", result: null });
|
|
||||||
|
|
||||||
debounceTimer.current = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const result = await parseGitUrl(formRemoteUrl.trim());
|
|
||||||
if (result.is_valid_clone_url) {
|
|
||||||
setUrlValidation({ status: "valid", result });
|
|
||||||
} else if (result.needs_parsing) {
|
|
||||||
setUrlValidation({ status: "needs-parsing", result });
|
|
||||||
} else {
|
|
||||||
setUrlValidation({ status: "invalid", result });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setUrlValidation({ status: "invalid", result: null });
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (debounceTimer.current) {
|
|
||||||
clearTimeout(debounceTimer.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [formRemoteUrl]);
|
|
||||||
|
|
||||||
const getUrlInputClass = () => {
|
|
||||||
switch (urlValidation.status) {
|
|
||||||
case "valid":
|
|
||||||
return "valid-url";
|
|
||||||
case "needs-parsing":
|
|
||||||
return "needs-parsing-url";
|
|
||||||
case "invalid":
|
|
||||||
return "invalid-url";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
if (!formName.trim()) {
|
|
||||||
setFormError("Repository name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!projectId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const input: GitRepositoryCreate = {
|
|
||||||
name: formName.trim(),
|
|
||||||
remote_url: formRemoteUrl.trim() || undefined,
|
|
||||||
};
|
|
||||||
await createRepository(projectId, input);
|
|
||||||
setShowCreate(false);
|
|
||||||
setFormName("");
|
|
||||||
setFormRemoteUrl("");
|
|
||||||
setUrlValidation({ status: "idle", result: null });
|
|
||||||
await loadRepositories();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const axiosError = err as { response?: { status: number; data: { detail: { suggested_url: string; message: string } } } };
|
|
||||||
if (axiosError.response?.status === 422 && axiosError.response?.data?.detail?.suggested_url) {
|
|
||||||
// Show URL correction suggestion
|
|
||||||
const detail = axiosError.response.data.detail;
|
|
||||||
setFormError(
|
|
||||||
`${detail.message}\nSuggested: ${detail.suggested_url}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setFormError("Failed to create repository");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUseSuggestedUrl = () => {
|
|
||||||
if (urlValidation.result?.base_url) {
|
|
||||||
setFormRemoteUrl(urlValidation.result.base_url);
|
|
||||||
setUrlValidation({ status: "idle", result: null });
|
|
||||||
setFormError(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (repoId: string) => {
|
const handleDelete = async (repoId: string) => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
try {
|
try {
|
||||||
@@ -233,81 +126,13 @@ export const GitRepositoriesPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<RepositoryCreateDialog
|
||||||
<div className="dialog">
|
projectId={projectId!}
|
||||||
<h2>Create Repository</h2>
|
open={showCreate}
|
||||||
<form onSubmit={handleSubmit} className="stack">
|
title="Create Repository"
|
||||||
<label className="form-field">
|
onClose={() => setShowCreate(false)}
|
||||||
Name
|
onCreated={loadRepositories}
|
||||||
<input
|
/>
|
||||||
type="text"
|
|
||||||
value={formName}
|
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
|
||||||
placeholder="repository-name"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Remote URL (optional)
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formRemoteUrl}
|
|
||||||
onChange={(e) => setFormRemoteUrl(e.target.value)}
|
|
||||||
placeholder="https://github.com/user/repo.git"
|
|
||||||
className={getUrlInputClass()}
|
|
||||||
/>
|
|
||||||
{urlValidation.status === "validating" && (
|
|
||||||
<span className="validation-status validating">Validating...</span>
|
|
||||||
)}
|
|
||||||
{urlValidation.status === "valid" && (
|
|
||||||
<span className="validation-status valid">
|
|
||||||
<Icon name="success" size="sm" /> Valid git URL
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
|
||||||
<div className="url-suggestion">
|
|
||||||
<span className="validation-status warning">
|
|
||||||
<Icon name="warning" size="sm" /> This looks like a browser URL
|
|
||||||
</span>
|
|
||||||
<div className="suggestion-actions">
|
|
||||||
<span className="suggested-url">
|
|
||||||
Suggested: {urlValidation.result.base_url}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={handleUseSuggestedUrl}
|
|
||||||
>
|
|
||||||
Use Suggested
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{urlValidation.status === "invalid" && (
|
|
||||||
<span className="validation-status invalid">
|
|
||||||
<Icon name="error" size="sm" /> Invalid URL
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
{formError && (
|
|
||||||
<div className="error-message">
|
|
||||||
{formError.split("\n").map((line, i) => (
|
|
||||||
<p key={i} className="error-text">{line}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="dialog-actions">
|
|
||||||
<button className="secondary-button" onClick={() => setShowCreate(false)} type="button">
|
|
||||||
<Icon name="cancel" size="sm" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button className="primary-button" type="submit">
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ProjectsPage } from "./projects";
|
import { ProjectsPage } from "./projects";
|
||||||
@@ -29,13 +30,21 @@ afterEach(() => {
|
|||||||
describe("ProjectsPage", () => {
|
describe("ProjectsPage", () => {
|
||||||
it("renders loading state initially", () => {
|
it("renders loading state initially", () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders project list after loading", async () => {
|
it("renders project list after loading", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
@@ -46,7 +55,11 @@ describe("ProjectsPage", () => {
|
|||||||
|
|
||||||
it("renders empty state when no projects", async () => {
|
it("renders empty state when no projects", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -55,7 +68,11 @@ describe("ProjectsPage", () => {
|
|||||||
|
|
||||||
it("renders error state with retry button", async () => {
|
it("renders error state with retry button", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
||||||
@@ -67,7 +84,11 @@ describe("ProjectsPage", () => {
|
|||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -96,7 +117,11 @@ describe("ProjectsPage", () => {
|
|||||||
it("shows validation error when name is empty", async () => {
|
it("shows validation error when name is empty", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -112,7 +137,11 @@ describe("ProjectsPage", () => {
|
|||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
@@ -141,7 +170,11 @@ describe("ProjectsPage", () => {
|
|||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export const RepoWorkspace = () => {
|
|||||||
repoId={selectedRepoId}
|
repoId={selectedRepoId}
|
||||||
currentBranch={currentBranch}
|
currentBranch={currentBranch}
|
||||||
branches={branches}
|
branches={branches}
|
||||||
|
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||||
onBranchChange={(branch) => {
|
onBranchChange={(branch) => {
|
||||||
setCurrentBranch(branch);
|
setCurrentBranch(branch);
|
||||||
const newParams = new URLSearchParams(searchParams);
|
const newParams = new URLSearchParams(searchParams);
|
||||||
@@ -364,6 +365,9 @@ const FileBrowser = ({
|
|||||||
<Icon name="folder" size="sm" /> ..
|
<Icon name="folder" size="sm" /> ..
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<p className="muted">No files in this repository yet.</p>
|
||||||
|
)}
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||||
return (
|
return (
|
||||||
@@ -388,4 +392,3 @@ const FileBrowser = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+229
-42
@@ -4,7 +4,15 @@ 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";
|
||||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
import { getUserSessions, type Session, deleteInstance, stopInstance } from "../api/sessions";
|
import {
|
||||||
|
getUserSessions,
|
||||||
|
type Session,
|
||||||
|
deleteInstance,
|
||||||
|
stopInstance,
|
||||||
|
startInstance,
|
||||||
|
checkInstanceHealth,
|
||||||
|
recreateInstanceTunnel,
|
||||||
|
} from "../api/sessions";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { createInstance } from "../api/sessions";
|
import { createInstance } from "../api/sessions";
|
||||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||||
@@ -31,6 +39,9 @@ export const SessionsPage = () => {
|
|||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||||
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, { healthy: boolean; status_code: number | null; error?: string }>>({});
|
||||||
|
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -75,6 +86,38 @@ export const SessionsPage = () => {
|
|||||||
void loadToolTypes();
|
void loadToolTypes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Poll tunnel health every 30 seconds for running instances
|
||||||
|
useEffect(() => {
|
||||||
|
const checkHealth = async () => {
|
||||||
|
const runningSessions = sessions.filter(
|
||||||
|
(s) => s.status === "running" && s.url
|
||||||
|
);
|
||||||
|
for (const session of runningSessions) {
|
||||||
|
try {
|
||||||
|
const health = await checkInstanceHealth(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id
|
||||||
|
);
|
||||||
|
setTunnelHealth((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[session.id]: health,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setTunnelHealth((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[session.id]: { healthy: false, status_code: null, error: "check failed" },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately and then every 30 seconds
|
||||||
|
void checkHealth();
|
||||||
|
const interval = setInterval(() => void checkHealth(), 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
setRepositories([]);
|
setRepositories([]);
|
||||||
@@ -92,12 +135,12 @@ export const SessionsPage = () => {
|
|||||||
}, [selectedProject]);
|
}, [selectedProject]);
|
||||||
|
|
||||||
const activeSessions = useMemo(
|
const activeSessions = useMemo(
|
||||||
() => sessions.filter((s) => s.status === "running"),
|
() => sessions.filter((s) => ["running", "building", "pending"].includes(s.status)),
|
||||||
[sessions]
|
[sessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const recentSessions = useMemo(
|
const recentSessions = useMemo(
|
||||||
() => sessions.filter((s) => s.status !== "running").slice(0, 5),
|
() => sessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5),
|
||||||
[sessions]
|
[sessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -123,6 +166,10 @@ export const SessionsPage = () => {
|
|||||||
selectedToolType,
|
selectedToolType,
|
||||||
displayName || undefined
|
displayName || undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-start the instance
|
||||||
|
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||||
|
|
||||||
await updateUserConfig({ last_session_id: instance.id });
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
setCreateStatus("idle");
|
setCreateStatus("idle");
|
||||||
setSelectedProject("");
|
setSelectedProject("");
|
||||||
@@ -139,9 +186,10 @@ export const SessionsPage = () => {
|
|||||||
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
||||||
try {
|
try {
|
||||||
await stopInstance(projectId, repoId, sessionId);
|
await stopInstance(projectId, repoId, sessionId);
|
||||||
|
setStopConfirmId(null);
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
setStopConfirmId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,14 +197,38 @@ export const SessionsPage = () => {
|
|||||||
try {
|
try {
|
||||||
await deleteInstance(projectId, repoId, sessionId);
|
await deleteInstance(projectId, repoId, sessionId);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
await loadSessions();
|
// Remove from local state immediately
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||||
} catch {
|
} catch {
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRecreateTunnel = async (session: Session) => {
|
||||||
|
setRecreatingId(session.id);
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id
|
||||||
|
);
|
||||||
|
// Refresh sessions to get new URL
|
||||||
|
await loadSessions();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setRecreatingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpen = (session: Session) => {
|
const handleOpen = (session: Session) => {
|
||||||
navigate(`/projects/${session.project_name}/repositories/${session.repository_name}`);
|
if (session.url) {
|
||||||
|
window.open(session.url, '_blank', 'noopener,noreferrer');
|
||||||
|
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
||||||
|
navigate(`/instances/${session.id}/terminal`);
|
||||||
|
} else {
|
||||||
|
navigate(`/projects/${session.project_id}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResumeLast = async () => {
|
const handleResumeLast = async () => {
|
||||||
@@ -198,13 +270,32 @@ export const SessionsPage = () => {
|
|||||||
<p className="muted">
|
<p className="muted">
|
||||||
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
|
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
|
||||||
</p>
|
</p>
|
||||||
|
{lastSession.url && (
|
||||||
|
<p className="session-url">
|
||||||
|
<a href={lastSession.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{lastSession.url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
|
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="last-session-actions">
|
<div className="last-session-actions">
|
||||||
<button className="primary-button" onClick={handleResumeLast} type="button">
|
{lastSession.url ? (
|
||||||
<Icon name="play" size="sm" />
|
<a
|
||||||
Resume
|
href={lastSession.url}
|
||||||
</button>
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="primary-button"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button className="primary-button" onClick={handleResumeLast} type="button">
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,31 +320,116 @@ export const SessionsPage = () => {
|
|||||||
<p className="muted">
|
<p className="muted">
|
||||||
{session.tool_type_name} · {session.project_name}
|
{session.tool_type_name} · {session.project_name}
|
||||||
</p>
|
</p>
|
||||||
<span className="status-badge running">running</span>
|
{session.url && (
|
||||||
|
<p className="session-url">
|
||||||
|
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{session.url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||||
|
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
|
||||||
|
<span className="status-badge error">tunnel error</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="session-actions">
|
<div className="session-actions">
|
||||||
<button
|
{session.url ? (
|
||||||
className="secondary-button small"
|
<a
|
||||||
onClick={() => handleOpen(session)}
|
href={session.url}
|
||||||
type="button"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
<Icon name="external" size="sm" />
|
className="secondary-button small"
|
||||||
Open
|
>
|
||||||
</button>
|
<Icon name="external" size="sm" />
|
||||||
<button
|
Open
|
||||||
className="secondary-button small"
|
</a>
|
||||||
onClick={() =>
|
) : (
|
||||||
void handleStop(
|
<button
|
||||||
session.id,
|
className="secondary-button small"
|
||||||
projects.find((p) => p.name === session.project_name)?.id ?? "",
|
onClick={() => handleOpen(session)}
|
||||||
""
|
type="button"
|
||||||
)
|
>
|
||||||
}
|
<Icon name="external" size="sm" />
|
||||||
type="button"
|
Open
|
||||||
>
|
</button>
|
||||||
<Icon name="stop" size="sm" />
|
)}
|
||||||
Stop
|
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
|
||||||
</button>
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => void handleRecreateTunnel(session)}
|
||||||
|
type="button"
|
||||||
|
disabled={recreatingId === session.id}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
{recreatingId === session.id ? "Recreating..." : "Recreate Tunnel"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{stopConfirmId === session.id ? (
|
||||||
|
<div className="stop-confirm-inline">
|
||||||
|
<span className="confirm-text">Stop?</span>
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={() =>
|
||||||
|
void handleStop(
|
||||||
|
session.id,
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => setStopConfirmId(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => setStopConfirmId(session.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{deleteConfirmId === session.id ? (
|
||||||
|
<div className="delete-confirm-inline">
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={() =>
|
||||||
|
void handleDelete(
|
||||||
|
session.id,
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={() => setDeleteConfirmId(session.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -275,13 +451,24 @@ export const SessionsPage = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="recent-session-actions">
|
<div className="recent-session-actions">
|
||||||
<button
|
{session.url ? (
|
||||||
className="ghost-button small"
|
<a
|
||||||
onClick={() => handleOpen(session)}
|
href={session.url}
|
||||||
type="button"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
Open
|
className="ghost-button small"
|
||||||
</button>
|
>
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => handleOpen(session)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{deleteConfirmId === session.id ? (
|
{deleteConfirmId === session.id ? (
|
||||||
<div className="delete-confirm-inline">
|
<div className="delete-confirm-inline">
|
||||||
<button
|
<button
|
||||||
@@ -289,8 +476,8 @@ export const SessionsPage = () => {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
void handleDelete(
|
void handleDelete(
|
||||||
session.id,
|
session.id,
|
||||||
projects.find((p) => p.name === session.project_name)?.id ?? "",
|
session.project_id,
|
||||||
""
|
session.repository_id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
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 { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
type SettingsStatus = "loading" | "ready" | "error";
|
type SettingsStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ label: "General", path: "general" },
|
||||||
|
{ label: "SSH Keys", path: "ssh-keys" },
|
||||||
|
{ label: "Tool Types", path: "tool-types" },
|
||||||
|
{ label: "Tool Configs", path: "tool-configs" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "system", label: "System" },
|
{ value: "system", label: "System" },
|
||||||
{ value: "light", label: "Light" },
|
{ value: "light", label: "Light" },
|
||||||
{ value: "dark", label: "Dark" },
|
{ value: "dark", label: "Dark" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type SettingsOutletContext = {
|
||||||
|
config: UserConfig;
|
||||||
|
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
|
||||||
|
handleSave: () => Promise<void>;
|
||||||
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
|
};
|
||||||
|
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
|
const location = useLocation();
|
||||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
const [status, setStatus] = useState<SettingsStatus>("loading");
|
||||||
const [config, setConfig] = useState<UserConfig>({
|
const [config, setConfig] = useState<UserConfig>({
|
||||||
theme: "system",
|
theme: "system",
|
||||||
@@ -46,25 +62,19 @@ export const SettingsPage = () => {
|
|||||||
try {
|
try {
|
||||||
const update: UserConfigUpdate = {
|
const update: UserConfigUpdate = {
|
||||||
theme: config.theme,
|
theme: config.theme,
|
||||||
default_editor: config.default_editor ?? undefined,
|
default_editor: config.default_editor,
|
||||||
git_user_name: config.git_user_name ?? undefined,
|
git_user_name: config.git_user_name,
|
||||||
git_user_email: config.git_user_email ?? undefined,
|
git_user_email: config.git_user_email,
|
||||||
};
|
};
|
||||||
console.log("Sending update:", update);
|
|
||||||
const updated = await updateUserConfig(update);
|
const updated = await updateUserConfig(update);
|
||||||
console.log("Received response:", updated);
|
|
||||||
setConfig(updated);
|
setConfig(updated);
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
|
if (updated.theme === "system") {
|
||||||
// Apply theme immediately
|
|
||||||
const theme = updated.theme ?? "system";
|
|
||||||
if (theme === "system") {
|
|
||||||
document.documentElement.removeAttribute("data-theme");
|
document.documentElement.removeAttribute("data-theme");
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
document.documentElement.setAttribute("data-theme", updated.theme);
|
||||||
}
|
}
|
||||||
|
window.setTimeout(() => setSaveStatus("idle"), 2000);
|
||||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
|
||||||
} catch {
|
} catch {
|
||||||
setSaveStatus("error");
|
setSaveStatus("error");
|
||||||
}
|
}
|
||||||
@@ -86,81 +96,71 @@ export const SettingsPage = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parts = location.pathname.split("/").filter(Boolean);
|
||||||
|
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack settings-page">
|
||||||
<div className="page-header">
|
<header className="settings-header card stack-sm">
|
||||||
<h1>Settings</h1>
|
<div>
|
||||||
</div>
|
<p className="eyebrow">Configuration</p>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</div>
|
||||||
|
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="card stack">
|
<nav className="settings-tabs" aria-label="Settings sections">
|
||||||
<h2>Appearance</h2>
|
{TABS.map((tab) => (
|
||||||
<label className="form-field">
|
<Link
|
||||||
Theme
|
key={tab.path}
|
||||||
<select
|
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
|
||||||
value={config.theme}
|
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
|
||||||
onChange={(e) => handleChange("theme", e.target.value)}
|
|
||||||
>
|
>
|
||||||
{THEME_OPTIONS.map((opt) => (
|
{tab.label}
|
||||||
<option key={opt.value} value={opt.value}>
|
</Link>
|
||||||
{opt.label}
|
))}
|
||||||
</option>
|
</nav>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card stack">
|
<div className="settings-panel card">
|
||||||
<h2>Git Identity</h2>
|
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
|
||||||
<label className="form-field">
|
|
||||||
User Name
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.git_user_name ?? ""}
|
|
||||||
onChange={(e) => handleChange("git_user_name", e.target.value || null)}
|
|
||||||
placeholder="Your git commit name"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
User Email
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={config.git_user_email ?? ""}
|
|
||||||
onChange={(e) => handleChange("git_user_email", e.target.value || null)}
|
|
||||||
placeholder="your.email@example.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card stack">
|
|
||||||
<h2>Editor</h2>
|
|
||||||
<label className="form-field">
|
|
||||||
Default Editor
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.default_editor ?? ""}
|
|
||||||
onChange={(e) => handleChange("default_editor", e.target.value || null)}
|
|
||||||
placeholder="e.g., vscode, vim, cursor"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-actions">
|
|
||||||
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
|
||||||
{saveStatus === "saving" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="save" size="sm" />
|
|
||||||
Save Settings
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
|
||||||
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const GeneralSettingsTab = () => {
|
||||||
|
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<h2>General</h2>
|
||||||
|
<label className="form-field">
|
||||||
|
Theme
|
||||||
|
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
|
||||||
|
{THEME_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Git user name
|
||||||
|
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Git user email
|
||||||
|
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Default editor
|
||||||
|
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
|
||||||
|
</label>
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
||||||
|
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
|
||||||
|
</button>
|
||||||
|
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
||||||
|
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
export const SSHKeysPage = () => {
|
export const SSHKeysPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -61,7 +63,15 @@ export const SSHKeysPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<h1>SSH Keys</h1>
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Settings</p>
|
||||||
|
<h1>SSH Keys</h1>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||||
|
Back to settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Icon } from "../components/icon";
|
||||||
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
|
import {
|
||||||
|
createToolConfig,
|
||||||
|
deleteToolConfig,
|
||||||
|
listToolConfigs,
|
||||||
|
updateToolConfig,
|
||||||
|
type ToolConfig,
|
||||||
|
} from "../api/tool_configs";
|
||||||
|
|
||||||
|
type ConfigStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export const ToolConfigsPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState<ConfigStatus>("loading");
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
key: "",
|
||||||
|
value: "",
|
||||||
|
config_type: "env",
|
||||||
|
file_path: "",
|
||||||
|
});
|
||||||
|
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [typesData, configsData] = await Promise.all([
|
||||||
|
listToolTypes(),
|
||||||
|
listToolConfigs(),
|
||||||
|
]);
|
||||||
|
setToolTypes(typesData);
|
||||||
|
setConfigs(configsData);
|
||||||
|
if (typesData.length > 0 && !selectedToolType) {
|
||||||
|
setSelectedToolType(typesData[0].id);
|
||||||
|
}
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, [selectedToolType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaveStatus("saving");
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
tool_type_id: selectedToolType,
|
||||||
|
key: formData.key,
|
||||||
|
value: formData.value,
|
||||||
|
config_type: formData.config_type,
|
||||||
|
file_path: formData.config_type === "file" ? formData.file_path : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingConfig) {
|
||||||
|
await updateToolConfig(editingConfig.id, data);
|
||||||
|
} else {
|
||||||
|
await createToolConfig(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveStatus("saved");
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingConfig(null);
|
||||||
|
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
setSaveStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (config: ToolConfig) => {
|
||||||
|
setEditingConfig(config);
|
||||||
|
setFormData({
|
||||||
|
key: config.key,
|
||||||
|
value: config.value,
|
||||||
|
config_type: config.config_type,
|
||||||
|
file_path: config.file_path || "",
|
||||||
|
});
|
||||||
|
setSelectedToolType(config.tool_type_id);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!window.confirm("Delete this config?")) return;
|
||||||
|
try {
|
||||||
|
await deleteToolConfig(id);
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
// Error handled by UI state
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredConfigs = configs.filter(
|
||||||
|
(c) => c.tool_type_id === selectedToolType
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Tool Configurations</h1>
|
||||||
|
</div>
|
||||||
|
<p className="muted">Loading...</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Tool Configurations</h1>
|
||||||
|
</div>
|
||||||
|
<div className="card stack">
|
||||||
|
<p>Failed to load configurations</p>
|
||||||
|
<button className="secondary-button" onClick={() => void loadData()} type="button">
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Settings</p>
|
||||||
|
<h1>Tool Configurations</h1>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||||
|
Back to settings
|
||||||
|
</button>
|
||||||
|
<p className="muted">
|
||||||
|
Manage environment variables and configuration files for your tools
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Type Selector */}
|
||||||
|
<div className="card">
|
||||||
|
<label htmlFor="tool-type-select">Select Tool</label>
|
||||||
|
<select
|
||||||
|
id="tool-type-select"
|
||||||
|
value={selectedToolType}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedToolType(e.target.value);
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingConfig(null);
|
||||||
|
}}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
{toolTypes.map((tool) => (
|
||||||
|
<option key={tool.id} value={tool.id}>
|
||||||
|
{tool.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedTool && (
|
||||||
|
<p className="muted" style={{ marginTop: "0.5rem" }}>
|
||||||
|
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config List */}
|
||||||
|
<div className="card stack">
|
||||||
|
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<h2>Configuration Variables</h2>
|
||||||
|
<button
|
||||||
|
className="primary-button small"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(true);
|
||||||
|
setEditingConfig(null);
|
||||||
|
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredConfigs.length === 0 ? (
|
||||||
|
<p className="muted">No configurations for this tool yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||||
|
{filteredConfigs.map((config) => (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}>
|
||||||
|
<code style={{ fontWeight: 600 }}>{config.key}</code>
|
||||||
|
<span
|
||||||
|
className="badge"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
background: config.config_type === "env" ? "var(--color-info)" : "var(--color-warning)",
|
||||||
|
color: "white",
|
||||||
|
padding: "0.125rem 0.5rem",
|
||||||
|
borderRadius: "9999px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.config_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}>
|
||||||
|
{config.config_type === "file" && config.file_path
|
||||||
|
? `File: ${config.file_path}`
|
||||||
|
: "Environment variable"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => handleEdit(config)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => void handleDelete(config.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add/Edit Form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="card stack">
|
||||||
|
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="stack">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-key">Key</label>
|
||||||
|
<input
|
||||||
|
id="config-key"
|
||||||
|
type="text"
|
||||||
|
value={formData.key}
|
||||||
|
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||||
|
placeholder="e.g., OPENAI_API_KEY"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-type">Type</label>
|
||||||
|
<select
|
||||||
|
id="config-type"
|
||||||
|
value={formData.config_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, config_type: e.target.value })
|
||||||
|
}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
<option value="env">Environment Variable</option>
|
||||||
|
<option value="file">Configuration File</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.config_type === "file" && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-file-path">File Path</label>
|
||||||
|
<input
|
||||||
|
id="config-file-path"
|
||||||
|
type="text"
|
||||||
|
value={formData.file_path}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, file_path: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g., /app/config.json"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="config-value">Value</label>
|
||||||
|
<textarea
|
||||||
|
id="config-value"
|
||||||
|
value={formData.value}
|
||||||
|
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
|
||||||
|
placeholder={
|
||||||
|
formData.config_type === "env"
|
||||||
|
? "Enter value..."
|
||||||
|
: "Enter file contents..."
|
||||||
|
}
|
||||||
|
className="form-input"
|
||||||
|
rows={formData.config_type === "file" ? 8 : 2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingConfig(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="primary-button">
|
||||||
|
{editingConfig ? "Update" : "Add"} Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveStatus === "saved" && (
|
||||||
|
<p className="text-success" style={{ textAlign: "right" }}>
|
||||||
|
Saved successfully!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saveStatus === "error" && (
|
||||||
|
<p className="text-error" style={{ textAlign: "right" }}>
|
||||||
|
Failed to save. Please try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createToolType,
|
createToolType,
|
||||||
@@ -15,6 +16,7 @@ type ToolTypesStatus = "loading" | "ready" | "error";
|
|||||||
type DialogMode = "none" | "create" | "edit";
|
type DialogMode = "none" | "create" | "edit";
|
||||||
|
|
||||||
export const ToolTypesPage = () => {
|
export const ToolTypesPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||||
@@ -22,6 +24,9 @@ export const ToolTypesPage = () => {
|
|||||||
const [formName, setFormName] = useState("");
|
const [formName, setFormName] = useState("");
|
||||||
const [formDisplayName, setFormDisplayName] = useState("");
|
const [formDisplayName, setFormDisplayName] = useState("");
|
||||||
const [formDescription, setFormDescription] = useState("");
|
const [formDescription, setFormDescription] = useState("");
|
||||||
|
const [formCategory, setFormCategory] = useState("");
|
||||||
|
const [formInterfaces, setFormInterfaces] = useState<string[]>([]);
|
||||||
|
const [formPort, setFormPort] = useState("");
|
||||||
const [formTemplate, setFormTemplate] = useState("");
|
const [formTemplate, setFormTemplate] = useState("");
|
||||||
const [formVariables, setFormVariables] = useState("");
|
const [formVariables, setFormVariables] = useState("");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
@@ -47,6 +52,9 @@ export const ToolTypesPage = () => {
|
|||||||
setFormName("");
|
setFormName("");
|
||||||
setFormDisplayName("");
|
setFormDisplayName("");
|
||||||
setFormDescription("");
|
setFormDescription("");
|
||||||
|
setFormCategory("");
|
||||||
|
setFormInterfaces([]);
|
||||||
|
setFormPort("");
|
||||||
setFormTemplate("");
|
setFormTemplate("");
|
||||||
setFormVariables("");
|
setFormVariables("");
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -58,7 +66,10 @@ export const ToolTypesPage = () => {
|
|||||||
setFormName(toolType.name);
|
setFormName(toolType.name);
|
||||||
setFormDisplayName(toolType.display_name);
|
setFormDisplayName(toolType.display_name);
|
||||||
setFormDescription(toolType.description ?? "");
|
setFormDescription(toolType.description ?? "");
|
||||||
setFormTemplate(toolType.compose_template);
|
setFormCategory(toolType.category ?? "");
|
||||||
|
setFormInterfaces(toolType.interfaces ?? []);
|
||||||
|
setFormPort(toolType.default_port?.toString() ?? "");
|
||||||
|
setFormTemplate(toolType.compose_template ?? "");
|
||||||
setFormVariables(toolType.required_variables.join(", "));
|
setFormVariables(toolType.required_variables.join(", "));
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setEditingToolType(toolType);
|
setEditingToolType(toolType);
|
||||||
@@ -80,6 +91,11 @@ export const ToolTypesPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!formPort.trim() || isNaN(Number(formPort))) {
|
||||||
|
setFormError("Default port is required and must be a number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const variables = formVariables
|
const variables = formVariables
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
@@ -91,6 +107,9 @@ export const ToolTypesPage = () => {
|
|||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
display_name: formDisplayName.trim(),
|
display_name: formDisplayName.trim(),
|
||||||
description: formDescription.trim() || undefined,
|
description: formDescription.trim() || undefined,
|
||||||
|
category: formCategory.trim() || undefined,
|
||||||
|
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||||
|
default_port: Number(formPort),
|
||||||
compose_template: formTemplate.trim(),
|
compose_template: formTemplate.trim(),
|
||||||
required_variables: variables,
|
required_variables: variables,
|
||||||
};
|
};
|
||||||
@@ -99,6 +118,9 @@ export const ToolTypesPage = () => {
|
|||||||
const input: UpdateToolTypeRequest = {
|
const input: UpdateToolTypeRequest = {
|
||||||
display_name: formDisplayName.trim(),
|
display_name: formDisplayName.trim(),
|
||||||
description: formDescription.trim() || undefined,
|
description: formDescription.trim() || undefined,
|
||||||
|
category: formCategory.trim() || undefined,
|
||||||
|
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||||
|
default_port: Number(formPort),
|
||||||
compose_template: formTemplate.trim(),
|
compose_template: formTemplate.trim(),
|
||||||
required_variables: variables,
|
required_variables: variables,
|
||||||
};
|
};
|
||||||
@@ -145,12 +167,18 @@ export const ToolTypesPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
<div className="page-header" style={{ marginBottom: "1rem" }}>
|
||||||
<h1>Tool Types</h1>
|
<div>
|
||||||
<button onClick={openCreate}>
|
<p className="eyebrow">Settings</p>
|
||||||
|
<h1>Tool Types</h1>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
|
||||||
|
<button onClick={openCreate}>
|
||||||
<Icon name="add" size="sm" />
|
<Icon name="add" size="sm" />
|
||||||
Create Tool Type
|
Create Tool Type
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{toolTypes.length === 0 ? (
|
{toolTypes.length === 0 ? (
|
||||||
@@ -164,6 +192,13 @@ export const ToolTypesPage = () => {
|
|||||||
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-secondary">{toolType.description || "No description"}</p>
|
<p className="text-secondary">{toolType.description || "No description"}</p>
|
||||||
|
<div className="tool-type-meta">
|
||||||
|
<span>Port: {toolType.default_port || "N/A"}</span>
|
||||||
|
{toolType.interfaces?.length > 0 && (
|
||||||
|
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
||||||
|
)}
|
||||||
|
{toolType.category && <span>Category: {toolType.category}</span>}
|
||||||
|
</div>
|
||||||
<div className="card-actions">
|
<div className="card-actions">
|
||||||
{!toolType.is_builtin && (
|
{!toolType.is_builtin && (
|
||||||
<>
|
<>
|
||||||
@@ -240,6 +275,61 @@ export const ToolTypesPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Category</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formCategory}
|
||||||
|
onChange={(e) => setFormCategory(e.target.value)}
|
||||||
|
placeholder="e.g., editor, notebook, ai-assistant"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Interfaces</label>
|
||||||
|
<div className="checkbox-group">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formInterfaces.includes("web")}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setFormInterfaces([...formInterfaces, "web"]);
|
||||||
|
} else {
|
||||||
|
setFormInterfaces(formInterfaces.filter((i) => i !== "web"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Web
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formInterfaces.includes("terminal")}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setFormInterfaces([...formInterfaces, "terminal"]);
|
||||||
|
} else {
|
||||||
|
setFormInterfaces(formInterfaces.filter((i) => i !== "terminal"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Terminal
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Default Port *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formPort}
|
||||||
|
onChange={(e) => setFormPort(e.target.value)}
|
||||||
|
placeholder="e.g., 8443"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Compose Template (YAML)</label>
|
<label>Compose Template (YAML)</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ToolWorkshopPage } from "./tool-workshop";
|
||||||
|
import * as toolTypesApi from "../api/tool_types";
|
||||||
|
import * as toolConfigsApi from "../api/tool_configs";
|
||||||
|
import * as configFoldersApi from "../api/config_folders";
|
||||||
|
|
||||||
|
const mockToolTypes = [
|
||||||
|
{
|
||||||
|
id: "type-1",
|
||||||
|
name: "code-server",
|
||||||
|
display_name: "VS Code Server",
|
||||||
|
description: "VS Code in browser",
|
||||||
|
category: "editor",
|
||||||
|
interfaces: ["web"],
|
||||||
|
default_port: 8443,
|
||||||
|
definition_type: "compose",
|
||||||
|
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
|
||||||
|
dockerfile_template: null,
|
||||||
|
build_context: null,
|
||||||
|
readiness_probe: null,
|
||||||
|
required_variables: ["REPO_PATH"],
|
||||||
|
is_builtin: true,
|
||||||
|
created_by_id: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "type-2",
|
||||||
|
name: "custom-tool",
|
||||||
|
display_name: "Custom Tool",
|
||||||
|
description: "My custom tool",
|
||||||
|
category: "utility",
|
||||||
|
interfaces: ["terminal"],
|
||||||
|
default_port: 8080,
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
compose_template: null,
|
||||||
|
dockerfile_template: "FROM python:3.11",
|
||||||
|
build_context: null,
|
||||||
|
readiness_probe: {
|
||||||
|
command: "python --version",
|
||||||
|
timeout: 30,
|
||||||
|
interval: 2,
|
||||||
|
},
|
||||||
|
required_variables: [],
|
||||||
|
is_builtin: false,
|
||||||
|
created_by_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockConfigs = [
|
||||||
|
{
|
||||||
|
id: "config-1",
|
||||||
|
tool_type_id: "type-1",
|
||||||
|
project_id: null,
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
value: "sk-test123",
|
||||||
|
config_type: "env",
|
||||||
|
file_path: null,
|
||||||
|
port_override: null,
|
||||||
|
start_command: null,
|
||||||
|
working_directory: null,
|
||||||
|
environment_variables: {},
|
||||||
|
volumes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "config-2",
|
||||||
|
tool_type_id: "type-2",
|
||||||
|
project_id: null,
|
||||||
|
key: "advanced-config",
|
||||||
|
value: "test-value",
|
||||||
|
config_type: "env",
|
||||||
|
file_path: null,
|
||||||
|
port_override: 9090,
|
||||||
|
start_command: "python app.py",
|
||||||
|
working_directory: "/app",
|
||||||
|
environment_variables: { DEBUG: "true" },
|
||||||
|
volumes: [{ source: "data", target: "/data", type: "bind" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockFolders = [
|
||||||
|
{
|
||||||
|
id: "folder-1",
|
||||||
|
user_id: "user-1",
|
||||||
|
name: "my-dotfiles",
|
||||||
|
description: "My personal config files",
|
||||||
|
mount_path: "/home/user",
|
||||||
|
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
||||||
|
project_overrides: {},
|
||||||
|
is_active: true,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "folder-2",
|
||||||
|
user_id: "user-1",
|
||||||
|
name: "project-configs",
|
||||||
|
description: "Project specific configs",
|
||||||
|
mount_path: "/workspace",
|
||||||
|
files: { ".env": "API_URL=http://localhost:8080" },
|
||||||
|
project_overrides: {
|
||||||
|
"proj-1": {
|
||||||
|
mount_path: "/app",
|
||||||
|
files: { ".env": "API_URL=http://prod.api" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
is_active: false,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ToolWorkshopPage", () => {
|
||||||
|
it("renders loading state initially", () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {}));
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {}));
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tool types tab by default", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to configs tab", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("advanced-config")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to folders tab", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("project-configs")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens tool type creation form", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates tool type with compose definition", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||||
|
target: { value: "new-tool" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||||
|
target: { value: "New Tool" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||||
|
target: { value: "8080" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/compose template/i), {
|
||||||
|
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "new-tool",
|
||||||
|
display_name: "New Tool",
|
||||||
|
definition_type: "compose",
|
||||||
|
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates tool type with dockerfile definition", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||||
|
target: { value: "docker-tool" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||||
|
target: { value: "Docker Tool" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||||
|
target: { value: "3000" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to dockerfile
|
||||||
|
fireEvent.change(screen.getByLabelText("Definition Type"), {
|
||||||
|
target: { value: "dockerfile" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
|
||||||
|
target: { value: "FROM python:3.11\\nRUN pip install flask" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "docker-tool",
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows readiness probe fields", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/interval/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens config creation form", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates config with advanced fields", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/key/i), {
|
||||||
|
target: { value: "MY_CONFIG" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/value/i), {
|
||||||
|
target: { value: "my-value" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/port override/i), {
|
||||||
|
target: { value: "9090" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/start command/i), {
|
||||||
|
target: { value: "python app.py" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
key: "MY_CONFIG",
|
||||||
|
value: "my-value",
|
||||||
|
port_override: 9090,
|
||||||
|
start_command: "python app.py",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(configsListMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens folder creation form", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates config folder successfully", async () => {
|
||||||
|
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||||
|
target: { value: "new-folder" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Mount Path *"), {
|
||||||
|
target: { value: "/home/dev" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "new-folder",
|
||||||
|
mount_path: "/home/dev",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(foldersListMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows folder active/inactive status", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that active folder shows Active badge
|
||||||
|
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles error state gracefully", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error"));
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error"));
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries loading after error", async () => {
|
||||||
|
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
|
||||||
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
|
.mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs")
|
||||||
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
|
.mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders")
|
||||||
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
|
.mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes tool type successfully", async () => {
|
||||||
|
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find and click delete button for custom tool (not built-in)
|
||||||
|
const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
|
||||||
|
screen.getByText("Custom Tool").parentElement;
|
||||||
|
if (customToolCard) {
|
||||||
|
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
|
||||||
|
if (deleteButton) {
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(deleteMock).toHaveBeenCalledWith("type-2");
|
||||||
|
});
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
+19
-9
@@ -2,24 +2,29 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
|||||||
|
|
||||||
import { AppShell } from "./components/app-shell";
|
import { AppShell } from "./components/app-shell";
|
||||||
import { ProtectedRoute } from "./components/protected-route";
|
import { ProtectedRoute } from "./components/protected-route";
|
||||||
import { DashboardPage } from "./pages/dashboard";
|
import { HomePage } from "./pages/dashboard";
|
||||||
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
|
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
|
||||||
import { SessionsPage } from "./pages/sessions";
|
|
||||||
import { ProfilePage } from "./pages/profile";
|
import { ProfilePage } from "./pages/profile";
|
||||||
import { ProjectsPage } from "./pages/projects";
|
import { ProjectsPage } from "./pages/projects";
|
||||||
import { GitRepositoriesPage } from "./pages/git-repositories";
|
import { GitRepositoriesPage } from "./pages/git-repositories";
|
||||||
import { GitHistoryPage } from "./pages/git-history";
|
import { GitHistoryPage } from "./pages/git-history";
|
||||||
import { ProjectSettingsPage } from "./pages/project-settings";
|
import { ProjectSettingsPage } from "./pages/project-settings";
|
||||||
import { RepoWorkspace } from "./pages/repo-workspace";
|
import { RepoWorkspace } from "./pages/repo-workspace";
|
||||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
||||||
import { SettingsPage } from "./pages/settings";
|
|
||||||
import { TerminalPage } from "./pages/terminal";
|
import { TerminalPage } from "./pages/terminal";
|
||||||
|
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||||
|
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||||
|
import { ToolConfigsPage } from "./pages/tool-configs";
|
||||||
import { ToolTypesPage } from "./pages/tool-types";
|
import { ToolTypesPage } from "./pages/tool-types";
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginRedirectPage />} />
|
<Route path="/login" element={<LoginRedirectPage />} />
|
||||||
|
<Route path="/sessions" element={<Navigate to="/" replace />} />
|
||||||
|
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
|
||||||
|
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
|
||||||
|
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
@@ -28,17 +33,22 @@ export const AppRouter = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="sessions" element={<SessionsPage />} />
|
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
||||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
||||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
||||||
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
||||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />}>
|
||||||
<Route path="tool-types" element={<ToolTypesPage />} />
|
<Route index element={<Navigate to="general" replace />} />
|
||||||
|
<Route path="general" element={<GeneralSettingsTab />} />
|
||||||
|
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||||
|
<Route path="tool-types" element={<ToolTypesPage />} />
|
||||||
|
<Route path="tool-configs" element={<ToolConfigsPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="general" replace />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/404" element={<NotFoundPage />} />
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
tool_type_name: string;
|
tool_type_name: string;
|
||||||
tool_icon: string;
|
tool_icon: string;
|
||||||
|
tool_type_interfaces: string[];
|
||||||
repository_name: string;
|
repository_name: string;
|
||||||
|
repository_id: string;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
|
project_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
}
|
}
|
||||||
@@ -21,9 +24,9 @@ const SessionsContext = createContext<SessionsContextType | undefined>(undefined
|
|||||||
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
|
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
|
||||||
const setAllSessions = (newSessions: Session[]) => {
|
const setAllSessions = useCallback((newSessions: Session[]) => {
|
||||||
setSessions(newSessions);
|
setSessions(newSessions);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
|
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
|
||||||
|
|||||||
+172
-11
@@ -1,6 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
--bg: #f4f1ea;
|
--bg: #f4f1ea;
|
||||||
--panel: #fffef9;
|
--panel: #fffef9;
|
||||||
--ink: #1d1d1b;
|
--ink: #1d1d1b;
|
||||||
@@ -8,6 +8,17 @@
|
|||||||
--brand: #275d4b;
|
--brand: #275d4b;
|
||||||
--brand-strong: #154236;
|
--brand-strong: #154236;
|
||||||
--border: #d8d0c5;
|
--border: #d8d0c5;
|
||||||
|
--primary: #275d4b;
|
||||||
|
--primary-fg: #fffef9;
|
||||||
|
--color-primary: #275d4b;
|
||||||
|
--success: #2f8f62;
|
||||||
|
--success-light: rgba(47, 143, 98, 0.14);
|
||||||
|
--warning: #c08a1e;
|
||||||
|
--warning-light: rgba(192, 138, 30, 0.14);
|
||||||
|
--danger: #b94a3c;
|
||||||
|
--danger-light: rgba(185, 74, 60, 0.14);
|
||||||
|
--info: #4f7fb8;
|
||||||
|
--info-light: rgba(79, 127, 184, 0.14);
|
||||||
|
|
||||||
/* Spacing Scale (4px base) */
|
/* Spacing Scale (4px base) */
|
||||||
--space-1: 0.25rem;
|
--space-1: 0.25rem;
|
||||||
@@ -36,13 +47,16 @@
|
|||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #1a1a18;
|
--bg: #171613;
|
||||||
--panel: #252522;
|
--panel: #22201d;
|
||||||
--ink: #e8e6e1;
|
--ink: #ece7df;
|
||||||
--muted: #a39e96;
|
--muted: #a59d92;
|
||||||
--brand: #4a9e7f;
|
--brand: #5fa889;
|
||||||
--brand-strong: #3d8a6e;
|
--brand-strong: #4d9175;
|
||||||
--border: #3d3d38;
|
--border: #39342d;
|
||||||
|
--primary: #5fa889;
|
||||||
|
--primary-fg: #171613;
|
||||||
|
--color-primary: #5fa889;
|
||||||
--success: #22c55e;
|
--success: #22c55e;
|
||||||
--success-light: rgba(34, 197, 94, 0.15);
|
--success-light: rgba(34, 197, 94, 0.15);
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
@@ -84,12 +98,12 @@ a {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.85rem 1.25rem;
|
padding: 0.85rem 1.25rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
backdrop-filter: blur(7px);
|
backdrop-filter: blur(7px);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .shell-header {
|
[data-theme="dark"] .shell-header {
|
||||||
background: rgba(37, 37, 34, 0.85);
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -115,6 +129,7 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
@@ -133,11 +148,134 @@ a {
|
|||||||
color: #f7fff7;
|
color: #f7fff7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-section-title {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.shell-content {
|
.shell-content {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-page,
|
||||||
|
.settings-page {
|
||||||
|
max-width: 1240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-grid,
|
||||||
|
.home-project-grid,
|
||||||
|
.home-session-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-grid,
|
||||||
|
.home-session-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section h2,
|
||||||
|
.settings-header h1,
|
||||||
|
.settings-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section h3,
|
||||||
|
.home-section p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-tight {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions,
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
padding: 0.42rem 0.7rem;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card,
|
||||||
|
.project-card,
|
||||||
|
.recent-session-item {
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Shell */
|
/* Responsive Shell */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.shell-body {
|
.shell-body {
|
||||||
@@ -2555,6 +2693,21 @@ a.nav-item,
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-url {
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-url a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-url a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.session-actions {
|
.session-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
@@ -2596,9 +2749,17 @@ a.nav-item,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-confirm-inline {
|
.delete-confirm-inline,
|
||||||
|
.stop-confirm-inline {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-session-section {
|
.create-session-section {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ services:
|
|||||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
||||||
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-24}
|
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-24}
|
||||||
REPO_BASE_PATH: /data/repos
|
REPO_BASE_PATH: /data/repos
|
||||||
|
INSTANCE_BASE_PATH: /data/instances
|
||||||
API_DOMAIN: ${API_DOMAIN}
|
API_DOMAIN: ${API_DOMAIN}
|
||||||
WEB_DOMAIN: ${WEB_DOMAIN}
|
WEB_DOMAIN: ${WEB_DOMAIN}
|
||||||
AUTHENTIK_DOMAIN: ${AUTHENTIK_DOMAIN}
|
AUTHENTIK_DOMAIN: ${AUTHENTIK_DOMAIN}
|
||||||
@@ -92,7 +93,9 @@ services:
|
|||||||
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
|
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- repo_data:/data/repos
|
- repo_data:/data/repos
|
||||||
|
- instance_data:/data/instances
|
||||||
- avatar_uploads:/app/uploads
|
- avatar_uploads:/app/uploads
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -114,6 +117,7 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
repo_data:
|
repo_data:
|
||||||
|
instance_data:
|
||||||
avatar_uploads:
|
avatar_uploads:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+4
-1
@@ -53,10 +53,12 @@ services:
|
|||||||
POSTGRES_HOST: postgres
|
POSTGRES_HOST: postgres
|
||||||
POSTGRES_PORT: 5432
|
POSTGRES_PORT: 5432
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
||||||
REPO_BASE_PATH: /data/repos
|
REPO_BASE_PATH: /data/repos
|
||||||
|
INSTANCE_BASE_PATH: /data/instances
|
||||||
volumes:
|
volumes:
|
||||||
- repo_data:/data/repos
|
- repo_data:/data/repos
|
||||||
|
- instance_data:/data/instances
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -90,6 +92,7 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
repo_data:
|
repo_data:
|
||||||
|
instance_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Git repositories are managed within projects. You can create bare repositories f
|
|||||||
2. Click the **"New Repository"** button
|
2. Click the **"New Repository"** button
|
||||||
3. Fill in the form:
|
3. Fill in the form:
|
||||||
- **Name**: Repository name (required)
|
- **Name**: Repository name (required)
|
||||||
- **Remote URL**: For cloning (optional)
|
- **Owner** and **Repository**: For SSH cloning from `git.commumedia.org`
|
||||||
- **Mirror Clone**: Toggle for mirror clones
|
- **Mirror Clone**: Toggle for mirror clones
|
||||||
4. Click **"Create Repository"**
|
4. Click **"Create Repository"**
|
||||||
|
|
||||||
@@ -25,12 +25,12 @@ Creates a new bare git repository. Use this for:
|
|||||||
|
|
||||||
#### Clone from Remote
|
#### Clone from Remote
|
||||||
|
|
||||||
Enter a git URL to clone from:
|
Enter the repository owner and name to clone from `git.commumedia.org` over SSH:
|
||||||
- `https://github.com/user/repo.git`
|
- `owner`: `alice`
|
||||||
- `git@github.com:user/repo.git`
|
- `repository`: `demo`
|
||||||
- `https://gitlab.com/user/repo.git`
|
- Resulting SSH URL: `git@git.commumedia.org:alice/demo.git`
|
||||||
|
|
||||||
**Smart URL Parsing:** If you paste a browser URL (like `https://github.com/user/repo/tree/main`), the system will automatically suggest the correct git URL.
|
**Advanced fallback:** If needed, you can still paste a full git URL and the system will suggest the correct clone URL.
|
||||||
|
|
||||||
#### Mirror Clone
|
#### Mirror Clone
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-24
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The current system uses `config_folders` with a flat `files` JSONB and an `is_active` flag for auto-mounting at tool launch time. This design is inflexible: only one folder can be active, there's no ordering of includes, no explicit per-tool-instance selection, and mount definitions are mixed with file contents in a single blob.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Provide structured config profiles with named collections of mounts and includes
|
||||||
|
- Support ordered include lists so profiles can reference other profiles in sequence
|
||||||
|
- Allow per-tool-instance profile selection with fallback to user/tool-type defaults
|
||||||
|
- Remove implicit auto-mounting behavior at launch time
|
||||||
|
- Maintain backward compatibility for existing `config_folders` data during migration
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Frontend UI for profile management (separate change)
|
||||||
|
- Real-time profile switching on running instances
|
||||||
|
- Profile versioning or history
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. New `config_profiles` table replaces the semantic role of `config_folders`
|
||||||
|
- Rationale: A profile is a higher-level concept than a folder; it includes mounts, includes, and metadata
|
||||||
|
- `config_folders` remains for data migration but is no longer used for auto-mounting
|
||||||
|
|
||||||
|
### 2. `config_includes` provides ordered many-to-many self-reference on `config_profiles`
|
||||||
|
- Rationale: Profiles need to include other profiles (e.g., a "base" profile included by "project-specific")
|
||||||
|
- `order_index` column controls application order
|
||||||
|
|
||||||
|
### 3. `config_mounts` stores individual mount/file entries
|
||||||
|
- Rationale: Normalizing mounts allows querying, ordering, and validation per mount
|
||||||
|
- Each mount has a `mount_path`, optional `content` text, and optional `source_profile_id` for transitive includes
|
||||||
|
|
||||||
|
### 4. Default profile stored on `user_configs.config` JSONB
|
||||||
|
- Rationale: Avoids schema changes to `users`; the existing `user_configs` table already stores per-user JSON
|
||||||
|
- Key: `default_profile_id` (global default) and `default_profiles` map for per-tool-type defaults
|
||||||
|
|
||||||
|
### 5. `tool_instances.selected_profile_id` for explicit selection
|
||||||
|
- Rationale: Clear, direct foreign key; nullable to allow fallback to defaults
|
||||||
|
- Null means "use default resolution"
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Risk] Existing `config_folders` data becomes orphaned if not migrated → Mitigation: keep table, stop auto-mount behavior only
|
||||||
|
- [Risk] Profile include cycles could cause infinite loops → Mitigation: validate at write time, detect cycles in include graph
|
||||||
|
- [Risk] Multiple includes with overlapping mount paths → Mitigation: last-include-wins based on order_index
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The current `config_folders` table provides basic file mounting but lacks structured profile management, ordering, and per-tool-instance selection. We need a proper config profile system that supports ordered includes, mount/file definitions, default selection, and explicit profile assignment per tool instance.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add `ConfigProfile` model to replace the legacy `config_folders` concept with structured profiles
|
||||||
|
- Add `ConfigInclude` model for ordered include lists within profiles
|
||||||
|
- Add `ConfigMount` model for mount/file definitions (replacing the flat `files` JSONB on `config_folders`)
|
||||||
|
- Add default profile selection per user and tool type
|
||||||
|
- Add `selected_profile_id` to `ToolInstance` for per-instance profile selection
|
||||||
|
- Remove launch-time reliance on legacy active config folder auto-mounting (mark `config_folders.is_active` as deprecated, stop auto-mounting at launch)
|
||||||
|
- Create database migrations for all new tables
|
||||||
|
- **BREAKING**: Legacy `config_folders` auto-mounting behavior will be removed; tool instances must explicitly select a profile
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `config-profile-management`: CRUD operations for config profiles, includes, and mounts
|
||||||
|
- `tool-instance-profile-selection`: Assign and switch config profiles per tool instance
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `tool-instance-launch`: Change launch behavior to use explicit profile selection instead of auto-mounting active config folder
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- New database tables: `config_profiles`, `config_includes`, `config_mounts`
|
||||||
|
- Modified tables: `tool_instances` (add `selected_profile_id`), `users` or `user_configs` (add default profile selection)
|
||||||
|
- API endpoints for profile management and instance profile assignment
|
||||||
|
- Tool launch logic changes (remove auto-mount, use explicit profile)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: User can create config profiles
|
||||||
|
The system SHALL allow users to create named config profiles containing mounts and includes.
|
||||||
|
|
||||||
|
#### Scenario: Successful profile creation
|
||||||
|
- **WHEN** user creates a profile with name, description, and mount list
|
||||||
|
- **THEN** the profile is stored with a unique ID and associated mounts
|
||||||
|
|
||||||
|
### Requirement: Profile includes are ordered
|
||||||
|
The system SHALL support ordered includes where profiles can reference other profiles with a defined application sequence.
|
||||||
|
|
||||||
|
#### Scenario: Include with order
|
||||||
|
- **WHEN** user adds an include to a profile with order_index 1
|
||||||
|
- **THEN** the included profile's mounts are applied after order_index 0 includes
|
||||||
|
|
||||||
|
### Requirement: Config mounts define files and paths
|
||||||
|
The system SHALL store individual mount entries with mount_path, optional content, and optional source profile reference.
|
||||||
|
|
||||||
|
#### Scenario: Add mount to profile
|
||||||
|
- **WHEN** user adds a mount with mount_path "/app/config.json" and content "{}"
|
||||||
|
- **THEN** the mount is stored and linked to the profile
|
||||||
|
|
||||||
|
### Requirement: Cycle detection in includes
|
||||||
|
The system SHALL prevent creation of include cycles.
|
||||||
|
|
||||||
|
#### Scenario: Attempt cyclic include
|
||||||
|
- **WHEN** user tries to include profile B in profile A where A is already included in B
|
||||||
|
- **THEN** the system rejects the request with an error
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tool instance can have selected profile
|
||||||
|
The system SHALL allow setting an explicit config profile on a tool instance.
|
||||||
|
|
||||||
|
#### Scenario: Assign profile to instance
|
||||||
|
- **WHEN** user sets selected_profile_id on a tool instance
|
||||||
|
- **THEN** the instance stores the profile ID and uses it at launch time
|
||||||
|
|
||||||
|
### Requirement: Tool instance uses default profile when none selected
|
||||||
|
The system SHALL resolve a default profile for a tool instance when no explicit profile is selected.
|
||||||
|
|
||||||
|
#### Scenario: Fallback to user default
|
||||||
|
- **WHEN** a tool instance has no selected_profile_id
|
||||||
|
- **THEN** the system uses the user's default profile for that tool type, or the global default
|
||||||
|
|
||||||
|
### Requirement: Remove legacy auto-mount behavior
|
||||||
|
The system SHALL no longer auto-mount the active config folder at tool launch time.
|
||||||
|
|
||||||
|
#### Scenario: Launch without active folder
|
||||||
|
- **WHEN** a tool instance launches with no selected profile and no default
|
||||||
|
- **THEN** the instance starts without mounting any config folder
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## 1. Data Models and Migrations
|
||||||
|
|
||||||
|
- [x] 1.1 Create ConfigProfile model with user ownership, name, description
|
||||||
|
- [x] 1.2 Create ConfigInclude model for ordered profile self-references
|
||||||
|
- [x] 1.3 Create ConfigMount model for mount/file definitions
|
||||||
|
- [x] 1.4 Add selected_profile_id to ToolInstance model
|
||||||
|
- [x] 1.5 Add default profile fields to UserConfig model
|
||||||
|
- [x] 1.6 Create Alembic migration for new tables and columns
|
||||||
|
- [x] 1.7 Register new models in models/__init__.py
|
||||||
|
- [x] 1.8 Add migration metadata and test
|
||||||
|
|
||||||
|
## 2. Legacy Deprecation
|
||||||
|
|
||||||
|
- [x] 2.1 Mark config_folders.is_active as deprecated in model
|
||||||
|
- [ ] 2.2 Remove auto-mounting logic from tool launch (separate change)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-20
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Currently, tool instances are exposed via an API proxy endpoint that forwards requests from `/instances/{id}/proxy/` to the internal Docker container. This creates latency, adds load to the API service, and doesn't support WebSocket features well. Cloudflare Tunnel offers a better architecture where each instance gets its own HTTPS subdomain.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Each running tool instance gets a unique public HTTPS subdomain
|
||||||
|
- No manual DNS or reverse proxy configuration per instance
|
||||||
|
- Automatic cleanup when instances are stopped or deleted
|
||||||
|
- Support for WebSocket and real-time features (code-server terminal, jupyter kernels)
|
||||||
|
- Minimal latency compared to API proxy approach
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Custom domains per instance (use Cloudflare zone's wildcard)
|
||||||
|
- Advanced tunnel features (load balancing, failover, ingress rules)
|
||||||
|
- Replacing Traefik for the main app (API + frontend)
|
||||||
|
- Supporting non-HTTP protocols (TCP/UDP raw tunneling)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Cloudflare API vs cloudflared CLI
|
||||||
|
|
||||||
|
**Decision:** Use the Cloudflare REST API to create/manage tunnels, not the `cloudflared` CLI.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- The API gives us programmatic control without parsing CLI output
|
||||||
|
- We can use `httpx` (already a dependency) instead of subprocess calls
|
||||||
|
- Easier to test and mock
|
||||||
|
|
||||||
|
**Alternative considered:** Running `cloudflared tunnel create` via subprocess
|
||||||
|
- Rejected: Fragile, harder to test, requires cloudflared binary in API container
|
||||||
|
|
||||||
|
### Architecture: cloudflared as a separate container
|
||||||
|
|
||||||
|
**Decision:** Run `cloudflared` as a standalone Docker service that connects to Cloudflare and routes traffic.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Separation of concerns: API manages tunnels, cloudflared handles connectivity
|
||||||
|
- The cloudflared container can access the Docker internal network where instances run
|
||||||
|
- Easier to scale/restart independently
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Cloudflare Edge │
|
||||||
|
└──────────────────────┬──────────────────────────────────────┘
|
||||||
|
│ HTTPS
|
||||||
|
┌──────────────────────▼──────────────────────────────────────┐
|
||||||
|
│ cloudflared container │
|
||||||
|
│ (connects to Cloudflare, receives traffic for *.zone) │
|
||||||
|
└──────────┬──────────────────────────────────────────────────┘
|
||||||
|
│ Docker network
|
||||||
|
┌──────────▼──────────────────────────────────────────────────┐
|
||||||
|
│ code-server container:8443 jupyter container:8888 │
|
||||||
|
│ (tool instances on Docker network with DNS names) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subdomain naming
|
||||||
|
|
||||||
|
**Decision:** Use `instance-{short-uuid}.{zone}` format (e.g., `instance-a1b2c3d4.headquarter.commumedia.org`)
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Predictable and URL-safe
|
||||||
|
- Short enough to be readable
|
||||||
|
- UUID ensures uniqueness without exposing internal IDs
|
||||||
|
|
||||||
|
### Tunnel lifecycle
|
||||||
|
|
||||||
|
**Decision:** Create tunnel on instance start, delete on instance stop/delete.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. User clicks "Start"
|
||||||
|
2. Backend creates Cloudflare tunnel via API
|
||||||
|
3. Backend creates DNS CNAME record: `instance-abc123` → `{tunnel-id}.cfargotunnel.com`
|
||||||
|
4. Backend stores `tunnel_id` and `public_url` in ToolInstance
|
||||||
|
5. cloudflared container routes traffic to container:port
|
||||||
|
6. On stop: delete DNS record, delete tunnel
|
||||||
|
|
||||||
|
### cloudflared configuration
|
||||||
|
|
||||||
|
**Decision:** Use a single cloudflared container with dynamic config file updates.
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- The cloudflared container reads an `config.yml` file mounted as a volume
|
||||||
|
- The API writes ingress rules to this file when instances start/stop
|
||||||
|
- cloudflared automatically reloads the config (or we restart the container)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /etc/cloudflared/config.yml
|
||||||
|
tunnel: {tunnel-token}
|
||||||
|
credentials-file: /etc/cloudflared/credentials.json
|
||||||
|
ingress:
|
||||||
|
- hostname: instance-abc123.headquarter.commumedia.org
|
||||||
|
service: http://code-server-repo-abc123:8443
|
||||||
|
- hostname: instance-xyz789.headquarter.commumedia.org
|
||||||
|
service: http://jupyter-repo-def:8888
|
||||||
|
- service: http_status:404
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
**Decision:** Cloudflare tunnels provide HTTPS but do NOT handle app-level auth. Tool instances without built-in auth (like code-server) will be publicly accessible.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Cloudflare Access could add auth, but adds complexity
|
||||||
|
- Many tools (code-server) have their own password/auth mechanisms
|
||||||
|
- Users should configure tool-level auth via ToolConfig
|
||||||
|
|
||||||
|
**Mitigation:** Document that users must configure tool passwords via ToolConfig (e.g., `PASSWORD` env for code-server).
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[Risk]** Cloudflare API rate limits (1200 requests/5 min)
|
||||||
|
→ **Mitigation:** Tunnel creation is infrequent (user-initiated), unlikely to hit limits
|
||||||
|
|
||||||
|
**[Risk]** cloudflared container becomes a single point of failure
|
||||||
|
→ **Mitigation:** It's stateless; can be restarted quickly. All instances share one cloudflared.
|
||||||
|
|
||||||
|
**[Risk]** Subdomain enumeration exposes running instances
|
||||||
|
→ **Mitigation:** UUID-based names are hard to guess. Consider adding Cloudflare Access in future.
|
||||||
|
|
||||||
|
**[Risk]** cloudflared config file updates require container restart
|
||||||
|
→ **Mitigation:** Investigate `cloudflared --no-autoupdate` with config watch, or accept brief restart
|
||||||
|
|
||||||
|
**[Risk]** Tool instances publicly accessible without auth
|
||||||
|
→ **Mitigation:** Document security best practices, recommend setting tool passwords
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Deploy cloudflared container with base config
|
||||||
|
2. Add Cloudflare env vars to API container
|
||||||
|
3. Deploy backend changes (tunnel service, updated lifecycle)
|
||||||
|
4. Deploy frontend changes (use public_url instead of proxy)
|
||||||
|
5. Test with code-server instance
|
||||||
|
6. Remove old proxy endpoint code
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should we add Cloudflare Access (Zero Trust) to protect instances?
|
||||||
|
- Do we need to support custom subdomains (e.g., `myproject.headquarter.commumedia.org`)?
|
||||||
|
- Should we keep the proxy endpoint as a fallback?
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The current approach of proxying tool instances through the backend API is fragile and creates a bottleneck. Every HTTP request and WebSocket connection to a tool instance (code-server, jupyter, etc.) must pass through the FastAPI application, adding latency and consuming API resources. Cloudflare Tunnel provides a robust alternative: each instance gets its own public subdomain with automatic HTTPS, without exposing ports or requiring complex reverse proxy rules.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Replace the API proxy endpoint (`/instances/{id}/proxy/`) with Cloudflare Tunnel integration
|
||||||
|
- Run a `cloudflared` container alongside the API that manages tunnels programmatically via the Cloudflare API
|
||||||
|
- When a tool instance starts, create a unique Cloudflare Tunnel and DNS record pointing to the instance's internal container name and port
|
||||||
|
- Store the public URL (e.g., `https://instance-abc123.headquarter.commumedia.org`) in the ToolInstance model
|
||||||
|
- Update the frontend "Open" button to use the Cloudflare URL instead of the proxy path
|
||||||
|
- Remove the proxy endpoint and related code (instance_proxy.py)
|
||||||
|
- **BREAKING**: The `/instances/{id}/proxy/{path:path}` endpoint will be removed
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `cloudflare-tunnel-management`: Creating, deleting, and managing Cloudflare tunnels for tool instances via the Cloudflare API
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `instance-proxy`: The current proxy-based approach will be replaced by Cloudflare tunnels. The requirement that "The API SHALL expose an endpoint that forwards HTTP requests" is replaced by "The system SHALL provide a public URL for each running instance."
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Backend: New Cloudflare tunnel service, updated instance lifecycle (create tunnel on start, delete on stop), removed proxy code
|
||||||
|
- Frontend: Update "Open" links to use public Cloudflare URLs
|
||||||
|
- Infrastructure: New `cloudflared` Docker service, Cloudflare API token required
|
||||||
|
- Environment: New env vars: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_ZONE_ID`
|
||||||
|
- Docker: Cloudflared container must be on the same network as tool instances
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: System creates Cloudflare tunnel on instance start
|
||||||
|
When a tool instance is started, the system SHALL create a Cloudflare tunnel and DNS record to expose it publicly.
|
||||||
|
|
||||||
|
#### Scenario: Start instance creates tunnel
|
||||||
|
- **WHEN** a user starts a tool instance
|
||||||
|
- **THEN** the system calls the Cloudflare API to create a tunnel
|
||||||
|
- **AND** creates a CNAME DNS record for `instance-{id}.{zone}`
|
||||||
|
- **AND** stores the tunnel ID and public URL in the database
|
||||||
|
|
||||||
|
#### Scenario: Tunnel points to correct container
|
||||||
|
- **WHEN** a tunnel is created for an instance
|
||||||
|
- **THEN** the tunnel ingress rule maps the subdomain to the container's internal DNS name and port
|
||||||
|
|
||||||
|
### Requirement: System deletes Cloudflare tunnel on instance stop
|
||||||
|
When a tool instance is stopped or deleted, the system SHALL clean up the associated Cloudflare tunnel and DNS record.
|
||||||
|
|
||||||
|
#### Scenario: Stop instance deletes tunnel
|
||||||
|
- **WHEN** a user stops a running instance
|
||||||
|
- **THEN** the system deletes the DNS record
|
||||||
|
- **AND** deletes the Cloudflare tunnel
|
||||||
|
|
||||||
|
#### Scenario: Delete instance cleans up tunnel
|
||||||
|
- **WHEN** a user deletes an instance
|
||||||
|
- **AND** the instance has an active tunnel
|
||||||
|
- **THEN** the system deletes both the DNS record and the tunnel
|
||||||
|
|
||||||
|
### Requirement: Frontend uses public URL for instance access
|
||||||
|
The frontend SHALL display and link to the public Cloudflare URL for running instances.
|
||||||
|
|
||||||
|
#### Scenario: Open button uses public URL
|
||||||
|
- **WHEN** a user views a running instance
|
||||||
|
- **THEN** the "Open" button links to the instance's public URL
|
||||||
|
- **AND** the URL opens in a new tab
|
||||||
|
|
||||||
|
#### Scenario: Session list shows public URL
|
||||||
|
- **WHEN** a user views their sessions
|
||||||
|
- **THEN** each running session displays its public URL
|
||||||
|
|
||||||
|
### Requirement: Only instance owner can start/stop/delete tunnels
|
||||||
|
The system SHALL verify that only the instance owner can trigger tunnel creation or deletion.
|
||||||
|
|
||||||
|
#### Scenario: Owner starts instance
|
||||||
|
- **WHEN** the instance owner clicks "Start"
|
||||||
|
- **THEN** the tunnel is created successfully
|
||||||
|
|
||||||
|
#### Scenario: Non-owner attempts to start
|
||||||
|
- **WHEN** a non-owner attempts to start an instance
|
||||||
|
- **THEN** the request returns 403 Forbidden
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## 1. Infrastructure Setup
|
||||||
|
|
||||||
|
- [ ] 1.1 Add cloudflared service to docker-compose.traefik.yml
|
||||||
|
- [ ] 1.2 Create cloudflared config directory and base config
|
||||||
|
- [ ] 1.3 Add Cloudflare env vars (API token, account ID, zone ID) to .env.example
|
||||||
|
- [ ] 1.4 Mount shared config volume between API and cloudflared containers
|
||||||
|
|
||||||
|
## 2. Backend - Cloudflare Tunnel Service
|
||||||
|
|
||||||
|
- [ ] 2.1 Create `src/services/cloudflare_tunnel.py` with tunnel CRUD operations
|
||||||
|
- [ ] 2.2 Implement `create_tunnel(instance_name, container_name, port)` function
|
||||||
|
- [ ] 2.3 Implement `delete_tunnel(tunnel_id)` function
|
||||||
|
- [ ] 2.4 Implement `update_cloudflared_config()` to rewrite config.yml
|
||||||
|
- [ ] 2.5 Add Cloudflare API token validation on startup
|
||||||
|
|
||||||
|
## 3. Backend - Instance Lifecycle Updates
|
||||||
|
|
||||||
|
- [ ] 3.1 Update ToolInstance model: add `tunnel_id` and `public_url` fields
|
||||||
|
- [ ] 3.2 Create Alembic migration for new fields
|
||||||
|
- [ ] 3.3 Update `start_instance` to create tunnel and store public_url
|
||||||
|
- [ ] 3.4 Update `stop_instance` to delete tunnel and DNS record
|
||||||
|
- [ ] 3.5 Update `delete_instance` to ensure tunnel cleanup
|
||||||
|
- [ ] 3.6 Update `get_user_sessions` to include `public_url`
|
||||||
|
|
||||||
|
## 4. Backend - Cleanup
|
||||||
|
|
||||||
|
- [ ] 4.1 Remove `instance_proxy.py` router
|
||||||
|
- [ ] 4.2 Remove proxy route registration from `main.py`
|
||||||
|
- [ ] 4.3 Remove `default_port` from ToolType (no longer needed)
|
||||||
|
- [ ] 4.4 Clean up any proxy-related code
|
||||||
|
|
||||||
|
## 5. Frontend Updates
|
||||||
|
|
||||||
|
- [ ] 5.1 Update Session interface to include `public_url`
|
||||||
|
- [ ] 5.2 Update InstanceList "Open" button to use `public_url`
|
||||||
|
- [ ] 5.3 Update SessionsPage "Open" button to use `public_url`
|
||||||
|
- [ ] 5.4 Remove proxy URL construction logic
|
||||||
|
|
||||||
|
## 6. Testing and Deployment
|
||||||
|
|
||||||
|
- [ ] 6.1 Test tunnel creation with code-server instance
|
||||||
|
- [ ] 6.2 Test tunnel deletion on instance stop
|
||||||
|
- [ ] 6.3 Verify HTTPS and WebSocket support
|
||||||
|
- [ ] 6.4 Run quality gates (ruff, mypy, typecheck, lint, build)
|
||||||
|
- [ ] 6.5 Deploy and test end-to-end
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-22
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The current repository creation flow already supports cloning remote repositories via `remote_url` and can normalize pasted browser URLs. However, the UI asks for a full URL, which is awkward for the fixed provider `git.commumedia.org`. The requested behavior is to enter `owner` and `repo`, check whether the repository exists, and clone only if it does.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Accept SSH-only `owner` and `repo` inputs for cloning from `git.commumedia.org`
|
||||||
|
- Verify repository existence before clone
|
||||||
|
- Preserve full URL paste as a fallback path
|
||||||
|
- Preserve blank repository creation
|
||||||
|
- Reuse the existing repository create endpoint and shared dialog
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Supporting multiple git providers
|
||||||
|
- Adding a remote repository discovery API
|
||||||
|
- Supporting HTTPS clone flow for the new structured path
|
||||||
|
- Changing repository storage or clone behavior beyond preflight validation
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**1. Provider assumption**
|
||||||
|
- Hardcode `git.commumedia.org` for the structured clone path
|
||||||
|
- Build SSH URLs as `git@git.commumedia.org:{owner}/{repo}.git`
|
||||||
|
|
||||||
|
**2. Existence check**
|
||||||
|
- Use `git ls-remote` on the constructed SSH URL before cloning
|
||||||
|
- If the command fails, surface a repository-not-found/inaccessible error and do not clone
|
||||||
|
|
||||||
|
**3. UI structure**
|
||||||
|
- Keep the shared repository creation dialog as the single entry point
|
||||||
|
- In clone mode, collect `owner` and `repo` instead of asking for a full URL
|
||||||
|
- Keep an advanced paste-URL fallback for existing behavior and browser URL parsing
|
||||||
|
- Keep blank repository creation available in the same dialog
|
||||||
|
|
||||||
|
**4. Backend behavior**
|
||||||
|
- Reuse `POST /projects/{project_id}/repositories`
|
||||||
|
- Add preflight logic before the existing `git clone --mirror`
|
||||||
|
- Leave the database schema unchanged
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[Risk] SSH auth may still fail even if the repo exists** → Mitigation: preflight error should be explicit and user-facing.
|
||||||
|
**[Risk] Command availability** → Mitigation: reuse the same `git` dependency already required for cloning.
|
||||||
|
**[Risk] UI complexity** → Mitigation: keep the dialog shared and minimal, with fallback URL paste.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Repository creation already supports cloning from a remote URL, but the current UI only accepts a full URL. For the common fixed-provider case (`git.commumedia.org`), users should be able to enter `owner` and `repo` and have the app verify the repository exists before cloning. If the repository does not exist, the app should surface a clear error. Existing blank repository creation must remain available.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Change the shared repository create dialog to support an SSH-only clone form with `owner` and `repo`
|
||||||
|
- Build the clone target as `git@git.commumedia.org:{owner}/{repo}.git`
|
||||||
|
- Preflight clone targets with `git ls-remote` before cloning
|
||||||
|
- Return a clear error when the repository is missing or inaccessible
|
||||||
|
- Keep the current full URL paste flow as an advanced fallback
|
||||||
|
- Keep blank repository creation as a fallback option
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `git-repo`: Repository creation UX and clone validation reuse the existing create endpoint and clone path
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Frontend: `repository-create-dialog.tsx`, `git-repositories.tsx`, `repositories-settings-tab.tsx`
|
||||||
|
- Backend: `git_repositories.py` create endpoint clone preflight
|
||||||
|
- Docs: repository creation guidance must reflect SSH-only owner/repo input
|
||||||
|
- Tests: add coverage for SSH repo existence checks and fallback URL behavior
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## 1. Backend - SSH Existence Check
|
||||||
|
|
||||||
|
- [ ] 1.1 Add `git ls-remote` preflight to repository creation in `git_repositories.py`
|
||||||
|
- [ ] 1.2 Build SSH clone URL from `owner` and `repo` for `git.commumedia.org`
|
||||||
|
- [ ] 1.3 Return a clear error when the repository is missing or inaccessible
|
||||||
|
|
||||||
|
## 2. Frontend - Structured Clone Form
|
||||||
|
|
||||||
|
- [ ] 2.1 Update `RepositoryCreateDialog` clone mode to accept `owner` and `repo`
|
||||||
|
- [ ] 2.2 Keep advanced full-URL paste flow and blank repository fallback
|
||||||
|
- [ ] 2.3 Reuse the shared dialog from repository settings and repositories page
|
||||||
|
|
||||||
|
## 3. Validation and Docs
|
||||||
|
|
||||||
|
- [ ] 3.1 Update repository docs to explain SSH-only owner/repo input
|
||||||
|
- [ ] 3.2 Add tests for success, missing repo, and URL fallback behavior
|
||||||
|
|
||||||
|
## 4. Quality Gates
|
||||||
|
|
||||||
|
- [ ] 4.1 Run backend and frontend targeted tests
|
||||||
|
- [ ] 4.2 Run frontend typecheck and lint where applicable
|
||||||
|
- [ ] 4.3 Commit and push changes
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user