Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b638dccd36 | |||
| 8f7e19fdb1 | |||
| ba76f09a1c | |||
| 8b08c3886c | |||
| 5577e19782 | |||
| 268651bab0 | |||
| fc873e2d6b | |||
| 7389344b6d | |||
| 0a8f1419a6 | |||
| 09938bede4 | |||
| 073013bc61 | |||
| 865b9411da | |||
| 48d1a7d05c | |||
| ab1d3a6aa1 | |||
| d117047711 | |||
| d1c187ab16 | |||
| ecd3ba5918 | |||
| a919ff8611 | |||
| 39cf01c3c9 | |||
| c49bb028c4 | |||
| 0baf7f7750 | |||
| 6e496e102d | |||
| 7cb88a3163 | |||
| ce1a73abce | |||
| 5fddc65468 | |||
| f065e2b8c0 | |||
| f4cf286bb5 | |||
| 549d13f469 | |||
| 45cd192188 | |||
| 030a39dd5a | |||
| ed15d53493 | |||
| ffb7ef0d21 | |||
| e265c86997 | |||
| 23c876b558 | |||
| 74c65068c8 | |||
| 48277369f2 | |||
| 5f39267781 | |||
| caf11cdb8a | |||
| 2f44306089 | |||
| 6173d42ddf | |||
| 9afd559394 | |||
| 25da0c149a | |||
| 457f0d29ae | |||
| dfbd3e60a8 | |||
| 075eb6a76b | |||
| 4571bebf8e | |||
| ec45283257 | |||
| cce3fa773a | |||
| 50474f7b13 | |||
| 9c7043bab1 | |||
| 2dd2f2ab06 | |||
| e555561a2d | |||
| 4ac3d593aa | |||
| 1a3860a4d8 | |||
| 3d9ff44d1a | |||
| 12378def4d | |||
| f6f7853aa4 | |||
| 802d8f1e8c | |||
| 4f9aa7e3c2 | |||
| cb25b94cb7 | |||
| 01aaf4c78f | |||
| 96c8dd7402 | |||
| f5c2c95af0 | |||
| 06fe8623bc | |||
| f2fed518f0 | |||
| cc2a638c76 | |||
| 5cec4a7a6f | |||
| 7e0df57f8c | |||
| aea1ff95f6 | |||
| c8fdca7f60 | |||
| 014b88ee56 | |||
| b6bda3d692 | |||
| d7fb51f427 | |||
| 0d57e3501a | |||
| 3672312028 | |||
| c5f117e5b1 | |||
| 10a5c29702 | |||
| 312a646b89 | |||
| 01adc9a00f | |||
| 2e9ca52cdb | |||
| cd4eba9803 | |||
| 18646e3d1b | |||
| 8c5e1b931e | |||
| d1be2e4951 | |||
| 953ea05756 | |||
| 507b71c586 | |||
| aebcf25bf4 | |||
| ae42cac61e | |||
| e2ad7d7fb6 | |||
| 9e1334eb6d | |||
| c41993310b | |||
| cb25f21c44 | |||
| 392e85ead4 | |||
| a4bf8afac9 | |||
| a559470369 | |||
| 8cab17472e | |||
| e9d404b1ff | |||
| dab6c74046 | |||
| ca8b255148 | |||
| 02a2ad6df5 | |||
| 4ef0f108ea | |||
| dc8ef0e463 | |||
| 6a7657aeda | |||
| eca8b8815b | |||
| d0f7a97f92 | |||
| 765cb965e6 | |||
| 063a839790 | |||
| ae41a64e66 | |||
| 0901b1e832 | |||
| 7cc720786e | |||
| e167a6be12 | |||
| 0fa926284c | |||
| 5c17de0c3c | |||
| 8efadc4432 | |||
| 1e40540ef4 | |||
| 7cbbb41661 | |||
| 952a9f3234 | |||
| ab8872f79e | |||
| be4893e2a7 | |||
| b3c6a5fdc9 | |||
| b7d17cea78 | |||
| 36d6448f5f | |||
| 20a5f6a9a1 | |||
| 95a7454bee |
@@ -0,0 +1,195 @@
|
|||||||
|
---
|
||||||
|
name: sift-backlog
|
||||||
|
description: Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sift Backlog
|
||||||
|
|
||||||
|
Triage backlog tasks: prioritize, group into plans, set dependencies, and activate.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
1. List backlog tasks (`sf task backlog`)
|
||||||
|
2. Clarify and enrich each task (titles, descriptions)
|
||||||
|
3. Identify groupings and create draft plans
|
||||||
|
4. Add tasks to plans and set dependencies
|
||||||
|
5. Activate plans
|
||||||
|
6. Set task status to open
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: List Backlog Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task backlog
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Clarify and Enrich Tasks
|
||||||
|
|
||||||
|
Backlog tasks often have only a brief title with no description. Before organizing, ensure each task is well-defined.
|
||||||
|
|
||||||
|
**For each task, evaluate:**
|
||||||
|
|
||||||
|
- Is the title clear and actionable?
|
||||||
|
- Is there a description? Check with `sf task describe <task-id> --show`
|
||||||
|
- Is the scope unambiguous?
|
||||||
|
|
||||||
|
**If the title is unclear**, update it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf update <task-id> --title "Clear, actionable title"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add a description** with context, scope, and acceptance criteria:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task describe <task-id> --content "Description with:
|
||||||
|
- What needs to be done
|
||||||
|
- Why it matters
|
||||||
|
- Acceptance criteria
|
||||||
|
- Any relevant context"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use your best judgment** to interpret tasks and make reasonable decisions about scope, grouping, and priority. You have context about the codebase, project patterns, and typical development practices—leverage this knowledge rather than deferring to the user for routine decisions.
|
||||||
|
|
||||||
|
**Only ask the user for clarity when absolutely necessary:**
|
||||||
|
|
||||||
|
- The task is fundamentally ambiguous (multiple mutually exclusive interpretations)
|
||||||
|
- Critical business logic or user-facing behavior that could go wrong in meaningful ways
|
||||||
|
- External dependencies or integrations you cannot verify
|
||||||
|
|
||||||
|
**Do NOT ask about:**
|
||||||
|
|
||||||
|
- Implementation details you can reasonably infer
|
||||||
|
- Priority or grouping decisions—use your judgment
|
||||||
|
- Standard development practices (testing, code style, etc.)
|
||||||
|
- Tasks where a reasonable interpretation exists
|
||||||
|
|
||||||
|
### Step 3: Create Draft Plans
|
||||||
|
|
||||||
|
Group related tasks into plans using your best judgment. Plans start as drafts (tasks won't be dispatched until activated).
|
||||||
|
|
||||||
|
**Grouping guidance:**
|
||||||
|
|
||||||
|
- Group tasks that share a common theme, feature area, or goal
|
||||||
|
- Consider technical dependencies when grouping (tasks that touch the same files/modules)
|
||||||
|
- Separate unrelated work into distinct plans for parallel execution
|
||||||
|
- Don't over-group—if tasks are truly independent, separate plans enable better parallelism
|
||||||
|
- Don't under-group—related tasks benefit from shared context and coordinated execution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan create --title "Plan Name"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan create --title "Authentication Improvements"
|
||||||
|
# Output: Created plan el-abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Add Tasks to Plans
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan add-task <plan-id> <task-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan add-task el-abc123 el-task1
|
||||||
|
sf plan add-task el-abc123 el-task2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Set Dependencies Between Tasks
|
||||||
|
|
||||||
|
Use `blocks` dependency when one task must complete before another can start.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf dependency add <blocked-id> <blocker-id> --type blocks
|
||||||
|
```
|
||||||
|
|
||||||
|
**Semantics:** The first ID is blocked BY the second ID. The blocker must complete first.
|
||||||
|
|
||||||
|
**Example:** Task 2 can't start until Task 1 completes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf dependency add el-task2 el-task1 --type blocks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Update Priorities
|
||||||
|
|
||||||
|
Set priorities based on your assessment of impact, urgency, and dependencies. Use your judgment—you don't need user confirmation for routine prioritization.
|
||||||
|
|
||||||
|
**Priority guidance:**
|
||||||
|
|
||||||
|
- **Critical (1):** Blocking issues, security vulnerabilities, production bugs
|
||||||
|
- **High (2):** Important features with deadlines, significant user impact
|
||||||
|
- **Medium (3):** Standard feature work, most tasks default here
|
||||||
|
- **Low (4):** Nice-to-haves, minor improvements, tech debt
|
||||||
|
- **Minimal (5):** Backlog cleanup, documentation, exploratory work
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf update <task-id> --priority <1-5>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Value | Level |
|
||||||
|
| ----- | -------- |
|
||||||
|
| 1 | Critical |
|
||||||
|
| 2 | High |
|
||||||
|
| 3 | Medium |
|
||||||
|
| 4 | Low |
|
||||||
|
| 5 | Minimal |
|
||||||
|
|
||||||
|
### Step 7: Activate Plans
|
||||||
|
|
||||||
|
Once tasks are organized with dependencies set, activate plans to enable dispatch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan activate <plan-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Set Task Status to Open
|
||||||
|
|
||||||
|
Move tasks from backlog to open so they become ready for work.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf update <id> --status open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other Actions
|
||||||
|
|
||||||
|
**Close obsolete tasks:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task close <id> --reason "Won't do: <reason>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Defer tasks:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task defer <id> --until <date>
|
||||||
|
```
|
||||||
|
|
||||||
|
**View existing plans:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan list
|
||||||
|
```
|
||||||
|
|
||||||
|
**View tasks in a plan:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan tasks <plan-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- **Use your best judgment** for grouping, prioritization, and task interpretation—don't defer routine decisions to the user
|
||||||
|
- **Only escalate to the user** when ambiguity is fundamental and could lead to wasted work (mutually exclusive interpretations, critical business decisions)
|
||||||
|
- Make reasonable inferences about implementation details, scope, and priority based on codebase context
|
||||||
|
- Create plans before setting dependencies to avoid dispatch race conditions
|
||||||
|
- Always activate plans after dependencies are set
|
||||||
|
- Focus on oldest backlog items first (sorted by creation date)
|
||||||
|
- Every task should have a clear title and description before activation
|
||||||
|
- When uncertain about a minor detail, make a reasonable choice and document it in the task description—workers can ask if needed
|
||||||
@@ -48,3 +48,4 @@ apps/web/dist/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/.stoneforge/.worktrees/
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
262629
|
||||||
|
1779624255076
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Runtime data
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
daemon-state.json
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Stoneforge Configuration
|
||||||
|
|
||||||
|
database: stoneforge.db
|
||||||
|
sync:
|
||||||
|
auto_export: true
|
||||||
|
elements_file: elements.jsonl
|
||||||
|
dependencies_file: dependencies.jsonl
|
||||||
|
playbooks:
|
||||||
|
paths:
|
||||||
|
- playbooks
|
||||||
|
identity:
|
||||||
|
mode: soft
|
||||||
|
merge:
|
||||||
|
auto_merge: true
|
||||||
|
target_branch: null
|
||||||
|
require_approval: false
|
||||||
|
workflow:
|
||||||
|
preset: auto
|
||||||
|
agents:
|
||||||
|
permission_model: unrestricted
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"blockedId":"el-1of","blockerId":"el-258","type":"parent-child","createdAt":"2026-05-24T09:44:58.759Z","createdBy":"el-2jua"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"id":"el-20no","type":"plan","createdAt":"2026-05-24T12:43:50.676Z","updatedAt":"2026-05-24T12:43:50.676Z","createdBy":"el-2jua","tags":["openspec","add-config-profiles"],"metadata":{},"title":"add-config-profiles","status":"active"}
|
||||||
@@ -27,6 +27,7 @@ WORKDIR /app
|
|||||||
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 \
|
||||||
|
openssh-client \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add probe_result to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0013_add_probe_result
|
||||||
|
Revises: 0012_default_port_req
|
||||||
|
Create Date: 2026-05-22 21:45: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_probe_result"
|
||||||
|
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:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("probe_result", postgresql.JSON, nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "probe_result")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""merge migration heads
|
||||||
|
|
||||||
|
Revision ID: 0014_merge_heads
|
||||||
|
Revises: 0013_add_probe_result, 8ed7dd80973d
|
||||||
|
Create Date: 2026-05-22 21:50:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0014_merge_heads"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ("0013_add_probe_result", "8ed7dd80973d")
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""replace interfaces with interface_type and add requires_port
|
||||||
|
|
||||||
|
Revision ID: 0015_single_interface
|
||||||
|
Revises: 0014_merge_heads
|
||||||
|
Create Date: 2026-05-22 22:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0015_single_interface"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "0014_merge_heads"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dialect() -> str:
|
||||||
|
"""Get the current database dialect name."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
return conn.dialect.name
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
dialect = _get_dialect()
|
||||||
|
|
||||||
|
# Add new columns
|
||||||
|
op.add_column('tool_types', sa.Column('interface_type', sa.String(20), nullable=True))
|
||||||
|
op.add_column('tool_types', sa.Column('requires_port', sa.Boolean(), nullable=False, server_default='true'))
|
||||||
|
|
||||||
|
# Migrate data: take first element from interfaces JSON array
|
||||||
|
if dialect == 'postgresql':
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interface_type = COALESCE(
|
||||||
|
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
|
||||||
|
'web'
|
||||||
|
),
|
||||||
|
requires_port = CASE
|
||||||
|
WHEN COALESCE(
|
||||||
|
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
|
||||||
|
'web'
|
||||||
|
) = 'web' THEN true
|
||||||
|
ELSE false
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
# SQLite: interfaces is stored as JSON text, extract first array element
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interface_type = COALESCE(
|
||||||
|
(SELECT json_extract(value, '$[0]')
|
||||||
|
FROM json_each(interfaces) AS value
|
||||||
|
WHERE json_valid(interfaces)
|
||||||
|
LIMIT 1),
|
||||||
|
'web'
|
||||||
|
),
|
||||||
|
requires_port = CASE
|
||||||
|
WHEN COALESCE(
|
||||||
|
(SELECT json_extract(value, '$[0]')
|
||||||
|
FROM json_each(interfaces) AS value
|
||||||
|
WHERE json_valid(interfaces)
|
||||||
|
LIMIT 1),
|
||||||
|
'web'
|
||||||
|
) = 'web' THEN true
|
||||||
|
ELSE false
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Make interface_type non-nullable after data migration
|
||||||
|
op.alter_column('tool_types', 'interface_type', nullable=False)
|
||||||
|
|
||||||
|
# Drop old interfaces column
|
||||||
|
op.drop_column('tool_types', 'interfaces')
|
||||||
|
|
||||||
|
# Add CHECK constraint for interface_type (only on PostgreSQL; SQLite supports it too)
|
||||||
|
op.create_check_constraint('chk_interface_type', 'tool_types', sa.text("interface_type IN ('web', 'terminal')"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
dialect = _get_dialect()
|
||||||
|
|
||||||
|
# Drop CHECK constraint
|
||||||
|
op.drop_constraint('chk_interface_type', 'tool_types', type_='check')
|
||||||
|
|
||||||
|
# Add back interfaces column
|
||||||
|
if dialect == 'postgresql':
|
||||||
|
op.add_column('tool_types', sa.Column('interfaces', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='["web"]'))
|
||||||
|
|
||||||
|
# Migrate data back: wrap interface_type in array
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interfaces = jsonb_build_array(interface_type)
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
op.add_column('tool_types', sa.Column('interfaces', sa.JSON(), nullable=False, server_default='["web"]'))
|
||||||
|
|
||||||
|
# Migrate data back: wrap interface_type in array for SQLite
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interfaces = json_array(interface_type)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Drop new columns
|
||||||
|
op.drop_column('tool_types', 'requires_port')
|
||||||
|
op.drop_column('tool_types', 'interface_type')
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add_clone_mode_and_ssh_key_id
|
||||||
|
|
||||||
|
Revision ID: 2026_05_22_add_clone_mode
|
||||||
|
Revises: 0014_merge_heads
|
||||||
|
Create Date: 2026-05-22 20:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2026_05_22_add_clone_mode'
|
||||||
|
down_revision = '0015_single_interface'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add ssh_key_id to git_repositories
|
||||||
|
op.add_column('git_repositories', sa.Column('ssh_key_id', postgresql.UUID(), nullable=True))
|
||||||
|
op.create_foreign_key('fk_git_repositories_ssh_key', 'git_repositories', 'ssh_keys', ['ssh_key_id'], ['id'])
|
||||||
|
|
||||||
|
# Add clone_mode and branch to tool_instances
|
||||||
|
op.add_column('tool_instances', sa.Column('clone_mode', sa.String(20), nullable=False, server_default='mount'))
|
||||||
|
op.add_column('tool_instances', sa.Column('branch', sa.String(255), nullable=True, server_default='main'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop columns from tool_instances
|
||||||
|
op.drop_column('tool_instances', 'branch')
|
||||||
|
op.drop_column('tool_instances', 'clone_mode')
|
||||||
|
|
||||||
|
# Drop ssh_key_id from git_repositories
|
||||||
|
op.drop_constraint('fk_git_repositories_ssh_key', 'git_repositories', type_='foreignkey')
|
||||||
|
op.drop_column('git_repositories', 'ssh_key_id')
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""remove_is_builtin_from_tool_types
|
||||||
|
|
||||||
|
Revision ID: 2026_05_23_remove_is_builtin
|
||||||
|
Revises: 2026_05_22_add_clone_mode
|
||||||
|
Create Date: 2026-05-23 14:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2026_05_23_remove_is_builtin'
|
||||||
|
down_revision = 'f3d2dc90ba3a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Drop the is_builtin column from tool_types
|
||||||
|
op.execute("ALTER TABLE tool_types DROP COLUMN IF EXISTS is_builtin")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Add the is_builtin column back to tool_types
|
||||||
|
op.add_column('tool_types', sa.Column('is_builtin', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""merge_single_interface_and_clone_mode
|
||||||
|
|
||||||
|
Revision ID: f3d2dc90ba3a
|
||||||
|
Revises: 0015_single_interface, 2026_05_22_add_clone_mode
|
||||||
|
Create Date: 2026-05-24 10:43:14.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "f3d2dc90ba3a"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ("0015_single_interface", "2026_05_22_add_clone_mode")
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -14,6 +14,7 @@ from src.auth.dependencies import get_current_user_id, get_db_session
|
|||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.utils.git_files import (
|
from src.utils.git_files import (
|
||||||
commit_file,
|
commit_file,
|
||||||
@@ -34,6 +35,7 @@ from src.utils.git_control import (
|
|||||||
)
|
)
|
||||||
from src.utils.git_history import get_commit_detail, get_commit_history
|
from src.utils.git_history import get_commit_detail, get_commit_history
|
||||||
from src.utils.git_url_parser import parse_git_url
|
from src.utils.git_url_parser import parse_git_url
|
||||||
|
from src.services.ssh_keys import _get_fernet
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||||
|
|
||||||
@@ -94,41 +96,97 @@ def _build_provider_clone_url(owner: str, repo: str) -> str:
|
|||||||
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
||||||
|
|
||||||
|
|
||||||
def _preflight_remote_repository(remote_url: str) -> None:
|
def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
|
||||||
|
"""Prepare environment variables for git commands with SSH authentication.
|
||||||
|
|
||||||
|
Returns a dict of extra env vars, or None if no SSH key provided.
|
||||||
|
The caller is responsible for cleaning up the temporary key file.
|
||||||
|
"""
|
||||||
|
if ssh_key is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Decrypt private key
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
# Write to temp file with restricted permissions
|
||||||
|
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||||
|
try:
|
||||||
|
os.write(fd, private_key.encode())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
|
||||||
|
# Return env vars and the key path for cleanup
|
||||||
|
env = {
|
||||||
|
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
}
|
||||||
|
return env, key_path
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
|
||||||
"""Verify a remote repository is reachable before cloning."""
|
"""Verify a remote repository is reachable before cloning."""
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
|
||||||
|
if ssh_key is not None:
|
||||||
|
ssh_result = _prepare_ssh_env(ssh_key)
|
||||||
|
if ssh_result:
|
||||||
|
env, key_path = ssh_result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "ls-remote", remote_url],
|
["git", "ls-remote", remote_url],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="repository not found or inaccessible",
|
detail=f"repository not found or inaccessible: {result.stderr}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
|
def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
|
||||||
|
if ssh_key is not None:
|
||||||
|
ssh_result = _prepare_ssh_env(ssh_key)
|
||||||
|
if ssh_result:
|
||||||
|
env, key_path = ssh_result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "clone", remote_url, repo_path],
|
["git", "clone", remote_url, repo_path],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"failed to clone repository: {result.stderr}",
|
detail=f"failed to clone repository: {result.stderr}",
|
||||||
@@ -175,6 +233,7 @@ class GitRepositoryCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
remote_url: str | None = None
|
remote_url: str | None = None
|
||||||
force_original_url: bool = False
|
force_original_url: bool = False
|
||||||
|
ssh_key_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class URLParseRequest(BaseModel):
|
class URLParseRequest(BaseModel):
|
||||||
@@ -202,6 +261,7 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
is_mirror: bool
|
is_mirror: bool
|
||||||
remote_url: str | None
|
remote_url: str | None
|
||||||
last_push: datetime | None
|
last_push: datetime | None
|
||||||
|
ssh_key_id: uuid.UUID | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -349,8 +409,23 @@ 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"]
|
||||||
|
|
||||||
|
# Validate SSH key if provided
|
||||||
|
ssh_key_id = None
|
||||||
|
ssh_key = None
|
||||||
|
if data.ssh_key_id:
|
||||||
|
try:
|
||||||
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||||
|
if ssh_key is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
_preflight_remote_repository(remote_url)
|
_preflight_remote_repository(remote_url, ssh_key)
|
||||||
|
|
||||||
repo_path = _get_repo_path(user_id, project_id, data.name)
|
repo_path = _get_repo_path(user_id, project_id, data.name)
|
||||||
|
|
||||||
@@ -358,7 +433,7 @@ async def create_repository(
|
|||||||
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_working_repository(remote_url, repo_path)
|
_clone_working_repository(remote_url, repo_path, ssh_key)
|
||||||
else:
|
else:
|
||||||
_init_working_repository(repo_path)
|
_init_working_repository(repo_path)
|
||||||
|
|
||||||
@@ -369,6 +444,7 @@ async def create_repository(
|
|||||||
owner_id=user_id,
|
owner_id=user_id,
|
||||||
is_mirror=False,
|
is_mirror=False,
|
||||||
remote_url=remote_url,
|
remote_url=remote_url,
|
||||||
|
ssh_key_id=ssh_key_id,
|
||||||
)
|
)
|
||||||
session.add(repo)
|
session.add(repo)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -376,6 +452,64 @@ async def create_repository(
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSSHKeyRequest(BaseModel):
|
||||||
|
ssh_key_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{project_id}/repositories/{repo_id}/ssh-key",
|
||||||
|
response_model=GitRepositoryResponse,
|
||||||
|
summary="Update repository SSH key",
|
||||||
|
description="Update the SSH key associated with a repository.",
|
||||||
|
)
|
||||||
|
async def update_repository_ssh_key(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: UpdateSSHKeyRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Update the SSH key for a repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
data: Update data containing the new SSH key ID.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated repository.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
repo = await session.get(GitRepository, repo_id)
|
||||||
|
if repo is None or repo.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||||
|
|
||||||
|
# Validate SSH key if provided
|
||||||
|
if data.ssh_key_id:
|
||||||
|
try:
|
||||||
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||||
|
if ssh_key is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
||||||
|
|
||||||
|
repo.ssh_key_id = ssh_key_id
|
||||||
|
else:
|
||||||
|
repo.ssh_key_id = None
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/repositories/{repo_id}/history",
|
"/{project_id}/repositories/{repo_id}/history",
|
||||||
summary="Get repository history",
|
summary="Get repository history",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -74,6 +75,23 @@ class SSHKeyResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SignPayloadRequest(BaseModel):
|
||||||
|
payload: str
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureResponse(BaseModel):
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySignatureRequest(BaseModel):
|
||||||
|
payload: str
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySignatureResponse(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=SSHKeyResponse,
|
response_model=SSHKeyResponse,
|
||||||
@@ -166,3 +184,80 @@ async def delete_ssh_key(
|
|||||||
|
|
||||||
await session.delete(ssh_key)
|
await session.delete(ssh_key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/sign",
|
||||||
|
response_model=SignatureResponse,
|
||||||
|
summary="Sign payload",
|
||||||
|
description="Sign a payload using the SSH private key.",
|
||||||
|
)
|
||||||
|
async def sign_payload(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: SignPayloadRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> SignatureResponse:
|
||||||
|
"""Sign a payload with an SSH key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for signing.
|
||||||
|
data: Sign request containing the payload string.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded Ed25519 signature.
|
||||||
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
private_key = serialization.load_ssh_private_key(
|
||||||
|
private_key_pem.encode(), password=None
|
||||||
|
)
|
||||||
|
|
||||||
|
signature = private_key.sign(data.payload.encode())
|
||||||
|
return SignatureResponse(signature=base64.b64encode(signature).decode())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/verify",
|
||||||
|
response_model=VerifySignatureResponse,
|
||||||
|
summary="Verify signature",
|
||||||
|
description="Verify a signature against a payload using the SSH public key.",
|
||||||
|
)
|
||||||
|
async def verify_signature(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: VerifySignatureRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> VerifySignatureResponse:
|
||||||
|
"""Verify a signature with an SSH key's public key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for verification.
|
||||||
|
data: Verify request containing payload and base64-encoded signature.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Whether the signature is valid.
|
||||||
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
|
||||||
|
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
||||||
|
|
||||||
|
try:
|
||||||
|
signature = base64.b64decode(data.signature)
|
||||||
|
public_key.verify(signature, data.payload.encode())
|
||||||
|
return VerifySignatureResponse(valid=True)
|
||||||
|
except Exception:
|
||||||
|
return VerifySignatureResponse(valid=False)
|
||||||
|
|||||||
+169
-10
@@ -26,6 +26,7 @@ async def terminal_websocket(
|
|||||||
"""WebSocket endpoint for terminal access to a tool instance.
|
"""WebSocket endpoint for terminal access to a tool instance.
|
||||||
|
|
||||||
Provides an interactive terminal session inside a running tool instance container.
|
Provides an interactive terminal session inside a running tool instance container.
|
||||||
|
Sessions persist across WebSocket disconnections.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
websocket: The WebSocket connection.
|
websocket: The WebSocket connection.
|
||||||
@@ -70,32 +71,190 @@ async def terminal_websocket(
|
|||||||
await websocket.close(code=4004, reason="Instance not running")
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id)
|
# Get or create terminal session
|
||||||
# Create terminal session
|
|
||||||
try:
|
try:
|
||||||
session = await terminal_manager.create_session(
|
session = await terminal_manager.get_or_create_session(
|
||||||
instance_uuid,
|
instance_uuid,
|
||||||
instance.container_id,
|
instance.container_id,
|
||||||
websocket,
|
|
||||||
)
|
)
|
||||||
logger.info("Terminal session created successfully for instance %s", instance_id)
|
logger.info("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
||||||
|
|
||||||
|
# Attach WebSocket to session
|
||||||
|
await terminal_manager.attach_websocket(session, websocket)
|
||||||
|
logger.info("WebSocket attached to session for instance %s", instance_id)
|
||||||
|
|
||||||
# Send connected status
|
# Send connected status
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
|
||||||
# Keep connection alive until session ends
|
# Start I/O loops and heartbeat
|
||||||
# The terminal_manager handles I/O loops, we just wait here
|
read_task = asyncio.create_task(_read_loop(session, websocket))
|
||||||
while session.is_alive() and not session._closed:
|
write_task = asyncio.create_task(_write_loop(session, websocket))
|
||||||
await asyncio.sleep(0.5)
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||||
|
|
||||||
|
# Wait for either task to complete (indicating disconnect or error)
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[read_task, write_task, heartbeat_task],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel remaining tasks
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
|
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||||
await websocket.close(code=4000, reason=f"Error: {exc}")
|
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||||
finally:
|
finally:
|
||||||
# Cleanup will be handled by the session manager
|
# Detach WebSocket, don't kill session
|
||||||
|
try:
|
||||||
|
if 'session' in locals():
|
||||||
|
await terminal_manager.detach_websocket(session, websocket)
|
||||||
|
logger.info("WebSocket detached from session for instance %s", instance_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_loop(session, websocket) -> None:
|
||||||
|
"""Read output from the container and send to WebSocket."""
|
||||||
|
try:
|
||||||
|
while session.is_alive() and not session._closed:
|
||||||
|
data = await session.read_output()
|
||||||
|
if data:
|
||||||
|
try:
|
||||||
|
await websocket.send_bytes(data)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_loop(session, websocket) -> None:
|
||||||
|
"""Read input from WebSocket and send to container."""
|
||||||
|
try:
|
||||||
|
while session.is_alive() and not session._closed:
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message["type"] == "websocket.receive":
|
||||||
|
if "bytes" in message:
|
||||||
|
await session.write_input(message["bytes"])
|
||||||
|
elif "text" in message:
|
||||||
|
text = message["text"]
|
||||||
|
if text.startswith("{"):
|
||||||
|
# Control message (JSON)
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
ctrl = json.loads(text)
|
||||||
|
msg_type = ctrl.get("type")
|
||||||
|
|
||||||
|
if msg_type == "resize":
|
||||||
|
await session.resize(
|
||||||
|
ctrl.get("cols", 80),
|
||||||
|
ctrl.get("rows", 24),
|
||||||
|
)
|
||||||
|
elif msg_type == "reset":
|
||||||
|
# Reset terminal session
|
||||||
|
logger.info("Resetting terminal session for instance %s", session.instance_id)
|
||||||
|
await websocket.send_json({"type": "status", "status": "resetting"})
|
||||||
|
|
||||||
|
# Reset the session
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
session.instance_id,
|
||||||
|
session.container_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach to new session
|
||||||
|
await terminal_manager.attach_websocket(new_session, websocket)
|
||||||
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
|
||||||
|
# Update session reference and restart loops
|
||||||
|
# Note: This will cause the current loops to exit
|
||||||
|
# The WebSocket handler will create new ones
|
||||||
|
return
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
elif message["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||||
|
"""Send periodic ping messages to detect disconnections."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30) # Ping every 30 seconds
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "ping"})
|
||||||
|
except Exception:
|
||||||
|
# WebSocket is closed or broken
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset",
|
||||||
|
summary="Reset terminal session",
|
||||||
|
description="Reset the terminal session for a tool instance, killing the current shell and starting fresh.",
|
||||||
|
)
|
||||||
|
async def reset_terminal_session(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Reset the terminal session for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status message.
|
||||||
|
"""
|
||||||
|
# Get instance and verify it exists and is running
|
||||||
|
instance = await db_session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Instance is not running"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Reset the session
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Terminal session reset successfully",
|
||||||
|
"instance_id": str(instance_id),
|
||||||
|
"session_id": new_session.session_id,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to reset terminal session for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to reset terminal session: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_user_from_websocket(
|
async def _get_user_from_websocket(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ 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.ssh_key import SSHKey
|
||||||
from src.models.tool_config import ToolConfig
|
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
|
||||||
@@ -43,8 +45,10 @@ from src.services.docker import (
|
|||||||
write_env_file,
|
write_env_file,
|
||||||
write_config_folder_files,
|
write_config_folder_files,
|
||||||
)
|
)
|
||||||
|
from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory
|
||||||
from src.services.docker_build import build_image
|
from src.services.docker_build import build_image
|
||||||
from src.services.readiness_probe import execute_probe
|
from src.services.readiness_probe import execute_probe
|
||||||
|
from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||||
|
|
||||||
@@ -56,6 +60,9 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
|
|
||||||
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||||
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")
|
||||||
|
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
|
||||||
|
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')")
|
||||||
|
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning")
|
||||||
|
|
||||||
|
|
||||||
def _modify_compose_file(
|
def _modify_compose_file(
|
||||||
@@ -193,6 +200,19 @@ async def create_instance(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Validate clone mode requirements
|
||||||
|
if data.clone_mode == "clone":
|
||||||
|
if not repo.remote_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="repository does not have a remote URL for cloning"
|
||||||
|
)
|
||||||
|
if not repo.ssh_key_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="repository must have an SSH key assigned for clone mode"
|
||||||
|
)
|
||||||
|
|
||||||
# Generate unique name
|
# Generate unique name
|
||||||
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
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}"
|
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}"
|
||||||
@@ -204,6 +224,74 @@ async def create_instance(
|
|||||||
# Find free port
|
# Find free port
|
||||||
tool_port = find_free_port()
|
tool_port = find_free_port()
|
||||||
|
|
||||||
|
# Determine repo path based on clone mode
|
||||||
|
if data.clone_mode == "clone":
|
||||||
|
# Get SSH key for cloning
|
||||||
|
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
||||||
|
if ssh_key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="repository SSH key not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepare SSH key for clone operation
|
||||||
|
ssh_key_path = None
|
||||||
|
try:
|
||||||
|
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
|
||||||
|
ssh_key_path = os.path.join(ssh_dir, "id_ed25519")
|
||||||
|
|
||||||
|
# Clone repository
|
||||||
|
clone_path = clone_repository(
|
||||||
|
remote_url=repo.remote_url,
|
||||||
|
ssh_key_path=ssh_key_path,
|
||||||
|
instance_dir=instance_dir,
|
||||||
|
branch=data.branch or "main",
|
||||||
|
)
|
||||||
|
repo_path = clone_path
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to clone repository: %s", exc)
|
||||||
|
cleanup_ssh_key_files(instance_dir)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to clone repository: {exc}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
repo_path = repo.path
|
||||||
|
|
||||||
|
# Verify cloned repo has files
|
||||||
|
if data.clone_mode == "clone" and repo_path:
|
||||||
|
try:
|
||||||
|
repo_contents = os.listdir(repo_path)
|
||||||
|
if not repo_contents or (len(repo_contents) == 1 and repo_contents[0] == ".git"):
|
||||||
|
logger.error("Cloned repository at %s appears empty", repo_path)
|
||||||
|
raise RuntimeError("Cloned repository is empty")
|
||||||
|
logger.info("Verified cloned repo at %s has %d items", repo_path, len(repo_contents))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to verify cloned repository: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Cloned repository verification failed: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new local branch if requested
|
||||||
|
if data.clone_mode == "clone" and data.new_branch:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr)
|
||||||
|
raise RuntimeError(f"Failed to create branch: {result.stderr}")
|
||||||
|
logger.info("Created local branch %s in cloned repository", data.new_branch)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to create local branch: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create local branch: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
# Handle based on definition type
|
# Handle based on definition type
|
||||||
if tool_type.definition_type == "dockerfile":
|
if tool_type.definition_type == "dockerfile":
|
||||||
# Build image from Dockerfile
|
# Build image from Dockerfile
|
||||||
@@ -235,7 +323,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "{tool_port}:{tool_type.default_port}"
|
- "{tool_port}:{tool_type.default_port}"
|
||||||
volumes:
|
volumes:
|
||||||
- {repo.path}:/workspace
|
- {repo_path}:/workspace
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
"""
|
"""
|
||||||
write_compose_file(instance_dir, compose_content)
|
write_compose_file(instance_dir, compose_content)
|
||||||
@@ -243,7 +331,7 @@ services:
|
|||||||
else:
|
else:
|
||||||
# Render compose template
|
# Render compose template
|
||||||
variables = {
|
variables = {
|
||||||
"REPO_PATH": repo.path,
|
"REPO_PATH": repo_path,
|
||||||
"INSTANCE_NAME": instance_name,
|
"INSTANCE_NAME": instance_name,
|
||||||
"INSTANCE_ID": instance_name,
|
"INSTANCE_ID": instance_name,
|
||||||
"TOOL_NAME": instance_name,
|
"TOOL_NAME": instance_name,
|
||||||
@@ -252,6 +340,37 @@ services:
|
|||||||
"PROJECT_ID": str(project_id),
|
"PROJECT_ID": str(project_id),
|
||||||
}
|
}
|
||||||
compose_content = render_compose_template(tool_type.compose_template, variables)
|
compose_content = render_compose_template(tool_type.compose_template, variables)
|
||||||
|
|
||||||
|
# Safety check: for clone mode, ensure repo is mounted in compose file
|
||||||
|
if data.clone_mode == "clone" and repo_path:
|
||||||
|
import yaml
|
||||||
|
compose_data = yaml.safe_load(compose_content)
|
||||||
|
repo_mounted = False
|
||||||
|
if compose_data and "services" in compose_data:
|
||||||
|
for svc in compose_data["services"].values():
|
||||||
|
volumes = svc.get("volumes", [])
|
||||||
|
for vol in volumes:
|
||||||
|
vol_str = str(vol)
|
||||||
|
if repo_path in vol_str:
|
||||||
|
repo_mounted = True
|
||||||
|
break
|
||||||
|
if repo_mounted:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not repo_mounted:
|
||||||
|
logger.warning(
|
||||||
|
"Compose template for tool type %s does not mount repo path; adding default mount",
|
||||||
|
tool_type.name,
|
||||||
|
)
|
||||||
|
# Add default mount to first service
|
||||||
|
if compose_data and "services" in compose_data:
|
||||||
|
for svc in compose_data["services"].values():
|
||||||
|
if "volumes" not in svc:
|
||||||
|
svc["volumes"] = []
|
||||||
|
svc["volumes"].append(f"{repo_path}:/workspace")
|
||||||
|
break
|
||||||
|
compose_content = yaml.dump(compose_data, default_flow_style=False)
|
||||||
|
|
||||||
write_compose_file(instance_dir, compose_content)
|
write_compose_file(instance_dir, compose_content)
|
||||||
|
|
||||||
# Create database record
|
# Create database record
|
||||||
@@ -265,6 +384,8 @@ services:
|
|||||||
status="pending",
|
status="pending",
|
||||||
compose_path=compose_path,
|
compose_path=compose_path,
|
||||||
port=tool_port,
|
port=tool_port,
|
||||||
|
clone_mode=data.clone_mode,
|
||||||
|
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None),
|
||||||
)
|
)
|
||||||
session.add(instance)
|
session.add(instance)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -276,6 +397,8 @@ services:
|
|||||||
"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,
|
||||||
|
"clone_mode": instance.clone_mode,
|
||||||
|
"branch": instance.branch,
|
||||||
"created_at": instance.created_at.isoformat(),
|
"created_at": instance.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -334,10 +457,12 @@ async def list_instances(
|
|||||||
"display_name": i.display_name,
|
"display_name": i.display_name,
|
||||||
"tool_type_id": str(i.tool_type_id),
|
"tool_type_id": str(i.tool_type_id),
|
||||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||||
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
|
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
|
||||||
"status": i.status,
|
"status": i.status,
|
||||||
"url": i.url,
|
"url": i.url,
|
||||||
"port": i.port,
|
"port": i.port,
|
||||||
|
"clone_mode": i.clone_mode,
|
||||||
|
"branch": i.branch,
|
||||||
"created_at": i.created_at.isoformat(),
|
"created_at": i.created_at.isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -398,6 +523,8 @@ async def get_instance(
|
|||||||
"compose_path": instance.compose_path,
|
"compose_path": instance.compose_path,
|
||||||
"url": instance.url,
|
"url": instance.url,
|
||||||
"port": instance.port,
|
"port": instance.port,
|
||||||
|
"clone_mode": instance.clone_mode,
|
||||||
|
"branch": instance.branch,
|
||||||
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
||||||
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
|
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
|
||||||
"created_at": instance.created_at.isoformat(),
|
"created_at": instance.created_at.isoformat(),
|
||||||
@@ -514,6 +641,23 @@ async def start_instance(
|
|||||||
extra_volumes.extend(folder_volumes)
|
extra_volumes.extend(folder_volumes)
|
||||||
logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id)
|
logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id)
|
||||||
|
|
||||||
|
# Mount SSH key for clone-mode instances
|
||||||
|
if instance.clone_mode == "clone":
|
||||||
|
repo = await session.get(GitRepository, instance.repository_id)
|
||||||
|
if repo and repo.ssh_key_id:
|
||||||
|
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
||||||
|
if ssh_key:
|
||||||
|
try:
|
||||||
|
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
|
||||||
|
extra_volumes.append({
|
||||||
|
"source": ssh_dir,
|
||||||
|
"target": "/root/.ssh",
|
||||||
|
"type": "ro",
|
||||||
|
})
|
||||||
|
logger.info("Mounted SSH key for clone-mode instance %s", instance.id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to prepare SSH key for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
# Modify compose file if needed (port override, start command, working dir, volumes)
|
# Modify compose file if needed (port override, start command, working dir, volumes)
|
||||||
if port_override or start_command or working_directory or extra_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)
|
_modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes)
|
||||||
@@ -607,7 +751,7 @@ async def start_instance(
|
|||||||
probe_command = probe_config.get("command", "")
|
probe_command = probe_config.get("command", "")
|
||||||
probe_timeout = probe_config.get("timeout", 30)
|
probe_timeout = probe_config.get("timeout", 30)
|
||||||
probe_interval = probe_config.get("interval", 2)
|
probe_interval = probe_config.get("interval", 2)
|
||||||
elif "web" in (tool_type.interfaces or []):
|
elif tool_type.interface_type == "web":
|
||||||
# Default probe for web tools
|
# Default probe for web tools
|
||||||
probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
|
probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
|
||||||
probe_timeout = 30
|
probe_timeout = 30
|
||||||
@@ -659,22 +803,21 @@ async def start_instance(
|
|||||||
|
|
||||||
# Get tool type for default port
|
# Get tool type for default port
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
if not tool_type or not tool_type.default_port:
|
if not tool_type:
|
||||||
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.",
|
logger.error("Tool type %s not found", instance.tool_type_id)
|
||||||
instance.tool_type_id)
|
|
||||||
instance.status = "error"
|
instance.status = "error"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
|
"error": f"Tool type '{instance.tool_type_id}' not found",
|
||||||
}
|
}
|
||||||
|
|
||||||
instance_port = tool_type.default_port
|
instance_port = tool_type.default_port or 0
|
||||||
logger.info("Tool type for instance %s: name=%s, default_port=%s, interfaces=%s",
|
logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
|
||||||
instance.id, tool_type.name, instance_port, tool_type.interfaces)
|
instance.id, tool_type.name, instance_port, tool_type.interface_type)
|
||||||
|
|
||||||
# Only create Cloudflare tunnel for web-enabled tools
|
# Only create Cloudflare tunnel for web-enabled tools
|
||||||
if "web" in tool_type.interfaces:
|
if tool_type.interface_type == "web":
|
||||||
# Create temporary Cloudflare tunnel for public access
|
# Create temporary Cloudflare tunnel for public access
|
||||||
try:
|
try:
|
||||||
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)",
|
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)",
|
||||||
@@ -839,7 +982,7 @@ async def restart_instance(
|
|||||||
instance_port = tool_type.default_port
|
instance_port = tool_type.default_port
|
||||||
|
|
||||||
# Only create tunnel for web-enabled tools
|
# Only create tunnel for web-enabled tools
|
||||||
if "web" in tool_type.interfaces:
|
if tool_type.interface_type == "web":
|
||||||
# Create new temporary tunnel
|
# Create new temporary tunnel
|
||||||
try:
|
try:
|
||||||
tunnel_info = start_cloudflared_tunnel(
|
tunnel_info = start_cloudflared_tunnel(
|
||||||
@@ -889,6 +1032,7 @@ async def delete_instance(
|
|||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
|
force: bool = False,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -913,6 +1057,23 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check dirty state for clone-mode instances
|
||||||
|
if instance.clone_mode == "clone" and not force:
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path) if instance.compose_path else None
|
||||||
|
if instance_dir:
|
||||||
|
clone_path = os.path.join(instance_dir, "repo-clone")
|
||||||
|
if os.path.exists(clone_path):
|
||||||
|
is_dirty, changed_files = check_dirty_state(clone_path)
|
||||||
|
if is_dirty:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": "Repository has uncommitted changes",
|
||||||
|
"changed_files": changed_files,
|
||||||
|
"force_required": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Stop Cloudflare tunnel if exists
|
# Stop Cloudflare tunnel if exists
|
||||||
if instance.tunnel_id:
|
if instance.tunnel_id:
|
||||||
try:
|
try:
|
||||||
@@ -925,7 +1086,7 @@ async def delete_instance(
|
|||||||
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")
|
||||||
|
|
||||||
# Remove instance directory
|
# Remove instance directory (includes clone and SSH keys)
|
||||||
if instance.compose_path:
|
if instance.compose_path:
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
if os.path.exists(instance_dir):
|
if os.path.exists(instance_dir):
|
||||||
@@ -1112,7 +1273,7 @@ async def check_instance_tunnel_health(
|
|||||||
response["probe_status"] = "pending"
|
response["probe_status"] = "pending"
|
||||||
elif instance.probe_result:
|
elif instance.probe_result:
|
||||||
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
|
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
|
||||||
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))[:500]
|
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))
|
||||||
|
|
||||||
# Check tunnel health if instance has a URL and is web-enabled
|
# Check tunnel health if instance has a URL and is web-enabled
|
||||||
if instance.url and instance.status in ("running", "unhealthy"):
|
if instance.url and instance.status in ("running", "unhealthy"):
|
||||||
@@ -1122,10 +1283,14 @@ async def check_instance_tunnel_health(
|
|||||||
if tunnel_health.get("error"):
|
if tunnel_health.get("error"):
|
||||||
response["error"] = tunnel_health["error"]
|
response["error"] = tunnel_health["error"]
|
||||||
|
|
||||||
# Overall healthy only if container is running AND tunnel is healthy
|
# Overall healthy: web tools need running container + healthy tunnel;
|
||||||
|
# terminal tools only need running container
|
||||||
container_healthy = container_info["status"] == "running"
|
container_healthy = container_info["status"] == "running"
|
||||||
tunnel_healthy = response["tunnel_status"] == "healthy"
|
if instance.url:
|
||||||
response["healthy"] = container_healthy and tunnel_healthy
|
tunnel_healthy = response["tunnel_status"] == "healthy"
|
||||||
|
response["healthy"] = container_healthy and tunnel_healthy
|
||||||
|
else:
|
||||||
|
response["healthy"] = container_healthy
|
||||||
|
|
||||||
# If container is not running, override error message
|
# If container is not running, override error message
|
||||||
if not container_healthy:
|
if not container_healthy:
|
||||||
@@ -1309,13 +1474,16 @@ 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 [],
|
"tool_type_interfaces": [tool_type.interface_type] 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),
|
"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),
|
"project_id": str(instance.project_id),
|
||||||
"status": instance.status,
|
"status": instance.status,
|
||||||
"url": instance.url,
|
"url": instance.url,
|
||||||
|
"clone_mode": instance.clone_mode,
|
||||||
|
"branch": instance.branch,
|
||||||
|
"created_at": instance.created_at.isoformat() if instance.created_at else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"sessions": sessions}
|
return {"sessions": sessions}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -7,6 +8,11 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_template_vars(template: str) -> str:
|
||||||
|
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
|
||||||
|
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
@@ -37,7 +43,7 @@ class ToolTypeCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_port: int
|
default_port: int = 0
|
||||||
definition_type: str = "compose"
|
definition_type: str = "compose"
|
||||||
compose_template: str | None = None
|
compose_template: str | None = None
|
||||||
dockerfile_template: str | None = None
|
dockerfile_template: str | None = None
|
||||||
@@ -45,7 +51,8 @@ class ToolTypeCreate(BaseModel):
|
|||||||
readiness_probe: dict | None = None
|
readiness_probe: dict | None = None
|
||||||
required_variables: list[str] = []
|
required_variables: list[str] = []
|
||||||
category: str = "other"
|
category: str = "other"
|
||||||
interfaces: list[str] = ["web"]
|
interface_type: str = "web"
|
||||||
|
requires_port: bool = True
|
||||||
|
|
||||||
@field_validator("definition_type")
|
@field_validator("definition_type")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -64,8 +71,12 @@ class ToolTypeCreate(BaseModel):
|
|||||||
if v is None:
|
if v is None:
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
|
||||||
|
# Replace template variables with dummy values before YAML validation
|
||||||
|
# to avoid YAML parsing errors with {{VAR}} syntax
|
||||||
|
sanitized = _sanitize_template_vars(v)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(v)
|
parsed = yaml.safe_load(sanitized)
|
||||||
except yaml.YAMLError as e:
|
except yaml.YAMLError as e:
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
raise ValueError(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
@@ -95,48 +106,22 @@ class ToolTypeCreate(BaseModel):
|
|||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("interface_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_interface_type(cls, v: str) -> str:
|
||||||
|
if v not in ("web", "terminal"):
|
||||||
|
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("default_port")
|
@field_validator("default_port")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_default_port(cls, v: int, info) -> int:
|
def validate_default_port(cls, v: int, info) -> int:
|
||||||
|
data = info.data
|
||||||
|
requires_port = data.get("requires_port", True)
|
||||||
|
if not requires_port:
|
||||||
|
return v
|
||||||
if v <= 0 or v > 65535:
|
if v <= 0 or v > 65535:
|
||||||
raise ValueError("Port must be between 1 and 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
|
return v
|
||||||
|
|
||||||
@field_validator("required_variables")
|
@field_validator("required_variables")
|
||||||
@@ -166,6 +151,35 @@ class ToolTypeCreate(BaseModel):
|
|||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
if self.definition_type == "compose" and self.compose_template is None:
|
if self.definition_type == "compose" and self.compose_template is None:
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
|
||||||
|
# Validate that default_port is exposed in compose template (only if requires_port)
|
||||||
|
if self.requires_port and self.definition_type == "compose" and self.compose_template:
|
||||||
|
try:
|
||||||
|
sanitized = _sanitize_template_vars(self.compose_template)
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return self
|
||||||
|
|
||||||
|
port_str = str(self.default_port)
|
||||||
|
port_exposed = False
|
||||||
|
|
||||||
|
if isinstance(parsed, dict) and "services" in parsed:
|
||||||
|
for service_name, service_config in parsed["services"].items():
|
||||||
|
if isinstance(service_config, dict) and "ports" in service_config:
|
||||||
|
for port_mapping in service_config["ports"]:
|
||||||
|
if isinstance(port_mapping, str):
|
||||||
|
if port_str in port_mapping:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
elif isinstance(port_mapping, int) and port_mapping == self.default_port:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
if port_exposed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not port_exposed:
|
||||||
|
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -180,7 +194,8 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
readiness_probe: dict | None = None
|
readiness_probe: dict | None = None
|
||||||
required_variables: list[str] | None = None
|
required_variables: list[str] | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
interfaces: list[str] | None = None
|
interface_type: str | None = None
|
||||||
|
requires_port: bool | None = None
|
||||||
|
|
||||||
@field_validator("definition_type")
|
@field_validator("definition_type")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -191,6 +206,15 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("interface_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_interface_type(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v not in ("web", "terminal"):
|
||||||
|
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
@@ -202,8 +226,11 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
if definition_type and definition_type != "compose":
|
if definition_type and definition_type != "compose":
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
# Replace template variables with dummy values before YAML validation
|
||||||
|
sanitized = _sanitize_template_vars(v)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(v)
|
parsed = yaml.safe_load(sanitized)
|
||||||
except yaml.YAMLError as e:
|
except yaml.YAMLError as e:
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
raise ValueError(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
@@ -243,7 +270,8 @@ class ToolTypeResponse(BaseModel):
|
|||||||
display_name: str
|
display_name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
category: str
|
category: str
|
||||||
interfaces: list[str]
|
interface_type: str
|
||||||
|
requires_port: bool
|
||||||
default_port: int
|
default_port: int
|
||||||
definition_type: str
|
definition_type: str
|
||||||
compose_template: str | None
|
compose_template: str | None
|
||||||
@@ -251,7 +279,6 @@ class ToolTypeResponse(BaseModel):
|
|||||||
build_context: dict | None
|
build_context: dict | None
|
||||||
readiness_probe: dict | None
|
readiness_probe: dict | None
|
||||||
required_variables: list[str]
|
required_variables: list[str]
|
||||||
is_builtin: bool
|
|
||||||
created_by_id: uuid.UUID | None
|
created_by_id: uuid.UUID | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -299,8 +326,8 @@ async def create_tool_type(
|
|||||||
readiness_probe=data.readiness_probe,
|
readiness_probe=data.readiness_probe,
|
||||||
required_variables=data.required_variables,
|
required_variables=data.required_variables,
|
||||||
category=data.category,
|
category=data.category,
|
||||||
interfaces=data.interfaces,
|
interface_type=data.interface_type,
|
||||||
is_builtin=False,
|
requires_port=data.requires_port,
|
||||||
created_by_id=user.id,
|
created_by_id=user.id,
|
||||||
)
|
)
|
||||||
session.add(tool_type)
|
session.add(tool_type)
|
||||||
@@ -391,13 +418,13 @@ async def update_tool_type(
|
|||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be modified
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
|
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Validate port if being updated
|
# Validate port if being updated
|
||||||
if "default_port" in update_data:
|
requires_port = update_data.get("requires_port", tool_type.requires_port)
|
||||||
|
if "default_port" in update_data and requires_port:
|
||||||
new_port = update_data["default_port"]
|
new_port = update_data["default_port"]
|
||||||
if new_port <= 0 or new_port > 65535:
|
if new_port <= 0 or new_port > 65535:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -411,7 +438,8 @@ async def update_tool_type(
|
|||||||
template = update_data.get("compose_template", tool_type.compose_template)
|
template = update_data.get("compose_template", tool_type.compose_template)
|
||||||
if template:
|
if template:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(template)
|
sanitized = _sanitize_template_vars(template)
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
except yaml.YAMLError:
|
except yaml.YAMLError:
|
||||||
parsed = None
|
parsed = None
|
||||||
|
|
||||||
@@ -502,7 +530,8 @@ async def validate_tool_type_template(
|
|||||||
errors.append("Compose template is required")
|
errors.append("Compose template is required")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(data.compose_template)
|
sanitized = _sanitize_template_vars(data.compose_template)
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
if not isinstance(parsed, dict):
|
if not isinstance(parsed, dict):
|
||||||
errors.append("Compose template must be a YAML mapping")
|
errors.append("Compose template must be a YAML mapping")
|
||||||
elif "services" not in parsed:
|
elif "services" not in parsed:
|
||||||
@@ -559,7 +588,8 @@ async def validate_tool_type(
|
|||||||
errors.append("Compose template is empty")
|
errors.append("Compose template is empty")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(tool_type.compose_template)
|
sanitized = _sanitize_template_vars(tool_type.compose_template)
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
if not isinstance(parsed, dict):
|
if not isinstance(parsed, dict):
|
||||||
errors.append("Compose template must be a YAML mapping")
|
errors.append("Compose template must be a YAML mapping")
|
||||||
elif "services" not in parsed:
|
elif "services" not in parsed:
|
||||||
@@ -609,8 +639,7 @@ async def delete_tool_type(
|
|||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be deleted
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
|
|
||||||
|
|
||||||
await session.delete(tool_type)
|
await session.delete(tool_type)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
+2
-154
@@ -7,7 +7,7 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
from src.api.dashboard import router as dashboard_router
|
||||||
@@ -25,13 +25,12 @@ from src.api.tool_types import router as tool_types_router
|
|||||||
from src.api.user_config import router as user_config_router
|
from src.api.user_config import router as user_config_router
|
||||||
from src.api.users import router as users_router
|
from src.api.users import router as users_router
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal, init_database
|
from src.database import init_database
|
||||||
from src.logging_config import (
|
from src.logging_config import (
|
||||||
ExceptionLoggingMiddleware,
|
ExceptionLoggingMiddleware,
|
||||||
RequestLoggingMiddleware,
|
RequestLoggingMiddleware,
|
||||||
configure_logging,
|
configure_logging,
|
||||||
)
|
)
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
|
|
||||||
# Configure logging early
|
# Configure logging early
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
@@ -103,155 +102,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _table_exists(session, table_name: str) -> bool:
|
|
||||||
"""Check if a table exists in the database."""
|
|
||||||
try:
|
|
||||||
result = await session.execute(
|
|
||||||
text("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = :table_name
|
|
||||||
)
|
|
||||||
"""),
|
|
||||||
{"table_name": table_name},
|
|
||||||
)
|
|
||||||
return result.scalar() or False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def seed_builtin_tool_types():
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
# Check if tool_types table exists before attempting to seed
|
|
||||||
if not await _table_exists(session, "tool_types"):
|
|
||||||
logger.warning(
|
|
||||||
"tool_types table does not exist. Skipping seeding. "
|
|
||||||
"Migrations may not have run yet."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
builtin_types = [
|
|
||||||
{
|
|
||||||
"name": "code-server",
|
|
||||||
"display_name": "VS Code Server",
|
|
||||||
"description": "VS Code running in the browser via code-server",
|
|
||||||
"category": "editor",
|
|
||||||
"interfaces": ["web"],
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
code-server:
|
|
||||||
image: lscr.io/linuxserver/code-server:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- PUID=1000
|
|
||||||
- PGID=1000
|
|
||||||
- TZ=Europe/London
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/config/workspace
|
|
||||||
ports:
|
|
||||||
- "8443:8443"
|
|
||||||
restart: unless-stopped""",
|
|
||||||
"default_port": 8443,
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "jupyter-notebook",
|
|
||||||
"display_name": "Jupyter Notebook",
|
|
||||||
"description": "Jupyter Lab for interactive development",
|
|
||||||
"category": "notebook",
|
|
||||||
"interfaces": ["web"],
|
|
||||||
"default_port": 8888,
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
jupyter:
|
|
||||||
image: jupyter/scipy-notebook:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- JUPYTER_ENABLE_LAB=yes
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/home/jovyan/work
|
|
||||||
ports:
|
|
||||||
- "8888:8888"
|
|
||||||
restart: unless-stopped""",
|
|
||||||
"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:
|
|
||||||
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
|
||||||
if not existing:
|
|
||||||
tool_type = ToolType(
|
|
||||||
name=tool_data["name"],
|
|
||||||
display_name=tool_data["display_name"],
|
|
||||||
description=tool_data["description"],
|
|
||||||
category=tool_data["category"],
|
|
||||||
interfaces=tool_data["interfaces"],
|
|
||||||
definition_type="compose",
|
|
||||||
compose_template=tool_data["compose_template"],
|
|
||||||
required_variables=tool_data["required_variables"],
|
|
||||||
default_port=tool_data.get("default_port"),
|
|
||||||
is_builtin=True,
|
|
||||||
)
|
|
||||||
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()
|
|
||||||
logger.info("Built-in tool types seeded successfully.")
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def on_startup():
|
async def on_startup():
|
||||||
logger.info("Starting up Headquarter API...")
|
logger.info("Starting up Headquarter API...")
|
||||||
@@ -263,8 +113,6 @@ async def on_startup():
|
|||||||
import sys
|
import sys
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Seed built-in data
|
|
||||||
await seed_builtin_tool_types()
|
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("ssh_keys.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
project: Mapped["Project"] = relationship(back_populates="repositories")
|
project: Mapped["Project"] = relationship(back_populates="repositories")
|
||||||
owner: Mapped["User"] = relationship()
|
owner: Mapped["User"] = relationship()
|
||||||
|
ssh_key: Mapped["SSHKey | None"] = relationship()
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
probe_result: Mapped[dict | None] = mapped_column(
|
probe_result: Mapped[dict | None] = mapped_column(
|
||||||
JSON, nullable=True
|
JSON, nullable=True
|
||||||
)
|
)
|
||||||
|
clone_mode: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="mount"
|
||||||
|
)
|
||||||
|
branch: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True, default="main"
|
||||||
|
)
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||||
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
|
||||||
|
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
default_port: Mapped[int] = mapped_column(nullable=False)
|
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||||
definition_type: Mapped[str] = mapped_column(
|
definition_type: Mapped[str] = mapped_column(
|
||||||
String(20), nullable=False, default="compose"
|
String(20), nullable=False, default="compose"
|
||||||
@@ -30,7 +31,6 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
)
|
)
|
||||||
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, 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(
|
||||||
UUID(),
|
UUID(),
|
||||||
ForeignKey("users.id"),
|
ForeignKey("users.id"),
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Clone service for repository cloning and dirty state checking."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def clone_repository(
|
||||||
|
remote_url: str,
|
||||||
|
ssh_key_path: str | None,
|
||||||
|
instance_dir: str,
|
||||||
|
branch: str = "main",
|
||||||
|
) -> str:
|
||||||
|
"""Clone a git repository into the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
remote_url: Git remote URL (SSH or HTTPS)
|
||||||
|
ssh_key_path: Path to SSH private key for authentication (optional)
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
branch: Branch to clone (default: main)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the cloned repository
|
||||||
|
"""
|
||||||
|
clone_path = Path(instance_dir) / "repo-clone"
|
||||||
|
clone_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
if ssh_key_path:
|
||||||
|
# Use SSH key for cloning
|
||||||
|
env["GIT_SSH_COMMAND"] = f"ssh -i {ssh_key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--branch", branch,
|
||||||
|
"--single-branch",
|
||||||
|
remote_url,
|
||||||
|
str(clone_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Git clone failed: %s", result.stderr)
|
||||||
|
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
||||||
|
|
||||||
|
logger.info("Successfully cloned repository into %s", clone_path)
|
||||||
|
return str(clone_path)
|
||||||
|
|
||||||
|
|
||||||
|
def check_dirty_state(clone_path: str) -> tuple[bool, list[str]]:
|
||||||
|
"""Check for uncommitted changes in a cloned repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
clone_path: Path to the cloned repository
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_dirty, list_of_changed_files)
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", clone_path, "status", "--short"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning("Failed to check git status: %s", result.stderr)
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
changed_files = [line.strip() for line in result.stdout.split("\n") if line.strip()]
|
||||||
|
is_dirty = len(changed_files) > 0
|
||||||
|
|
||||||
|
return is_dirty, changed_files
|
||||||
|
|
||||||
|
|
||||||
|
def remove_clone_directory(instance_dir: str) -> None:
|
||||||
|
"""Remove the cloned repository from the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
"""
|
||||||
|
clone_path = Path(instance_dir) / "repo-clone"
|
||||||
|
if clone_path.exists():
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(clone_path)
|
||||||
|
logger.info("Removed clone directory: %s", clone_path)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""SSH key service utilities for preparing keys for container use."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
|
||||||
|
key = base64.urlsafe_b64encode(key_bytes)
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str:
|
||||||
|
"""Decrypt and write SSH key files to instance directory for container mounting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
ssh_key: SSHKey model instance with encrypted private key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the .ssh directory
|
||||||
|
"""
|
||||||
|
ssh_dir = Path(instance_dir) / ".ssh"
|
||||||
|
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Decrypt private key
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
# Write private key with restricted permissions
|
||||||
|
private_key_path = ssh_dir / "id_ed25519"
|
||||||
|
private_key_path.write_text(private_key)
|
||||||
|
os.chmod(private_key_path, 0o600)
|
||||||
|
|
||||||
|
# Write public key
|
||||||
|
public_key_path = ssh_dir / "id_ed25519.pub"
|
||||||
|
public_key_path.write_text(ssh_key.public_key)
|
||||||
|
os.chmod(public_key_path, 0o644)
|
||||||
|
|
||||||
|
# Write SSH config
|
||||||
|
config_path = ssh_dir / "config"
|
||||||
|
config_content = """Host *
|
||||||
|
StrictHostKeyChecking no
|
||||||
|
UserKnownHostsFile /dev/null
|
||||||
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
|
IdentitiesOnly yes
|
||||||
|
"""
|
||||||
|
config_path.write_text(config_content)
|
||||||
|
os.chmod(config_path, 0o644)
|
||||||
|
|
||||||
|
return str(ssh_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_ssh_key_files(instance_dir: str) -> None:
|
||||||
|
"""Remove temporary SSH key files from instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
"""
|
||||||
|
ssh_dir = Path(instance_dir) / ".ssh"
|
||||||
|
if ssh_dir.exists():
|
||||||
|
for file_path in ssh_dir.iterdir():
|
||||||
|
file_path.unlink()
|
||||||
|
ssh_dir.rmdir()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Terminal session manager for WebSocket connections."""
|
"""Terminal session manager for WebSocket connections."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -8,81 +9,141 @@ from fastapi import WebSocket
|
|||||||
|
|
||||||
from src.services.terminal_session import TerminalSession
|
from src.services.terminal_session import TerminalSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TerminalManager:
|
class TerminalManager:
|
||||||
"""Manages active terminal sessions."""
|
"""Manages active terminal sessions with persistence support."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
# Track sessions by instance_id for persistence
|
||||||
self._sessions: dict[str, TerminalSession] = {}
|
self._sessions: dict[str, TerminalSession] = {}
|
||||||
|
self._idle_check_task: asyncio.Task | None = None
|
||||||
|
self._start_idle_check()
|
||||||
|
|
||||||
async def create_session(
|
def _start_idle_check(self) -> None:
|
||||||
|
"""Start the idle timeout background task."""
|
||||||
|
if self._idle_check_task is not None and not self._idle_check_task.done():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._idle_check_task = loop.create_task(self._idle_check_loop())
|
||||||
|
except RuntimeError:
|
||||||
|
# No event loop running yet, will be started lazily
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _idle_check_loop(self) -> None:
|
||||||
|
"""Periodically check for idle sessions and clean them up."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(60) # Check every minute
|
||||||
|
await self._cleanup_idle_sessions()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error in idle check loop: %s", exc)
|
||||||
|
|
||||||
|
async def _cleanup_idle_sessions(self) -> None:
|
||||||
|
"""Clean up sessions that have been idle for too long."""
|
||||||
|
idle_sessions = []
|
||||||
|
for instance_id, session in list(self._sessions.items()):
|
||||||
|
if session.is_idle():
|
||||||
|
idle_sessions.append(instance_id)
|
||||||
|
|
||||||
|
for instance_id in idle_sessions:
|
||||||
|
logger.info("Cleaning up idle terminal session for instance %s", instance_id)
|
||||||
|
session = self._sessions.pop(instance_id, None)
|
||||||
|
if session:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
async def get_or_create_session(
|
||||||
self,
|
self,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
container_id: str,
|
container_id: str,
|
||||||
websocket: WebSocket,
|
|
||||||
) -> TerminalSession:
|
) -> TerminalSession:
|
||||||
"""Create a new terminal session."""
|
"""Get existing session or create a new one."""
|
||||||
|
# Ensure idle check is running (lazy start)
|
||||||
|
self._start_idle_check()
|
||||||
|
|
||||||
|
instance_id_str = str(instance_id)
|
||||||
|
|
||||||
|
# Check for existing session
|
||||||
|
if instance_id_str in self._sessions:
|
||||||
|
session = self._sessions[instance_id_str]
|
||||||
|
|
||||||
|
# Check if session is still alive
|
||||||
|
if session.is_alive():
|
||||||
|
logger.info("Reattaching to existing terminal session for instance %s", instance_id)
|
||||||
|
return session
|
||||||
|
else:
|
||||||
|
# Session died, clean it up
|
||||||
|
logger.info("Existing session for instance %s is dead, cleaning up", instance_id)
|
||||||
|
await session.close()
|
||||||
|
del self._sessions[instance_id_str]
|
||||||
|
|
||||||
|
# Create new session
|
||||||
|
logger.info("Creating new terminal session for instance %s", instance_id)
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
session = TerminalSession(session_id, instance_id, container_id)
|
session = TerminalSession(session_id, instance_id, container_id)
|
||||||
await session.start()
|
await session.start()
|
||||||
self._sessions[session_id] = session
|
self._sessions[instance_id_str] = session
|
||||||
|
|
||||||
# Start background tasks for I/O streaming
|
|
||||||
asyncio.create_task(self._read_loop(session, websocket))
|
|
||||||
asyncio.create_task(self._write_loop(session, websocket))
|
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
async def attach_websocket(
|
||||||
"""Read output from the container and send to WebSocket."""
|
self,
|
||||||
try:
|
session: TerminalSession,
|
||||||
while session.is_alive() and not session._closed:
|
websocket: WebSocket,
|
||||||
data = await session.read_output()
|
) -> None:
|
||||||
if data:
|
"""Attach a WebSocket to an existing session."""
|
||||||
await websocket.send_bytes(data)
|
# Handle concurrent connections - close existing ones
|
||||||
else:
|
if session.has_websockets():
|
||||||
await asyncio.sleep(0.01)
|
logger.info("Closing existing WebSocket connections for instance %s", session.instance_id)
|
||||||
except Exception:
|
for ws in list(session._websockets):
|
||||||
pass
|
try:
|
||||||
finally:
|
await ws.close(code=4000, reason="New connection established")
|
||||||
await self._cleanup_session(session)
|
except Exception:
|
||||||
|
pass
|
||||||
|
session._websockets.clear()
|
||||||
|
|
||||||
async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
# Attach new WebSocket
|
||||||
"""Read input from WebSocket and send to container."""
|
session.attach_websocket(websocket)
|
||||||
try:
|
|
||||||
while session.is_alive() and not session._closed:
|
|
||||||
message = await websocket.receive()
|
|
||||||
if message["type"] == "websocket.receive":
|
|
||||||
if "bytes" in message:
|
|
||||||
await session.write_input(message["bytes"])
|
|
||||||
elif "text" in message:
|
|
||||||
text = message["text"]
|
|
||||||
if text.startswith("{"):
|
|
||||||
# Control message (JSON)
|
|
||||||
import json
|
|
||||||
try:
|
|
||||||
ctrl = json.loads(text)
|
|
||||||
if ctrl.get("type") == "resize":
|
|
||||||
await session.resize(
|
|
||||||
ctrl.get("cols", 80),
|
|
||||||
ctrl.get("rows", 24),
|
|
||||||
)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
await session.write_input(text.encode("utf-8"))
|
|
||||||
elif message["type"] == "websocket.disconnect":
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
await self._cleanup_session(session)
|
|
||||||
|
|
||||||
async def _cleanup_session(self, session: TerminalSession) -> None:
|
# Replay buffer
|
||||||
"""Clean up a session."""
|
buffer = session.get_buffer()
|
||||||
if session.session_id in self._sessions:
|
if buffer:
|
||||||
del self._sessions[session.session_id]
|
try:
|
||||||
await session.close()
|
await websocket.send_bytes(buffer)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def detach_websocket(
|
||||||
|
self,
|
||||||
|
session: TerminalSession,
|
||||||
|
websocket: WebSocket,
|
||||||
|
) -> None:
|
||||||
|
"""Detach a WebSocket from a session."""
|
||||||
|
session.detach_websocket(websocket)
|
||||||
|
|
||||||
|
async def reset_session(
|
||||||
|
self,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
container_id: str,
|
||||||
|
) -> TerminalSession:
|
||||||
|
"""Reset a session by killing it and creating a new one."""
|
||||||
|
instance_id_str = str(instance_id)
|
||||||
|
|
||||||
|
# Close existing session if any
|
||||||
|
if instance_id_str in self._sessions:
|
||||||
|
logger.info("Resetting terminal session for instance %s", instance_id)
|
||||||
|
old_session = self._sessions.pop(instance_id_str)
|
||||||
|
await old_session.close()
|
||||||
|
|
||||||
|
# Create new session
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
session = TerminalSession(session_id, instance_id, container_id)
|
||||||
|
await session.start()
|
||||||
|
self._sessions[instance_id_str] = session
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
async def close_all(self) -> None:
|
async def close_all(self) -> None:
|
||||||
"""Close all active sessions."""
|
"""Close all active sessions."""
|
||||||
@@ -91,6 +152,9 @@ class TerminalManager:
|
|||||||
for session in sessions:
|
for session in sessions:
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
if self._idle_check_task and not self._idle_check_task.done():
|
||||||
|
self._idle_check_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
# Global terminal manager instance
|
# Global terminal manager instance
|
||||||
terminal_manager = TerminalManager()
|
terminal_manager = TerminalManager()
|
||||||
|
|||||||
@@ -6,12 +6,24 @@ import pty
|
|||||||
import select
|
import select
|
||||||
import struct
|
import struct
|
||||||
import fcntl
|
import fcntl
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class TerminalSession:
|
class TerminalSession:
|
||||||
"""Manages a single terminal session connected to a docker container."""
|
"""Manages a single terminal session connected to a docker container.
|
||||||
|
|
||||||
|
Supports persistent sessions that survive WebSocket disconnections.
|
||||||
|
Multiple WebSocket connections can attach/detach from the same session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Circular buffer size (10KB)
|
||||||
|
BUFFER_SIZE = 10 * 1024
|
||||||
|
|
||||||
|
# Idle timeout in seconds (30 minutes)
|
||||||
|
IDLE_TIMEOUT = 30 * 60
|
||||||
|
|
||||||
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
|
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
@@ -22,13 +34,27 @@ class TerminalSession:
|
|||||||
self._master_fd: int | None = None
|
self._master_fd: int | None = None
|
||||||
self._slave_fd: int | None = None
|
self._slave_fd: int | None = None
|
||||||
|
|
||||||
|
# Circular buffer for output replay
|
||||||
|
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
||||||
|
self._buffer_size = 0
|
||||||
|
|
||||||
|
# WebSocket connections
|
||||||
|
self._websockets: set[Any] = set()
|
||||||
|
|
||||||
|
# Activity tracking
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
# Terminal size
|
||||||
|
self._cols = 80
|
||||||
|
self._rows = 24
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the docker exec process with a shell using a PTY."""
|
"""Start the docker exec process with a shell using a PTY."""
|
||||||
# Create a pseudo-terminal on the host
|
# Create a pseudo-terminal on the host
|
||||||
self._master_fd, self._slave_fd = pty.openpty()
|
self._master_fd, self._slave_fd = pty.openpty()
|
||||||
|
|
||||||
# Set the terminal size initially
|
# Set the terminal size initially
|
||||||
self._set_terminal_size(80, 24)
|
self._set_terminal_size(self._cols, self._rows)
|
||||||
|
|
||||||
# Start docker exec with the slave fd as stdin/stdout/stderr
|
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||||
# Using -it because the slave fd IS a TTY
|
# Using -it because the slave fd IS a TTY
|
||||||
@@ -50,6 +76,8 @@ class TerminalSession:
|
|||||||
os.close(self._slave_fd)
|
os.close(self._slave_fd)
|
||||||
self._slave_fd = None
|
self._slave_fd = None
|
||||||
|
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||||
"""Set the terminal size using TIOCSWINSZ."""
|
"""Set the terminal size using TIOCSWINSZ."""
|
||||||
if self._master_fd is None:
|
if self._master_fd is None:
|
||||||
@@ -63,24 +91,43 @@ class TerminalSession:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def read_output(self) -> bytes:
|
async def read_output(self) -> bytes:
|
||||||
"""Read output from the PTY master."""
|
"""Read output from the PTY master and store in buffer."""
|
||||||
if self._master_fd is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
return b""
|
return b""
|
||||||
try:
|
try:
|
||||||
# Use select to check if data is available
|
# Use select to check if data is available
|
||||||
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
||||||
if readable:
|
if readable:
|
||||||
return os.read(self._master_fd, 4096)
|
data = os.read(self._master_fd, 4096)
|
||||||
|
if data:
|
||||||
|
self._add_to_buffer(data)
|
||||||
|
self.last_activity = time.time()
|
||||||
|
return data
|
||||||
return b""
|
return b""
|
||||||
except (OSError, IOError, ValueError):
|
except (OSError, IOError, ValueError):
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
def _add_to_buffer(self, data: bytes) -> None:
|
||||||
|
"""Add data to circular buffer, maintaining size limit."""
|
||||||
|
self._output_buffer.append(data)
|
||||||
|
self._buffer_size += len(data)
|
||||||
|
|
||||||
|
# Trim if exceeds max size
|
||||||
|
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
|
||||||
|
removed = self._output_buffer.popleft()
|
||||||
|
self._buffer_size -= len(removed)
|
||||||
|
|
||||||
|
def get_buffer(self) -> bytes:
|
||||||
|
"""Get buffered output for replay."""
|
||||||
|
return b"".join(self._output_buffer)
|
||||||
|
|
||||||
async def write_input(self, data: bytes) -> None:
|
async def write_input(self, data: bytes) -> None:
|
||||||
"""Write input to the PTY master."""
|
"""Write input to the PTY master."""
|
||||||
if self._master_fd is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
os.write(self._master_fd, data)
|
os.write(self._master_fd, data)
|
||||||
|
self.last_activity = time.time()
|
||||||
except (OSError, IOError):
|
except (OSError, IOError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -88,8 +135,21 @@ class TerminalSession:
|
|||||||
"""Resize the terminal."""
|
"""Resize the terminal."""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
|
self._cols = cols
|
||||||
|
self._rows = rows
|
||||||
self._set_terminal_size(cols, rows)
|
self._set_terminal_size(cols, rows)
|
||||||
|
|
||||||
|
async def reset(self) -> None:
|
||||||
|
"""Reset the session by killing the process and clearing state."""
|
||||||
|
await self.close()
|
||||||
|
self._closed = False
|
||||||
|
self._output_buffer.clear()
|
||||||
|
self._buffer_size = 0
|
||||||
|
self._websockets.clear()
|
||||||
|
self.process = None
|
||||||
|
self._master_fd = None
|
||||||
|
self._slave_fd = None
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Close the session and cleanup."""
|
"""Close the session and cleanup."""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
@@ -115,3 +175,35 @@ class TerminalSession:
|
|||||||
if self.process is None:
|
if self.process is None:
|
||||||
return False
|
return False
|
||||||
return self.process.returncode is None
|
return self.process.returncode is None
|
||||||
|
|
||||||
|
def is_idle(self) -> bool:
|
||||||
|
"""Check if the session has been idle for too long."""
|
||||||
|
if self._websockets:
|
||||||
|
return False
|
||||||
|
return time.time() - self.last_activity > self.IDLE_TIMEOUT
|
||||||
|
|
||||||
|
def attach_websocket(self, websocket: Any) -> None:
|
||||||
|
"""Attach a WebSocket to this session."""
|
||||||
|
self._websockets.add(websocket)
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
def detach_websocket(self, websocket: Any) -> None:
|
||||||
|
"""Detach a WebSocket from this session."""
|
||||||
|
self._websockets.discard(websocket)
|
||||||
|
|
||||||
|
def has_websockets(self) -> bool:
|
||||||
|
"""Check if any WebSockets are attached."""
|
||||||
|
return len(self._websockets) > 0
|
||||||
|
|
||||||
|
async def send_to_all(self, data: bytes) -> None:
|
||||||
|
"""Send data to all attached WebSockets."""
|
||||||
|
dead_sockets = set()
|
||||||
|
for ws in self._websockets:
|
||||||
|
try:
|
||||||
|
await ws.send_bytes(data)
|
||||||
|
except Exception:
|
||||||
|
dead_sockets.add(ws)
|
||||||
|
|
||||||
|
# Clean up dead sockets
|
||||||
|
for ws in dead_sockets:
|
||||||
|
self._websockets.discard(ws)
|
||||||
|
|||||||
@@ -289,19 +289,37 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
|
|||||||
branches: list[BranchInfo] = []
|
branches: list[BranchInfo] = []
|
||||||
default_branch = "main"
|
default_branch = "main"
|
||||||
|
|
||||||
|
# Get list of remote names to properly filter remote tracking branches
|
||||||
|
try:
|
||||||
|
remote_output = _run_git_command(repo_path, "remote")
|
||||||
|
remote_names = {r.strip() for r in remote_output.strip().split("\n") if r.strip()}
|
||||||
|
except RuntimeError:
|
||||||
|
remote_names = set()
|
||||||
|
|
||||||
for line in output.strip().split("\n"):
|
for line in output.strip().split("\n"):
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
branch_name = line.strip()
|
branch_name = line.strip()
|
||||||
# Skip remote tracking branches (they start with remotes/)
|
|
||||||
if branch_name.startswith("remotes/"):
|
# Skip detached HEAD pointer
|
||||||
# Extract just the branch name part
|
if branch_name == "HEAD":
|
||||||
parts = branch_name.split("/", 2)
|
continue
|
||||||
if len(parts) >= 3:
|
|
||||||
branch_name = parts[2]
|
# Skip remote tracking branches - they appear as "origin/branch-name"
|
||||||
else:
|
# Check if first part is a remote name
|
||||||
continue
|
if "/" in branch_name:
|
||||||
|
first_part = branch_name.split("/", 1)[0]
|
||||||
|
if first_part in remote_names:
|
||||||
|
# Extract just the branch name part (after "origin/")
|
||||||
|
branch_name = branch_name.split("/", 1)[1]
|
||||||
|
elif branch_name.startswith("remotes/"):
|
||||||
|
# Handle "remotes/origin/branch-name" format
|
||||||
|
parts = branch_name.split("/", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
branch_name = parts[2]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
# Skip duplicates
|
# Skip duplicates
|
||||||
if any(b.name == branch_name for b in branches):
|
if any(b.name == branch_name for b in branches):
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ def _insert_tool_type(
|
|||||||
name: str,
|
name: str,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
compose_template: str,
|
compose_template: str,
|
||||||
is_builtin: bool = False,
|
|
||||||
created_by_id: str | None = None,
|
created_by_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
async def _run() -> None:
|
async def _run() -> None:
|
||||||
@@ -120,7 +119,6 @@ def _insert_tool_type(
|
|||||||
description="A test tool type",
|
description="A test tool type",
|
||||||
compose_template=compose_template,
|
compose_template=compose_template,
|
||||||
required_variables=["REPO_PATH", "TOOL_NAME"],
|
required_variables=["REPO_PATH", "TOOL_NAME"],
|
||||||
is_builtin=is_builtin,
|
|
||||||
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
|
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
|
||||||
)
|
)
|
||||||
await session.merge(tool_type)
|
await session.merge(tool_type)
|
||||||
@@ -234,7 +232,6 @@ def test_create_tool_type_successfully() -> None:
|
|||||||
assert data["name"] == "my-custom-tool"
|
assert data["name"] == "my-custom-tool"
|
||||||
assert data["display_name"] == "My Custom Tool"
|
assert data["display_name"] == "My Custom Tool"
|
||||||
assert data["description"] == "A custom development tool"
|
assert data["description"] == "A custom development tool"
|
||||||
assert data["is_builtin"] == False
|
|
||||||
assert data["created_by_id"] == user_id
|
assert data["created_by_id"] == user_id
|
||||||
assert "id" in data
|
assert "id" in data
|
||||||
|
|
||||||
@@ -376,28 +373,7 @@ def test_update_tool_type_not_found() -> None:
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
def test_update_builtin_tool_type_fails() -> None:
|
|
||||||
_prepare_test_db()
|
|
||||||
user_id = "11111111-1111-1111-1111-111111111111"
|
|
||||||
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
||||||
_insert_user(user_id)
|
|
||||||
_insert_tool_type(
|
|
||||||
tool_type_id,
|
|
||||||
"builtin-tool",
|
|
||||||
"Built-in Tool",
|
|
||||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
|
||||||
is_builtin=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = _load_app()
|
|
||||||
client = TestClient(app)
|
|
||||||
client.cookies.set("access_token", _mint_token(user_id))
|
|
||||||
|
|
||||||
payload = {"display_name": "Updated"}
|
|
||||||
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -442,53 +418,4 @@ def test_delete_tool_type_not_found() -> None:
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
def test_delete_builtin_tool_type_fails() -> None:
|
|
||||||
_prepare_test_db()
|
|
||||||
user_id = "11111111-1111-1111-1111-111111111111"
|
|
||||||
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
||||||
_insert_user(user_id)
|
|
||||||
_insert_tool_type(
|
|
||||||
tool_type_id,
|
|
||||||
"builtin-tool",
|
|
||||||
"Built-in Tool",
|
|
||||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
|
||||||
is_builtin=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = _load_app()
|
|
||||||
client = TestClient(app)
|
|
||||||
client.cookies.set("access_token", _mint_token(user_id))
|
|
||||||
|
|
||||||
response = client.delete(f"/tool-types/{tool_type_id}")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
def test_builtin_tool_types_seeded_on_startup() -> None:
|
|
||||||
_prepare_test_db()
|
|
||||||
user_id = "11111111-1111-1111-1111-111111111111"
|
|
||||||
_insert_user(user_id)
|
|
||||||
|
|
||||||
# Load app triggers startup event which seeds built-in types
|
|
||||||
app = _load_app()
|
|
||||||
client = TestClient(app)
|
|
||||||
client.cookies.set("access_token", _mint_token(user_id))
|
|
||||||
|
|
||||||
response = client.get("/tool-types")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Check that built-in types exist
|
|
||||||
builtin_names = [t["name"] for t in data if t["is_builtin"]]
|
|
||||||
assert "code-server" in builtin_names
|
|
||||||
assert "jupyter-notebook" in builtin_names
|
|
||||||
|
|
||||||
# Verify built-in types have correct attributes
|
|
||||||
code_server = next((t for t in data if t["name"] == "code-server"), None)
|
|
||||||
assert code_server is not None
|
|
||||||
assert code_server["display_name"] == "VS Code Server"
|
|
||||||
assert "services" in code_server["compose_template"]
|
|
||||||
assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"]
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class TestToolTypesAPIExtended:
|
|||||||
"interfaces": ["web"],
|
"interfaces": ["web"],
|
||||||
"default_port": 8080,
|
"default_port": 8080,
|
||||||
"definition_type": "compose",
|
"definition_type": "compose",
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
"readiness_probe": {
|
"readiness_probe": {
|
||||||
"command": "curl -f http://localhost:8080",
|
"command": "curl -f http://localhost:8080",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
@@ -92,7 +92,7 @@ class TestToolTypesAPIExtended:
|
|||||||
"display_name": "Update Test Tool",
|
"display_name": "Update Test Tool",
|
||||||
"default_port": 8080,
|
"default_port": 8080,
|
||||||
"definition_type": "compose",
|
"definition_type": "compose",
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
"required_variables": [],
|
"required_variables": [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -167,7 +167,7 @@ class TestToolTypesAPIExtended:
|
|||||||
"interfaces": ["web", "terminal"],
|
"interfaces": ["web", "terminal"],
|
||||||
"default_port": 8443,
|
"default_port": 8443,
|
||||||
"definition_type": "compose",
|
"definition_type": "compose",
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
||||||
"readiness_probe": {
|
"readiness_probe": {
|
||||||
"command": "curl -f http://localhost:8443",
|
"command": "curl -f http://localhost:8443",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
@@ -186,3 +186,40 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["category"] == "editor"
|
assert data["category"] == "editor"
|
||||||
assert data["interfaces"] == ["web", "terminal"]
|
assert data["interfaces"] == ["web", "terminal"]
|
||||||
assert "readiness_probe" in data
|
assert "readiness_probe" in data
|
||||||
|
|
||||||
|
def test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that creating a tool type without default_port fails validation."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "no-port-tool",
|
||||||
|
"display_name": "No Port Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert "default_port" in str(data)
|
||||||
|
|
||||||
|
def test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that port mismatch between default_port and compose template fails."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "port-mismatch-tool",
|
||||||
|
"display_name": "Port Mismatch Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"default_port": 9999,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert "Port 9999 is not exposed" in str(data)
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Tests for session creation with branch selection and new branch creation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.api.tool_instances import CreateInstanceRequest
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateInstanceRequest:
|
||||||
|
"""Tests for CreateInstanceRequest model."""
|
||||||
|
|
||||||
|
def test_default_values(self):
|
||||||
|
"""Test default values for CreateInstanceRequest."""
|
||||||
|
request = CreateInstanceRequest(tool_type_id="123")
|
||||||
|
assert request.clone_mode == "mount"
|
||||||
|
assert request.branch == "main"
|
||||||
|
assert request.new_branch is None
|
||||||
|
assert request.display_name is None
|
||||||
|
|
||||||
|
def test_clone_mode_with_branch(self):
|
||||||
|
"""Test CreateInstanceRequest with clone mode and branch."""
|
||||||
|
request = CreateInstanceRequest(
|
||||||
|
tool_type_id="123",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="dev",
|
||||||
|
)
|
||||||
|
assert request.clone_mode == "clone"
|
||||||
|
assert request.branch == "dev"
|
||||||
|
|
||||||
|
def test_new_branch_field(self):
|
||||||
|
"""Test CreateInstanceRequest with new_branch field."""
|
||||||
|
request = CreateInstanceRequest(
|
||||||
|
tool_type_id="123",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="main",
|
||||||
|
new_branch="feature/test",
|
||||||
|
)
|
||||||
|
assert request.new_branch == "feature/test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBranchCreationInClone:
|
||||||
|
"""Tests for branch creation logic in clone process."""
|
||||||
|
|
||||||
|
def test_create_local_branch_success(self):
|
||||||
|
"""Test successful local branch creation."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Initialize repo
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", tmpdir],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.name", "Test User"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create initial commit
|
||||||
|
readme = os.path.join(tmpdir, "README.md")
|
||||||
|
with open(readme, "w") as f:
|
||||||
|
f.write("# Test\n")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "add", "README.md"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new branch
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "checkout", "-b", "feature/new-branch"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
# Verify branch exists
|
||||||
|
branches_result = subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "branch", "--show-current"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert branches_result.stdout.strip() == "feature/new-branch"
|
||||||
|
|
||||||
|
def test_create_local_branch_invalid_name(self):
|
||||||
|
"""Test local branch creation with invalid name fails."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Initialize repo
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", tmpdir],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "config", "user.name", "Test User"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create initial commit
|
||||||
|
readme = os.path.join(tmpdir, "README.md")
|
||||||
|
with open(readme, "w") as f:
|
||||||
|
f.write("# Test\n")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "add", "README.md"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to create branch with invalid name (contains spaces)
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", tmpdir, "checkout", "-b", "invalid branch name"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Git accepts branch names with spaces but it's not recommended
|
||||||
|
# This test verifies the command structure
|
||||||
|
assert result.returncode == 0 or "fatal" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateInstanceAPI:
|
||||||
|
"""Tests for create instance API endpoint with branch options."""
|
||||||
|
|
||||||
|
def test_create_instance_request_validation(self):
|
||||||
|
"""Test that CreateInstanceRequest validates correctly."""
|
||||||
|
# Valid request with new_branch
|
||||||
|
request = CreateInstanceRequest(
|
||||||
|
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="main",
|
||||||
|
new_branch="feature/test",
|
||||||
|
)
|
||||||
|
assert request.new_branch == "feature/test"
|
||||||
|
|
||||||
|
# Valid request without new_branch
|
||||||
|
request2 = CreateInstanceRequest(
|
||||||
|
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
clone_mode="clone",
|
||||||
|
branch="dev",
|
||||||
|
)
|
||||||
|
assert request2.new_branch is None
|
||||||
|
|
||||||
|
def test_create_instance_with_new_branch_sets_instance_branch(self):
|
||||||
|
"""Test that instance branch is set to new_branch when provided."""
|
||||||
|
# This tests the logic: data.new_branch if data.new_branch else data.branch
|
||||||
|
new_branch = "feature/test"
|
||||||
|
base_branch = "main"
|
||||||
|
|
||||||
|
# Simulate the logic from create_instance
|
||||||
|
stored_branch = new_branch if new_branch else base_branch
|
||||||
|
assert stored_branch == "feature/test"
|
||||||
|
|
||||||
|
# Without new_branch
|
||||||
|
stored_branch2 = None if None else base_branch
|
||||||
|
assert stored_branch2 == "main"
|
||||||
@@ -8,6 +8,7 @@ export interface GitRepository {
|
|||||||
owner_id: string;
|
owner_id: string;
|
||||||
is_mirror: boolean;
|
is_mirror: boolean;
|
||||||
remote_url: string | null;
|
remote_url: string | null;
|
||||||
|
ssh_key_id: string | null;
|
||||||
last_push: string | null;
|
last_push: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -16,6 +17,7 @@ export interface GitRepositoryCreate {
|
|||||||
name: string;
|
name: string;
|
||||||
remote_url?: string;
|
remote_url?: string;
|
||||||
force_original_url?: boolean;
|
force_original_url?: boolean;
|
||||||
|
ssh_key_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface URLParseResult {
|
export interface URLParseResult {
|
||||||
@@ -50,6 +52,39 @@ export async function deleteRepository(projectId: string, repoId: string): Promi
|
|||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateRepositorySshKey(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
sshKeyId: string | null
|
||||||
|
): Promise<GitRepository> {
|
||||||
|
const response = await apiClient.patch(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/ssh-key`,
|
||||||
|
{ ssh_key_id: sshKeyId }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Branch {
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
last_commit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchesResponse {
|
||||||
|
branches: Branch[];
|
||||||
|
default_branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRepositoryBranches(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string
|
||||||
|
): Promise<BranchesResponse> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CommitHistoryEntry {
|
export interface CommitHistoryEntry {
|
||||||
hash: string;
|
hash: string;
|
||||||
short_hash: string;
|
short_hash: string;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export interface Session {
|
|||||||
url: string | null;
|
url: string | null;
|
||||||
container_status?: string;
|
container_status?: string;
|
||||||
probe_status?: string;
|
probe_status?: string;
|
||||||
|
clone_mode?: string;
|
||||||
|
branch?: string | null;
|
||||||
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listInstances(
|
export async function listInstances(
|
||||||
@@ -43,13 +46,19 @@ export async function createInstance(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
toolTypeId: string,
|
toolTypeId: string,
|
||||||
displayName?: string
|
displayName?: string,
|
||||||
|
cloneMode?: string,
|
||||||
|
branch?: string,
|
||||||
|
newBranch?: string
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
{
|
{
|
||||||
tool_type_id: toolTypeId,
|
tool_type_id: toolTypeId,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
|
clone_mode: cloneMode || "mount",
|
||||||
|
branch: branch || undefined,
|
||||||
|
new_branch: newBranch || undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -91,10 +100,12 @@ export async function restartInstance(
|
|||||||
export async function deleteInstance(
|
export async function deleteInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
|
force?: boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await apiClient.delete(
|
await apiClient.delete(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||||
|
{ params: { force } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,30 @@ export async function createSSHKey(data: SSHKeyCreate): Promise<SSHKey> {
|
|||||||
export async function deleteSSHKey(keyId: string): Promise<void> {
|
export async function deleteSSHKey(keyId: string): Promise<void> {
|
||||||
await apiClient.delete(`/ssh-keys/${keyId}`);
|
await apiClient.delete(`/ssh-keys/${keyId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SignPayloadRequest {
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureResponse {
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifySignatureRequest {
|
||||||
|
payload: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifySignatureResponse {
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signPayload(keyId: string, data: SignPayloadRequest): Promise<SignatureResponse> {
|
||||||
|
const response = await apiClient.post<SignatureResponse>(`/ssh-keys/${keyId}/sign`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySignature(keyId: string, data: VerifySignatureRequest): Promise<VerifySignatureResponse> {
|
||||||
|
const response = await apiClient.post<VerifySignatureResponse>(`/ssh-keys/${keyId}/verify`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export interface ToolType {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
category: string;
|
category: string;
|
||||||
interfaces: string[];
|
interface_type: string;
|
||||||
|
requires_port: boolean;
|
||||||
default_port: number | null;
|
default_port: number | null;
|
||||||
definition_type: 'compose' | 'dockerfile';
|
definition_type: 'compose' | 'dockerfile';
|
||||||
compose_template: string | null;
|
compose_template: string | null;
|
||||||
@@ -20,7 +21,6 @@ export interface ToolType {
|
|||||||
build_context: Record<string, string> | null;
|
build_context: Record<string, string> | null;
|
||||||
readiness_probe: ReadinessProbe | null;
|
readiness_probe: ReadinessProbe | null;
|
||||||
required_variables: string[];
|
required_variables: string[];
|
||||||
is_builtin: boolean;
|
|
||||||
created_by_id: string | null;
|
created_by_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -31,7 +31,8 @@ export interface CreateToolTypeRequest {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
interfaces?: string[];
|
interface_type?: string;
|
||||||
|
requires_port?: boolean;
|
||||||
default_port: number;
|
default_port: number;
|
||||||
definition_type?: 'compose' | 'dockerfile';
|
definition_type?: 'compose' | 'dockerfile';
|
||||||
compose_template?: string;
|
compose_template?: string;
|
||||||
@@ -45,7 +46,8 @@ export interface UpdateToolTypeRequest {
|
|||||||
display_name?: string;
|
display_name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
interfaces?: string[];
|
interface_type?: string;
|
||||||
|
requires_port?: boolean;
|
||||||
default_port?: number;
|
default_port?: number;
|
||||||
definition_type?: 'compose' | 'dockerfile';
|
definition_type?: 'compose' | 'dockerfile';
|
||||||
compose_template?: string;
|
compose_template?: string;
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { getUserSessions } from "../api/sessions";
|
import { getUserSessions } from "../api/sessions";
|
||||||
import type { Session } from "../api/sessions";
|
import type { Session } from "../api/sessions";
|
||||||
import { useTheme } from "../hooks/use-theme";
|
import { useTheme } from "../hooks/use-theme";
|
||||||
import { useAuth } from "../state/auth";
|
import { useAuth } from "../state/auth";
|
||||||
import { useSessions } from "../state/sessions";
|
import { useSessions } from "../state/sessions";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
import { Icon } from "./icon";
|
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; badge?: "sessions" }[] = [
|
||||||
{ to: "/", label: "Home", icon: "dashboard" },
|
{ to: "/", label: "Home", icon: "dashboard" },
|
||||||
|
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||||
@@ -38,6 +40,11 @@ export const AppShell = () => {
|
|||||||
useTheme();
|
useTheme();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { sessions, setAllSessions } = useSessions();
|
const { sessions, setAllSessions } = useSessions();
|
||||||
|
const location = useLocation();
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -57,6 +64,19 @@ export const AppShell = () => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
|
// Close mobile menu on route change
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
if (isMobileTerminal) {
|
||||||
|
return (
|
||||||
|
<div className="shell mobile-terminal-shell">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<header className="shell-header">
|
<header className="shell-header">
|
||||||
@@ -81,9 +101,18 @@ export const AppShell = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="shell-body">
|
<div className="shell-body">
|
||||||
<aside className="shell-nav" aria-label="Primary navigation">
|
<aside className={`shell-nav ${mobileMenuOpen ? "mobile-open" : ""}`} aria-label="Primary navigation">
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
className="mobile-menu-close"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
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
|
||||||
@@ -94,7 +123,7 @@ export const AppShell = () => {
|
|||||||
>
|
>
|
||||||
<Icon name={item.icon} size="sm" />
|
<Icon name={item.icon} size="sm" />
|
||||||
{item.label}
|
{item.label}
|
||||||
{isHome && activeCount > 0 && (
|
{item.badge === "sessions" && activeCount > 0 && (
|
||||||
<span className="nav-badge">{activeCount}</span>
|
<span className="nav-badge">{activeCount}</span>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -112,6 +141,13 @@ export const AppShell = () => {
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{isMobile && mobileMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="mobile-menu-overlay"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="shell-content">
|
<main className="shell-content">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import { createInstance, startInstance, type ToolInstance } from "../api/sessions";
|
||||||
|
import type { Project } from "../types";
|
||||||
|
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
||||||
|
import type { ToolType } from "../api/tool_types";
|
||||||
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
|
|
||||||
|
interface CreateSessionFormProps {
|
||||||
|
projects: Project[];
|
||||||
|
repositories: GitRepository[];
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
fixedProjectId?: string;
|
||||||
|
fixedRepoId?: string;
|
||||||
|
projectName?: string;
|
||||||
|
repoName?: string;
|
||||||
|
showCloneMode?: boolean;
|
||||||
|
showFixedFields?: boolean;
|
||||||
|
onProjectChange?: (projectId: string) => void;
|
||||||
|
onSuccess?: (instance: ToolInstance) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
submitLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateSessionForm = ({
|
||||||
|
projects,
|
||||||
|
repositories,
|
||||||
|
toolTypes,
|
||||||
|
fixedProjectId,
|
||||||
|
fixedRepoId,
|
||||||
|
projectName,
|
||||||
|
repoName,
|
||||||
|
showCloneMode = true,
|
||||||
|
showFixedFields = true,
|
||||||
|
onProjectChange,
|
||||||
|
onSuccess,
|
||||||
|
onCancel,
|
||||||
|
submitLabel = "Create Session",
|
||||||
|
className = "",
|
||||||
|
}: CreateSessionFormProps) => {
|
||||||
|
const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||||
|
const [branch, setBranch] = useState("main");
|
||||||
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
|
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
|
||||||
|
const [newBranchName, setNewBranchName] = useState("");
|
||||||
|
const [baseBranch, setBaseBranch] = useState("");
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<"idle" | "creating" | "error">("idle");
|
||||||
|
const [progress, setProgress] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load SSH keys when clone mode is shown
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showCloneMode) return;
|
||||||
|
const loadKeys = async () => {
|
||||||
|
try {
|
||||||
|
const keys = await listSSHKeys();
|
||||||
|
setSshKeys(keys);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadKeys();
|
||||||
|
}, [showCloneMode]);
|
||||||
|
|
||||||
|
// Load branches when selected repo changes
|
||||||
|
useEffect(() => {
|
||||||
|
const projectId = fixedProjectId || selectedProject;
|
||||||
|
if (!selectedRepo || !projectId || !showCloneMode) {
|
||||||
|
setBranches([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadBranches = async () => {
|
||||||
|
setIsLoadingBranches(true);
|
||||||
|
try {
|
||||||
|
const response = await listRepositoryBranches(projectId, selectedRepo);
|
||||||
|
setBranches(response.branches);
|
||||||
|
if (response.default_branch) {
|
||||||
|
setBranch(response.default_branch);
|
||||||
|
setBaseBranch(response.default_branch);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBranches(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadBranches();
|
||||||
|
}, [selectedRepo, selectedProject, fixedProjectId, showCloneMode]);
|
||||||
|
|
||||||
|
// Filter repositories by selected project
|
||||||
|
const availableRepos = selectedProject
|
||||||
|
? repositories.filter((r) => r.project_id === selectedProject)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const projectId = fixedProjectId || selectedProject;
|
||||||
|
const repoId = fixedRepoId || selectedRepo;
|
||||||
|
|
||||||
|
if (!projectId || !repoId || !selectedToolType) {
|
||||||
|
setError("Project, repository, and tool type are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCloneMode && cloneMode === "clone") {
|
||||||
|
const repo = repositories.find((r) => r.id === repoId);
|
||||||
|
if (!repo?.ssh_key_id) {
|
||||||
|
setError("Repository must have an SSH key assigned for clone mode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("creating");
|
||||||
|
setProgress("Creating instance...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instance = await createInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
selectedToolType,
|
||||||
|
displayName || undefined,
|
||||||
|
showCloneMode ? cloneMode : undefined,
|
||||||
|
showCloneMode && cloneMode === "clone"
|
||||||
|
? isCreatingNewBranch
|
||||||
|
? baseBranch
|
||||||
|
: branch
|
||||||
|
: undefined,
|
||||||
|
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||||
|
? newBranchName
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
setProgress("Starting container...");
|
||||||
|
await startInstance(projectId, repoId, instance.id);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
if (!fixedProjectId) setSelectedProject("");
|
||||||
|
if (!fixedRepoId) setSelectedRepo("");
|
||||||
|
setSelectedToolType("");
|
||||||
|
setDisplayName("");
|
||||||
|
setCloneMode("mount");
|
||||||
|
setBranch("main");
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setNewBranchName("");
|
||||||
|
setBaseBranch("");
|
||||||
|
setBranches([]);
|
||||||
|
setStatus("idle");
|
||||||
|
|
||||||
|
onSuccess?.(instance);
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
setError("Failed to create session");
|
||||||
|
setProgress("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSubmitting = status === "creating";
|
||||||
|
|
||||||
|
// Determine which steps are active/unlocked
|
||||||
|
const hasProject = !!(fixedProjectId || selectedProject);
|
||||||
|
const hasRepo = !!(fixedRepoId || selectedRepo);
|
||||||
|
const hasToolType = !!selectedToolType;
|
||||||
|
|
||||||
|
const renderStep = (
|
||||||
|
label: string,
|
||||||
|
number: number,
|
||||||
|
isActive: boolean,
|
||||||
|
isComplete: boolean,
|
||||||
|
children: React.ReactNode
|
||||||
|
) => {
|
||||||
|
const stepClass = `workflow-step ${isActive ? "active" : ""} ${isComplete ? "complete" : ""}`;
|
||||||
|
return (
|
||||||
|
<div className={stepClass}>
|
||||||
|
<div className="workflow-step-header">
|
||||||
|
<span className="workflow-step-number">{number}</span>
|
||||||
|
<span className="workflow-step-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="workflow-step-content">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`create-session-form-wrapper ${className}`}>
|
||||||
|
{isSubmitting && (
|
||||||
|
<div className="loading-overlay">
|
||||||
|
<div className="loading-content">
|
||||||
|
<Icon name="loading" size="lg" />
|
||||||
|
<p>{progress || "Creating session..."}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="stack create-session-form workflow-form">
|
||||||
|
{/* Step 1: Project */}
|
||||||
|
{renderStep("Select Project", 1, true, hasProject,
|
||||||
|
fixedProjectId && showFixedFields ? (
|
||||||
|
<label className="form-field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={projectName || projects.find((p) => p.id === fixedProjectId)?.name || ""}
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label className="form-field">
|
||||||
|
<select
|
||||||
|
value={selectedProject}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setSelectedProject(value);
|
||||||
|
setSelectedRepo("");
|
||||||
|
setSelectedToolType("");
|
||||||
|
setCloneMode("mount");
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
onProjectChange?.(value);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Repository */}
|
||||||
|
{hasProject && renderStep("Select Repository", 2, true, hasRepo,
|
||||||
|
fixedRepoId && showFixedFields ? (
|
||||||
|
<label className="form-field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={repoName || repositories.find((r) => r.id === fixedRepoId)?.name || ""}
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label className="form-field">
|
||||||
|
<select
|
||||||
|
value={selectedRepo}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedRepo(e.target.value);
|
||||||
|
setSelectedToolType("");
|
||||||
|
setCloneMode("mount");
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
}}
|
||||||
|
disabled={!hasProject || isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">Select repository...</option>
|
||||||
|
{availableRepos.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Tool Type */}
|
||||||
|
{hasRepo && renderStep("Select Tool", 3, true, hasToolType,
|
||||||
|
<label className="form-field">
|
||||||
|
<select
|
||||||
|
value={selectedToolType}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedToolType(e.target.value);
|
||||||
|
setCloneMode("mount");
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
}}
|
||||||
|
disabled={!hasRepo || isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">Select tool...</option>
|
||||||
|
{toolTypes.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Clone Mode & Branch */}
|
||||||
|
{showCloneMode && hasToolType && renderStep("Repository Access", 4, true, false,
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
<div className="radio-group">
|
||||||
|
<label className="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cloneMode"
|
||||||
|
value="mount"
|
||||||
|
checked={cloneMode === "mount"}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCloneMode(e.target.value as "mount" | "clone");
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
Mount (live sync)
|
||||||
|
</label>
|
||||||
|
<label className="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cloneMode"
|
||||||
|
value="clone"
|
||||||
|
checked={cloneMode === "clone"}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCloneMode(e.target.value as "mount" | "clone");
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
Clone fresh copy
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cloneMode === "clone" && (
|
||||||
|
<>
|
||||||
|
<label className="form-field">
|
||||||
|
Branch
|
||||||
|
{isLoadingBranches ? (
|
||||||
|
<span className="muted">Loading branches...</span>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={isCreatingNewBranch ? "__new__" : branch}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === "__new__") {
|
||||||
|
setIsCreatingNewBranch(true);
|
||||||
|
setNewBranchName("");
|
||||||
|
} else {
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setBranch(value);
|
||||||
|
setBaseBranch(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name} {b.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__new__">Create new branch...</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isCreatingNewBranch && (
|
||||||
|
<>
|
||||||
|
<label className="form-field">
|
||||||
|
New Branch Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newBranchName}
|
||||||
|
onChange={(e) => setNewBranchName(e.target.value)}
|
||||||
|
placeholder="feature/my-new-branch"
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Base Branch
|
||||||
|
<select
|
||||||
|
value={baseBranch}
|
||||||
|
onChange={(e) => setBaseBranch(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name} {b.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRepo && (
|
||||||
|
<div className="form-field ssh-key-info">
|
||||||
|
{(() => {
|
||||||
|
const repo = repositories.find((r) => r.id === selectedRepo);
|
||||||
|
if (!repo) return null;
|
||||||
|
if (repo.ssh_key_id) {
|
||||||
|
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
|
||||||
|
return (
|
||||||
|
<span className="success-text">
|
||||||
|
SSH key: {key?.name || "Assigned"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="warning-text">
|
||||||
|
No SSH key assigned to this repository. Clone mode requires an SSH key.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: Display Name */}
|
||||||
|
{hasToolType && renderStep("Display Name (optional)", 5, true, !!displayName,
|
||||||
|
<label className="form-field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="My Development Environment"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error & Submit */}
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
|
||||||
|
{hasToolType && (
|
||||||
|
<div className="form-actions">
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
{submitLabel}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ interface GitToolbarProps {
|
|||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
branches: string[];
|
branches: string[];
|
||||||
hasRemote: boolean;
|
hasRemote: boolean;
|
||||||
|
isMirror: boolean;
|
||||||
onBranchChange: (branch: string) => void;
|
onBranchChange: (branch: string) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
@@ -28,6 +29,7 @@ export const GitToolbar = ({
|
|||||||
currentBranch,
|
currentBranch,
|
||||||
branches,
|
branches,
|
||||||
hasRemote,
|
hasRemote,
|
||||||
|
isMirror,
|
||||||
onBranchChange,
|
onBranchChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: GitToolbarProps) => {
|
}: GitToolbarProps) => {
|
||||||
@@ -136,7 +138,13 @@ export const GitToolbar = ({
|
|||||||
return (
|
return (
|
||||||
<div className="git-toolbar">
|
<div className="git-toolbar">
|
||||||
{error && <div className="toolbar-error">{error}</div>}
|
{error && <div className="toolbar-error">{error}</div>}
|
||||||
|
{isMirror && (
|
||||||
|
<div className="warning-message">
|
||||||
|
<Icon name="warning" size="sm" /> This repository is a bare mirror.
|
||||||
|
Editing, committing, pulling, and merging are not available.
|
||||||
|
Delete and recreate it to enable full workspace features.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="toolbar-row">
|
<div className="toolbar-row">
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Icon } from "./icon";
|
|||||||
import type { ToolInstance } from "../api/sessions";
|
import type { ToolInstance } from "../api/sessions";
|
||||||
import {
|
import {
|
||||||
checkInstanceHealth,
|
checkInstanceHealth,
|
||||||
createInstance,
|
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
listInstances,
|
listInstances,
|
||||||
recreateInstanceTunnel,
|
recreateInstanceTunnel,
|
||||||
@@ -13,22 +12,23 @@ import {
|
|||||||
stopInstance,
|
stopInstance,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
|
import { CreateSessionForm } from "./create-session-form";
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
interface InstanceListProps {
|
interface InstanceListProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
|
projectName?: string;
|
||||||
|
repoName?: string;
|
||||||
toolTypes: ToolType[];
|
toolTypes: ToolType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
|
export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTypes }: InstanceListProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [selectedToolType, setSelectedToolType] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Stop confirmation
|
// Stop confirmation
|
||||||
@@ -83,18 +83,9 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [instances, projectId, repoId]);
|
}, [instances, projectId, repoId]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreateSuccess = async () => {
|
||||||
if (!selectedToolType) return;
|
setShowCreate(false);
|
||||||
setError(null);
|
await loadInstances();
|
||||||
try {
|
|
||||||
await createInstance(projectId, repoId, selectedToolType, displayName || undefined);
|
|
||||||
setShowCreate(false);
|
|
||||||
setSelectedToolType("");
|
|
||||||
setDisplayName("");
|
|
||||||
await loadInstances();
|
|
||||||
} catch {
|
|
||||||
setError("Failed to create instance");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStart = async (instanceId: string) => {
|
const handleStart = async (instanceId: string) => {
|
||||||
@@ -309,48 +300,18 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
<h2>Launch Tool</h2>
|
<h2>Launch Tool</h2>
|
||||||
<div className="stack">
|
<CreateSessionForm
|
||||||
<label className="form-field">
|
projects={[]}
|
||||||
Tool Type
|
repositories={[]}
|
||||||
<select
|
toolTypes={toolTypes}
|
||||||
value={selectedToolType}
|
fixedProjectId={projectId}
|
||||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
fixedRepoId={repoId}
|
||||||
>
|
projectName={projectName}
|
||||||
<option value="">Select a tool...</option>
|
repoName={repoName}
|
||||||
{toolTypes.map((tool) => (
|
onSuccess={handleCreateSuccess}
|
||||||
<option key={tool.id} value={tool.id}>
|
onCancel={() => setShowCreate(false)}
|
||||||
{tool.display_name}
|
submitLabel="Launch"
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Display Name (optional)
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
placeholder="My Development Environment"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="dialog-actions">
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={() => setShowCreate(false)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
onClick={() => void handleCreate()}
|
|
||||||
disabled={!selectedToolType}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Launch
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
interface MobileTerminalHeaderProps {
|
||||||
|
instanceName?: string;
|
||||||
|
onBack?: () => void;
|
||||||
|
onMenuToggle?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onFontSizeChange?: (delta: number) => void;
|
||||||
|
isVisible: boolean;
|
||||||
|
connectionStatus?: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
|
||||||
|
instanceName,
|
||||||
|
onBack,
|
||||||
|
onMenuToggle,
|
||||||
|
onClose,
|
||||||
|
onFontSizeChange,
|
||||||
|
isVisible,
|
||||||
|
connectionStatus = "connecting",
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mobile-terminal-header ${isVisible ? "visible" : "hidden"}`}
|
||||||
|
>
|
||||||
|
<div className="mobile-terminal-header-left">
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
className="mobile-terminal-header-button"
|
||||||
|
onClick={onBack}
|
||||||
|
type="button"
|
||||||
|
aria-label="Go back"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onMenuToggle && (
|
||||||
|
<button
|
||||||
|
className="mobile-terminal-header-button"
|
||||||
|
onClick={onMenuToggle}
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<Icon name="menu" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-terminal-header-center">
|
||||||
|
<span className="mobile-terminal-header-title">
|
||||||
|
{instanceName || "Terminal"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`mobile-terminal-header-status ${connectionStatus}`}
|
||||||
|
aria-label={`Connection status: ${connectionStatus}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-terminal-header-right">
|
||||||
|
{onFontSizeChange && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="mobile-terminal-header-button"
|
||||||
|
onClick={() => onFontSizeChange(-1)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Decrease font size"
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.75rem" }}>A-</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="mobile-terminal-header-button"
|
||||||
|
onClick={() => onFontSizeChange(1)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Increase font size"
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "1rem" }}>A+</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
className="mobile-terminal-header-button"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
aria-label="Close terminal"
|
||||||
|
>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { TerminalComponent } from "./terminal";
|
||||||
|
import { MobileTerminalHeader } from "./mobile-terminal-header";
|
||||||
|
import { SpecialKeysStrip } from "./special-keys-strip";
|
||||||
|
import { SpecialKeysPanel } from "./special-keys-panel";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
||||||
|
import { useAutoHide } from "../hooks/use-auto-hide";
|
||||||
|
import type { ModifierKey } from "../hooks/use-special-keys";
|
||||||
|
|
||||||
|
interface MobileTerminalWrapperProps {
|
||||||
|
instanceId: string;
|
||||||
|
instanceName?: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
onMenuToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||||
|
instanceId,
|
||||||
|
instanceName,
|
||||||
|
onClose,
|
||||||
|
onBack,
|
||||||
|
onMenuToggle,
|
||||||
|
}) => {
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||||
|
useVirtualKeyboard();
|
||||||
|
const [showPanel, setShowPanel] = useState(false);
|
||||||
|
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
||||||
|
const [terminalRef, setTerminalRef] = useState<{
|
||||||
|
sendData: (data: string) => void;
|
||||||
|
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||||
|
focusInput: () => void;
|
||||||
|
changeFontSize: (delta: number) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||||
|
|
||||||
|
const handleTerminalTap = useCallback(() => {
|
||||||
|
headerAutoHide.toggle();
|
||||||
|
}, [headerAutoHide]);
|
||||||
|
|
||||||
|
const handleTerminalReady = useCallback(
|
||||||
|
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => {
|
||||||
|
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSendKey = useCallback(
|
||||||
|
(data: string) => {
|
||||||
|
terminalRef?.sendData(data);
|
||||||
|
},
|
||||||
|
[terminalRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
return (
|
||||||
|
<TerminalComponent
|
||||||
|
instanceId={instanceId}
|
||||||
|
onClose={onClose}
|
||||||
|
isMobile={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-terminal-wrapper">
|
||||||
|
<MobileTerminalHeader
|
||||||
|
instanceName={instanceName}
|
||||||
|
onBack={onBack}
|
||||||
|
onMenuToggle={onMenuToggle}
|
||||||
|
onClose={onClose}
|
||||||
|
onFontSizeChange={(delta) => terminalRef?.changeFontSize(delta)}
|
||||||
|
isVisible={headerAutoHide.isVisible}
|
||||||
|
connectionStatus={terminalRef?.connectionStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mobile-terminal-content"
|
||||||
|
style={{
|
||||||
|
paddingBottom: isKeyboardOpen ? keyboardHeight : 0,
|
||||||
|
}}
|
||||||
|
onClick={handleTerminalTap}
|
||||||
|
>
|
||||||
|
<TerminalComponent
|
||||||
|
instanceId={instanceId}
|
||||||
|
onClose={onClose}
|
||||||
|
isMobile={true}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
onModifierChange={setActiveModifier}
|
||||||
|
onTerminalReady={handleTerminalReady}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpecialKeysStrip
|
||||||
|
onSend={handleSendKey}
|
||||||
|
isVisible={!showPanel}
|
||||||
|
onMoreClick={() => setShowPanel(true)}
|
||||||
|
onKeepFocus={() => terminalRef?.focusInput()}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
onModifierChange={setActiveModifier}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SpecialKeysPanel
|
||||||
|
onSend={handleSendKey}
|
||||||
|
isOpen={showPanel}
|
||||||
|
onClose={() => setShowPanel(false)}
|
||||||
|
onKeepFocus={() => terminalRef?.focusInput()}
|
||||||
|
activeModifier={activeModifier}
|
||||||
|
onModifierChange={setActiveModifier}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
|
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
|
||||||
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
type CreateMode = "clone" | "blank";
|
type CreateMode = "clone" | "blank";
|
||||||
@@ -20,12 +21,14 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
const [owner, setOwner] = useState("");
|
const [owner, setOwner] = useState("");
|
||||||
const [repoName, setRepoName] = useState("");
|
const [repoName, setRepoName] = useState("");
|
||||||
const [advancedUrl, setAdvancedUrl] = useState("");
|
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||||
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
|
const [useAdvancedUrl, setUseAdvancedUrl] = useState(true);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [urlValidation, setUrlValidation] = useState<{
|
const [urlValidation, setUrlValidation] = useState<{
|
||||||
status: UrlValidationStatus;
|
status: UrlValidationStatus;
|
||||||
result: URLParseResult | null;
|
result: URLParseResult | null;
|
||||||
}>({ status: "idle", result: null });
|
}>({ status: "idle", result: null });
|
||||||
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
|
||||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,6 +38,19 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const loadKeys = async () => {
|
||||||
|
try {
|
||||||
|
const data = await listSSHKeys();
|
||||||
|
setSshKeys(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadKeys();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (!useAdvancedUrl) {
|
if (!useAdvancedUrl) {
|
||||||
@@ -81,9 +97,10 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
setOwner("");
|
setOwner("");
|
||||||
setRepoName("");
|
setRepoName("");
|
||||||
setAdvancedUrl("");
|
setAdvancedUrl("");
|
||||||
setUseAdvancedUrl(false);
|
setUseAdvancedUrl(true);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setUrlValidation({ status: "idle", result: null });
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
setSelectedSshKey("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -120,6 +137,9 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
}
|
}
|
||||||
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||||
}
|
}
|
||||||
|
if (selectedSshKey) {
|
||||||
|
input.ssh_key_id = selectedSshKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await createRepository(projectId, input);
|
await createRepository(projectId, input);
|
||||||
@@ -212,6 +232,20 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
placeholder="repo-name"
|
placeholder="repo-name"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
SSH Key
|
||||||
|
<select
|
||||||
|
value={selectedSshKey}
|
||||||
|
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select SSH key (optional)...</option>
|
||||||
|
{sshKeys.map((k) => (
|
||||||
|
<option key={k.id} value={k.id}>
|
||||||
|
{k.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -223,45 +257,61 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{createMode === "clone" && useAdvancedUrl && (
|
{createMode === "clone" && useAdvancedUrl && (
|
||||||
<label className="form-field">
|
<>
|
||||||
Remote URL
|
<label className="form-field">
|
||||||
<input
|
Remote URL
|
||||||
type="text"
|
<input
|
||||||
value={advancedUrl}
|
type="text"
|
||||||
onChange={(event) => setAdvancedUrl(event.target.value)}
|
value={advancedUrl}
|
||||||
placeholder="https://github.com/user/repo.git"
|
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||||
className={getUrlInputClass()}
|
placeholder="https://github.com/user/repo.git"
|
||||||
/>
|
className={getUrlInputClass()}
|
||||||
{urlValidation.status === "validating" && (
|
/>
|
||||||
<span className="validation-status validating">Validating...</span>
|
{urlValidation.status === "validating" && (
|
||||||
)}
|
<span className="validation-status validating">Validating...</span>
|
||||||
{urlValidation.status === "valid" && (
|
)}
|
||||||
<span className="validation-status valid">
|
{urlValidation.status === "valid" && (
|
||||||
<Icon name="success" size="sm" /> Valid git URL
|
<span className="validation-status valid">
|
||||||
</span>
|
<Icon name="success" size="sm" /> Valid git URL
|
||||||
)}
|
|
||||||
{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>
|
</span>
|
||||||
<div className="suggestion-actions">
|
)}
|
||||||
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
|
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
||||||
<button
|
<div className="url-suggestion">
|
||||||
type="button"
|
<span className="validation-status warning">
|
||||||
className="secondary-button small"
|
<Icon name="warning" size="sm" /> This looks like a browser URL
|
||||||
onClick={handleUseSuggestedUrl}
|
</span>
|
||||||
>
|
<div className="suggestion-actions">
|
||||||
Use Suggested
|
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={handleUseSuggestedUrl}
|
||||||
|
>
|
||||||
|
Use Suggested
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{urlValidation.status === "invalid" && (
|
||||||
{urlValidation.status === "invalid" && (
|
<span className="validation-status invalid">
|
||||||
<span className="validation-status invalid">
|
<Icon name="error" size="sm" /> Invalid URL
|
||||||
<Icon name="error" size="sm" /> Invalid URL
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
SSH Key
|
||||||
|
<select
|
||||||
|
value={selectedSshKey}
|
||||||
|
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select SSH key (optional)...</option>
|
||||||
|
{sshKeys.map((k) => (
|
||||||
|
<option key={k.id} value={k.id}>
|
||||||
|
{k.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
@@ -269,7 +319,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
|
|||||||
>
|
>
|
||||||
Use owner/repo instead
|
Use owner/repo instead
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</>
|
||||||
)}
|
)}
|
||||||
{formError && (
|
{formError && (
|
||||||
<div className="error-message">
|
<div className="error-message">
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { Session } from "../api/sessions";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
export interface SessionCardProps {
|
||||||
|
session: Session;
|
||||||
|
onOpen?: (session: Session) => void;
|
||||||
|
onStart?: (session: Session) => void;
|
||||||
|
onStop?: (session: Session) => void;
|
||||||
|
onDelete?: (session: Session) => void;
|
||||||
|
onRecreateTunnel?: (session: Session) => void;
|
||||||
|
isBusy?: boolean;
|
||||||
|
tunnelHealth?: {
|
||||||
|
healthy: boolean;
|
||||||
|
container_status: string;
|
||||||
|
container_health: string | null;
|
||||||
|
tunnel_status: string;
|
||||||
|
tunnel_status_code: number | null;
|
||||||
|
probe_status: string;
|
||||||
|
last_probe_output: string | null;
|
||||||
|
error: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { color: string; label: string }> = {
|
||||||
|
running: { color: "green", label: "Running" },
|
||||||
|
building: { color: "yellow", label: "Building" },
|
||||||
|
starting: { color: "yellow", label: "Starting" },
|
||||||
|
probing: { color: "yellow", label: "Probing" },
|
||||||
|
pending: { color: "yellow", label: "Pending" },
|
||||||
|
stopped: { color: "gray", label: "Stopped" },
|
||||||
|
error: { color: "red", label: "Error" },
|
||||||
|
unhealthy: { color: "orange", label: "Unhealthy" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SessionCard({
|
||||||
|
session,
|
||||||
|
onOpen,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onDelete,
|
||||||
|
onRecreateTunnel,
|
||||||
|
isBusy = false,
|
||||||
|
tunnelHealth = null,
|
||||||
|
}: SessionCardProps) {
|
||||||
|
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
const status = statusConfig[session.status] || { color: "gray", label: session.status };
|
||||||
|
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
|
||||||
|
const hasTunnelError = !isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
|
||||||
|
const hasAppError = !isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
if (showStopConfirm) {
|
||||||
|
setShowStopConfirm(false);
|
||||||
|
onStop?.(session);
|
||||||
|
} else {
|
||||||
|
setShowStopConfirm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (showDeleteConfirm) {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onDelete?.(session);
|
||||||
|
} else {
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelStop = () => setShowStopConfirm(false);
|
||||||
|
const handleCancelDelete = () => setShowDeleteConfirm(false);
|
||||||
|
|
||||||
|
const isActive = ["running", "building", "starting", "probing", "pending", "unhealthy"].includes(session.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="card session-card">
|
||||||
|
<div className="session-card-content">
|
||||||
|
<div className="session-card-header">
|
||||||
|
<div className="session-card-title">
|
||||||
|
<h4>{session.display_name}</h4>
|
||||||
|
<div className="session-card-status-badges">
|
||||||
|
<span className={`status-badge ${status.color}`}>{status.label}</span>
|
||||||
|
{hasTunnelError && (
|
||||||
|
<span className="status-badge error">Tunnel Error</span>
|
||||||
|
)}
|
||||||
|
{hasAppError && (
|
||||||
|
<span className="status-badge warning">App Error {tunnelHealth?.tunnel_status_code}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="muted session-card-meta">
|
||||||
|
{session.tool_type_name}
|
||||||
|
{session.project_name && ` · ${session.project_name}`}
|
||||||
|
{session.repository_name && ` · ${session.repository_name}`}
|
||||||
|
</p>
|
||||||
|
{session.clone_mode && (
|
||||||
|
<p className="muted session-card-meta">
|
||||||
|
<Icon name="branch" size="sm" />
|
||||||
|
{session.clone_mode === "clone"
|
||||||
|
? `Clone${session.branch ? ` (${session.branch})` : ""}`
|
||||||
|
: "Mount"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{session.url && (
|
||||||
|
<p className="session-card-url">
|
||||||
|
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{session.url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{session.created_at && (
|
||||||
|
<p className="muted session-card-meta">
|
||||||
|
Created: {new Date(session.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="session-card-actions">
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
{session.url ? (
|
||||||
|
<a
|
||||||
|
href={session.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="secondary-button small"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
<span className="action-label">Open</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => onOpen?.(session)}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
<span className="action-label">Open</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasTunnelError && onRecreateTunnel && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => onRecreateTunnel(session)}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
<span className="action-label">Tunnel</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showStopConfirm ? (
|
||||||
|
<div className="confirm-inline">
|
||||||
|
<span className="confirm-text">Stop?</span>
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={handleStop}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={handleCancelStop}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={handleStop}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
<span className="action-label">Stop</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isActive && onStart && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => onStart(session)}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
<span className="action-label">Start</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDeleteConfirm ? (
|
||||||
|
<div className="confirm-inline">
|
||||||
|
<span className="confirm-text">Delete?</span>
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={handleDelete}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={handleCancelDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={handleDelete}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { Session } from "../api/sessions";
|
||||||
|
import { SessionCard } from "./session-card";
|
||||||
|
import type { InstanceHealth } from "../api/sessions";
|
||||||
|
|
||||||
|
export interface SessionListProps {
|
||||||
|
sessions: Session[];
|
||||||
|
onOpen?: (session: Session) => void;
|
||||||
|
onStart?: (session: Session) => void;
|
||||||
|
onStop?: (session: Session) => void;
|
||||||
|
onDelete?: (session: Session) => void;
|
||||||
|
onRecreateTunnel?: (session: Session) => void;
|
||||||
|
actionBusyId?: string | null;
|
||||||
|
tunnelHealth?: Record<string, InstanceHealth>;
|
||||||
|
showGrouping?: boolean;
|
||||||
|
activeTitle?: string;
|
||||||
|
recentTitle?: string;
|
||||||
|
maxRecent?: number;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeStatuses = ["running", "building", "starting", "probing", "pending", "unhealthy"];
|
||||||
|
const recentStatuses = ["stopped", "error"];
|
||||||
|
|
||||||
|
export function SessionList({
|
||||||
|
sessions,
|
||||||
|
onOpen,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onDelete,
|
||||||
|
onRecreateTunnel,
|
||||||
|
actionBusyId = null,
|
||||||
|
tunnelHealth = {},
|
||||||
|
showGrouping = true,
|
||||||
|
activeTitle = "Active Sessions",
|
||||||
|
recentTitle = "Recent Sessions",
|
||||||
|
maxRecent = 5,
|
||||||
|
emptyMessage = "No sessions",
|
||||||
|
}: SessionListProps) {
|
||||||
|
const activeSessions = sessions.filter((s) => activeStatuses.includes(s.status));
|
||||||
|
const recentSessions = sessions
|
||||||
|
.filter((s) => recentStatuses.includes(s.status))
|
||||||
|
.slice(0, maxRecent);
|
||||||
|
|
||||||
|
if (!showGrouping) {
|
||||||
|
return (
|
||||||
|
<div className="sessions-grid">
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<p className="muted">{emptyMessage}</p>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onStart={onStart}
|
||||||
|
onStop={onStop}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
|
isBusy={actionBusyId === session.id}
|
||||||
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="session-list">
|
||||||
|
{/* Active Sessions */}
|
||||||
|
<div className="session-group">
|
||||||
|
<div className="session-group-header">
|
||||||
|
<h3>{activeTitle}</h3>
|
||||||
|
{activeSessions.length > 0 && (
|
||||||
|
<span className="badge">{activeSessions.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{activeSessions.length === 0 ? (
|
||||||
|
<p className="muted">No active sessions</p>
|
||||||
|
) : (
|
||||||
|
<div className="sessions-grid">
|
||||||
|
{activeSessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onStart={onStart}
|
||||||
|
onStop={onStop}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
|
isBusy={actionBusyId === session.id}
|
||||||
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Sessions */}
|
||||||
|
{recentSessions.length > 0 && (
|
||||||
|
<div className="session-group">
|
||||||
|
<div className="session-group-header">
|
||||||
|
<h3>{recentTitle}</h3>
|
||||||
|
<span className="badge">{recentSessions.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sessions-grid">
|
||||||
|
{recentSessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onStart={onStart}
|
||||||
|
onStop={onStop}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
|
isBusy={actionBusyId === session.id}
|
||||||
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
|
||||||
|
|
||||||
|
interface SpecialKeysPanelProps {
|
||||||
|
onSend: (data: string) => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onKeepFocus?: () => void;
|
||||||
|
activeModifier: ModifierKey | null;
|
||||||
|
onModifierChange: (modifier: ModifierKey | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
|
||||||
|
{ key: "home", label: "Home" },
|
||||||
|
{ key: "end", label: "End" },
|
||||||
|
{ key: "pageup", label: "PgUp" },
|
||||||
|
{ key: "pagedown", label: "PgDn" },
|
||||||
|
{ key: "ctrlc", label: "Ctrl+C" },
|
||||||
|
{ key: "ctrld", label: "Ctrl+D" },
|
||||||
|
{ key: "ctrlz", label: "Ctrl+Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const F_KEYS: { key: SpecialKey; label: string }[] = [
|
||||||
|
{ key: "f1", label: "F1" },
|
||||||
|
{ key: "f2", label: "F2" },
|
||||||
|
{ key: "f3", label: "F3" },
|
||||||
|
{ key: "f4", label: "F4" },
|
||||||
|
{ key: "f5", label: "F5" },
|
||||||
|
{ key: "f6", label: "F6" },
|
||||||
|
{ key: "f7", label: "F7" },
|
||||||
|
{ key: "f8", label: "F8" },
|
||||||
|
{ key: "f9", label: "F9" },
|
||||||
|
{ key: "f10", label: "F10" },
|
||||||
|
{ key: "f11", label: "F11" },
|
||||||
|
{ key: "f12", label: "F12" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
|
||||||
|
onSend,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onKeepFocus,
|
||||||
|
activeModifier,
|
||||||
|
onModifierChange,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const result = getSequenceWithModifier(key, activeModifier);
|
||||||
|
if (result) {
|
||||||
|
onSend(result.sequence);
|
||||||
|
if (result.clearModifier) {
|
||||||
|
onModifierChange(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
// Always refocus terminal after sending
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onKeepFocus?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOverlayPointerDown = (e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onModifierChange(null);
|
||||||
|
onClose();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onKeepFocus?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="special-keys-panel-overlay"
|
||||||
|
onPointerDown={handleOverlayPointerDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="special-keys-panel"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="special-keys-panel-section">
|
||||||
|
{EXPANDED_KEYS.map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className="special-key-button"
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="special-keys-panel-divider" />
|
||||||
|
<div className="special-keys-panel-section">
|
||||||
|
{F_KEYS.map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className="special-key-button"
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { getSequenceWithModifier, type SpecialKey, type ModifierKey, KEY_SEQUENCES } from "../hooks/use-special-keys";
|
||||||
|
|
||||||
|
interface SpecialKeysStripProps {
|
||||||
|
onSend: (data: string) => void;
|
||||||
|
isVisible: boolean;
|
||||||
|
onMoreClick?: () => void;
|
||||||
|
onKeepFocus?: () => void;
|
||||||
|
activeModifier: ModifierKey | null;
|
||||||
|
onModifierChange: (modifier: ModifierKey | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [
|
||||||
|
{ key: "escape", label: "Esc" },
|
||||||
|
{ key: "tab", label: "Tab" },
|
||||||
|
{ key: "ctrl", label: "Ctrl", isModifier: true },
|
||||||
|
{ key: "alt", label: "Alt", isModifier: true },
|
||||||
|
{ key: "up", label: "↑" },
|
||||||
|
{ key: "down", label: "↓" },
|
||||||
|
{ key: "left", label: "←" },
|
||||||
|
{ key: "right", label: "→" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
|
||||||
|
onSend,
|
||||||
|
isVisible,
|
||||||
|
onMoreClick,
|
||||||
|
onKeepFocus,
|
||||||
|
activeModifier,
|
||||||
|
onModifierChange,
|
||||||
|
}) => {
|
||||||
|
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Handle modifier keys (one-shot)
|
||||||
|
if (key === "ctrl" || key === "alt") {
|
||||||
|
onModifierChange(activeModifier === key ? null : key);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onKeepFocus?.();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = getSequenceWithModifier(key, activeModifier);
|
||||||
|
if (result) {
|
||||||
|
onSend(result.sequence);
|
||||||
|
if (result.clearModifier) {
|
||||||
|
onModifierChange(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always refocus terminal after sending
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onKeepFocus?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMorePointerDown = (e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onMoreClick?.();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onKeepFocus?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`special-keys-strip ${isVisible ? "visible" : "hidden"}`}>
|
||||||
|
{PRIMARY_KEYS.map(({ key, label, isModifier }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className={`special-key-button ${
|
||||||
|
isModifier && activeModifier === key ? "active-modifier" : ""
|
||||||
|
}`}
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={`Send ${label}`}
|
||||||
|
aria-pressed={isModifier && activeModifier === key}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{onMoreClick && (
|
||||||
|
<button
|
||||||
|
className="special-key-button special-key-more"
|
||||||
|
onPointerDown={handleMorePointerDown}
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="More special keys"
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,29 +1,174 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { Terminal } from "xterm";
|
import { Terminal } from "xterm";
|
||||||
import { FitAddon } from "xterm-addon-fit";
|
import { FitAddon } from "xterm-addon-fit";
|
||||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||||
import "xterm/css/xterm.css";
|
import "xterm/css/xterm.css";
|
||||||
|
|
||||||
|
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys";
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
activeModifier?: ModifierKey | null;
|
||||||
|
onModifierChange?: (modifier: ModifierKey | null) => void;
|
||||||
|
onTerminalReady?: (
|
||||||
|
sendData: (data: string) => void,
|
||||||
|
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting",
|
||||||
|
focusInput: () => void,
|
||||||
|
changeFontSize: (delta: number) => void
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
|
const FONT_SIZE_KEY = "terminal-font-size";
|
||||||
|
const MIN_FONT_SIZE = 10;
|
||||||
|
const MAX_FONT_SIZE = 24;
|
||||||
|
const RECONNECT_ATTEMPTS = 3;
|
||||||
|
const RECONNECT_DELAY_BASE = 1000;
|
||||||
|
|
||||||
|
export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||||
|
instanceId,
|
||||||
|
onClose,
|
||||||
|
isMobile = false,
|
||||||
|
activeModifier,
|
||||||
|
onModifierChange,
|
||||||
|
onTerminalReady,
|
||||||
|
}) => {
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hiddenInputRef = useRef<HTMLInputElement>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
|
const termRef = useRef<Terminal | null>(null);
|
||||||
"connecting",
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
const onTerminalReadyRef = useRef(onTerminalReady);
|
||||||
|
onTerminalReadyRef.current = onTerminalReady;
|
||||||
|
const [status, setStatus] = useState<
|
||||||
|
"connecting" | "connected" | "disconnected" | "error" | "resetting"
|
||||||
|
>("connecting");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const activeModifierRef = useRef(activeModifier);
|
||||||
|
activeModifierRef.current = activeModifier;
|
||||||
|
const [fontSize, setFontSize] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return isMobile ? 16 : 14;
|
||||||
|
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
||||||
|
return stored ? parseInt(stored, 10) : isMobile ? 16 : 14;
|
||||||
|
});
|
||||||
|
const lastPingRef = useRef<number>(0);
|
||||||
|
const heartbeatCheckRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const calculateFontSize = useCallback(() => {
|
||||||
|
if (!isMobile) return fontSize;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const calculated = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, vw / 25));
|
||||||
|
return Math.round(calculated);
|
||||||
|
}, [isMobile, fontSize]);
|
||||||
|
|
||||||
|
const connectWebSocket = useCallback(() => {
|
||||||
|
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||||
|
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setStatus("connected");
|
||||||
|
setError(null);
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
lastPingRef.current = Date.now();
|
||||||
|
|
||||||
|
// Start heartbeat check
|
||||||
|
if (heartbeatCheckRef.current) {
|
||||||
|
window.clearInterval(heartbeatCheckRef.current);
|
||||||
|
}
|
||||||
|
heartbeatCheckRef.current = window.setInterval(() => {
|
||||||
|
const elapsed = Date.now() - lastPingRef.current;
|
||||||
|
if (elapsed > 60000) {
|
||||||
|
// No ping for 60 seconds, connection may be dead
|
||||||
|
console.warn("Terminal heartbeat timeout, reconnecting...");
|
||||||
|
ws.close(4000, "Heartbeat timeout");
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (!termRef.current) return;
|
||||||
|
|
||||||
|
if (event.data instanceof Blob) {
|
||||||
|
event.data.arrayBuffer().then((buffer) => {
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
termRef.current?.write(data);
|
||||||
|
});
|
||||||
|
} else if (typeof event.data === "string") {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === "status") {
|
||||||
|
if (msg.status === "connected") {
|
||||||
|
setStatus("connected");
|
||||||
|
setError(null);
|
||||||
|
} else if (msg.status === "resetting") {
|
||||||
|
setStatus("resetting");
|
||||||
|
}
|
||||||
|
} else if (msg.type === "ping") {
|
||||||
|
// Respond with pong and update last ping time
|
||||||
|
lastPingRef.current = Date.now();
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "pong" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
termRef.current?.write(event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
setStatus("disconnected");
|
||||||
|
|
||||||
|
// Clean up heartbeat check
|
||||||
|
if (heartbeatCheckRef.current) {
|
||||||
|
window.clearInterval(heartbeatCheckRef.current);
|
||||||
|
heartbeatCheckRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.code !== 1000 && event.code !== 4000) {
|
||||||
|
setError(`Connection closed (code: ${event.code})`);
|
||||||
|
|
||||||
|
// Attempt reconnection
|
||||||
|
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
||||||
|
reconnectAttemptsRef.current++;
|
||||||
|
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.visibilityState !== "hidden") {
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
} else if (event.code === 4000) {
|
||||||
|
// Server closed old connection for concurrent connection - don't reconnect
|
||||||
|
// The new connection is already established
|
||||||
|
console.log("Old WebSocket closed by server (new connection established)");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setStatus("error");
|
||||||
|
setError("WebSocket error");
|
||||||
|
};
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminalRef.current) return;
|
if (!terminalRef.current) return;
|
||||||
|
|
||||||
// Initialize terminal
|
// Initialize terminal
|
||||||
|
const currentFontSize = calculateFontSize();
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: currentFontSize,
|
||||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
theme: {
|
theme: {
|
||||||
background: "#1e1e1e",
|
background: "#1e1e1e",
|
||||||
@@ -49,110 +194,338 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
termRef.current = term;
|
||||||
|
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
term.loadAddon(new WebLinksAddon());
|
term.loadAddon(new WebLinksAddon());
|
||||||
|
|
||||||
term.open(terminalRef.current);
|
term.open(terminalRef.current);
|
||||||
fitAddon.fit();
|
|
||||||
|
|
||||||
// Build WebSocket URL
|
|
||||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
|
||||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
||||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
|
||||||
|
|
||||||
// Connect WebSocket
|
// Connect WebSocket
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = connectWebSocket();
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
// Fit after layout settles - use rAF + timeout to ensure DOM is ready
|
||||||
setStatus("connected");
|
requestAnimationFrame(() => {
|
||||||
setError(null);
|
requestAnimationFrame(() => {
|
||||||
};
|
fitAddon.fit();
|
||||||
|
// Force a second fit after layout fully settles
|
||||||
ws.onmessage = (event) => {
|
setTimeout(() => {
|
||||||
if (event.data instanceof Blob) {
|
fitAddon.fit();
|
||||||
event.data.arrayBuffer().then((buffer) => {
|
const { cols, rows } = term;
|
||||||
const data = new Uint8Array(buffer);
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
term.write(data);
|
ws.send(
|
||||||
});
|
JSON.stringify({
|
||||||
} else if (typeof event.data === "string") {
|
type: "resize",
|
||||||
try {
|
cols,
|
||||||
const msg = JSON.parse(event.data);
|
rows,
|
||||||
if (msg.type === "status" && msg.status === "connected") {
|
})
|
||||||
setStatus("connected");
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
}, 500);
|
||||||
term.write(event.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
// Fit after mobile header auto-hides (3s delay + 0.3s transition)
|
||||||
setStatus("disconnected");
|
setTimeout(() => {
|
||||||
if (event.code !== 1000) {
|
fitAddon.fit();
|
||||||
setError(`Connection closed (code: ${event.code})`);
|
const { cols, rows } = term;
|
||||||
}
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
};
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
ws.onerror = () => {
|
type: "resize",
|
||||||
setStatus("error");
|
cols,
|
||||||
setError("WebSocket error");
|
rows,
|
||||||
};
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 4000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Handle terminal input
|
// Handle terminal input
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
ws.send(data);
|
|
||||||
|
// Apply active modifier to single-character input
|
||||||
|
const modifier = activeModifierRef.current;
|
||||||
|
if (modifier && data.length === 1) {
|
||||||
|
const modified = applyModifierToChar(data, modifier);
|
||||||
|
if (modified) {
|
||||||
|
ws.send(modified);
|
||||||
|
onModifierChange?.(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ws.send(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize with debounce
|
||||||
|
let resizeTimeout: ReturnType<typeof setTimeout>;
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
fitAddon.fit();
|
clearTimeout(resizeTimeout);
|
||||||
const { cols, rows } = term;
|
resizeTimeout = setTimeout(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
fitAddon.fit();
|
||||||
ws.send(
|
const { cols, rows } = term;
|
||||||
JSON.stringify({
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
type: "resize",
|
ws.send(
|
||||||
cols,
|
JSON.stringify({
|
||||||
rows,
|
type: "resize",
|
||||||
}),
|
cols,
|
||||||
);
|
rows,
|
||||||
}
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
// Initial resize
|
// Notify parent about terminal readiness
|
||||||
setTimeout(handleResize, 100);
|
if (onTerminalReadyRef.current) {
|
||||||
|
const sendData = (data: string) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const focusInput = () => {
|
||||||
|
termRef.current?.focus();
|
||||||
|
};
|
||||||
|
const changeFontSize = (delta: number) => {
|
||||||
|
handleFontSizeChange(delta);
|
||||||
|
};
|
||||||
|
onTerminalReadyRef.current(sendData, status, focusInput, changeFontSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility API for reconnection
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "visible" && ws.readyState !== WebSocket.OPEN) {
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
clearTimeout(resizeTimeout);
|
||||||
window.removeEventListener("resize", handleResize);
|
window.removeEventListener("resize", handleResize);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
ws.close();
|
ws.close();
|
||||||
term.dispose();
|
term.dispose();
|
||||||
};
|
};
|
||||||
}, [instanceId]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [instanceId, connectWebSocket]);
|
||||||
|
|
||||||
|
// Update parent about status changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (onTerminalReady && termRef.current) {
|
||||||
|
const sendData = (data: string) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const focusInput = () => {
|
||||||
|
termRef.current?.focus();
|
||||||
|
};
|
||||||
|
const changeFontSize = (delta: number) => {
|
||||||
|
handleFontSizeChange(delta);
|
||||||
|
};
|
||||||
|
onTerminalReady(sendData, status, focusInput, changeFontSize);
|
||||||
|
}
|
||||||
|
}, [status, onTerminalReady]);
|
||||||
|
|
||||||
|
const handleFontSizeChange = (delta: number) => {
|
||||||
|
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
|
||||||
|
setFontSize(newSize);
|
||||||
|
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
||||||
|
if (termRef.current && fitAddonRef.current) {
|
||||||
|
termRef.current.options.fontSize = newSize;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (termRef.current && fitAddonRef.current) {
|
||||||
|
try {
|
||||||
|
fitAddonRef.current.fit();
|
||||||
|
const { cols, rows } = termRef.current;
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "resize",
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore fit errors during re-initialization
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!termRef.current) return;
|
||||||
|
const selection = termRef.current.getSelection();
|
||||||
|
if (selection) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(selection);
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = selection;
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(text);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Clipboard API not available
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Focus terminal on mobile to keep keyboard open
|
||||||
|
const handleTerminalClick = () => {
|
||||||
|
if (isMobile && termRef.current) {
|
||||||
|
termRef.current.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="terminal-wrapper">
|
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
|
||||||
<div className="terminal-header">
|
<div className="terminal-header">
|
||||||
<div className="terminal-status">
|
<div className="terminal-header-left">
|
||||||
<span
|
<div className="terminal-status">
|
||||||
className={`status-dot ${status}`}
|
<span
|
||||||
aria-label={`Terminal status: ${status}`}
|
className={`status-dot ${status}`}
|
||||||
/>
|
aria-label={`Terminal status: ${status}`}
|
||||||
<span className="status-text">{status}</span>
|
/>
|
||||||
|
<span className="status-text">
|
||||||
|
{status === "resetting"
|
||||||
|
? "Resetting..."
|
||||||
|
: reconnectAttemptsRef.current > 0 && status !== "connected"
|
||||||
|
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
|
||||||
|
: status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isMobile && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
type="button"
|
||||||
|
aria-label="Copy selection"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={handlePaste}
|
||||||
|
type="button"
|
||||||
|
aria-label="Paste from clipboard"
|
||||||
|
>
|
||||||
|
Paste
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onClose && (
|
<div className="terminal-header-right">
|
||||||
<button className="terminal-close" onClick={onClose} type="button">
|
<button
|
||||||
Close
|
className="terminal-header-button"
|
||||||
|
onClick={() => handleFontSizeChange(-1)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Decrease font size"
|
||||||
|
>
|
||||||
|
A-
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={() => handleFontSizeChange(1)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Increase font size"
|
||||||
|
>
|
||||||
|
A+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-header-button"
|
||||||
|
onClick={() => setShowResetConfirm(true)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Reset terminal"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
{onClose && (
|
||||||
|
<button className="terminal-close" onClick={onClose} type="button">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="terminal-error">{error}</div>}
|
{showResetConfirm && (
|
||||||
<div ref={terminalRef} className="terminal-container" />
|
<div className="terminal-reset-confirm">
|
||||||
|
<div className="terminal-reset-confirm-content">
|
||||||
|
<p>Reset terminal? This will kill the current shell session and start fresh.</p>
|
||||||
|
<div className="terminal-reset-confirm-buttons">
|
||||||
|
<button
|
||||||
|
className="terminal-reset-confirm-button cancel"
|
||||||
|
onClick={() => setShowResetConfirm(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="terminal-reset-confirm-button confirm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowResetConfirm(false);
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: "reset" }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="terminal-error">
|
||||||
|
{error}
|
||||||
|
{status === "error" && (
|
||||||
|
<button
|
||||||
|
className="terminal-reconnect"
|
||||||
|
onClick={() => {
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
connectWebSocket();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={terminalRef}
|
||||||
|
className="terminal-container"
|
||||||
|
onClick={handleTerminalClick}
|
||||||
|
/>
|
||||||
|
{isMobile && (
|
||||||
|
<input
|
||||||
|
ref={hiddenInputRef}
|
||||||
|
type="text"
|
||||||
|
className="terminal-hidden-input"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
interface AutoHideOptions {
|
||||||
|
timeout?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoHide(options: AutoHideOptions = {}) {
|
||||||
|
const { timeout = 3000, enabled = true } = options;
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const lastInteractionRef = useRef(Date.now());
|
||||||
|
|
||||||
|
const show = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
setIsVisible(true);
|
||||||
|
lastInteractionRef.current = Date.now();
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
}, timeout);
|
||||||
|
}, [enabled, timeout]);
|
||||||
|
|
||||||
|
const hide = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
setIsVisible(false);
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (isVisible) {
|
||||||
|
hide();
|
||||||
|
} else {
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
}, [enabled, isVisible, show, hide]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setIsVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the timer initially
|
||||||
|
show();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [enabled, show]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isVisible,
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
toggle,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export function useMobileViewport() {
|
||||||
|
const [isMobile, setIsMobile] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export type SpecialKey =
|
||||||
|
| "escape"
|
||||||
|
| "tab"
|
||||||
|
| "ctrl"
|
||||||
|
| "alt"
|
||||||
|
| "up"
|
||||||
|
| "down"
|
||||||
|
| "left"
|
||||||
|
| "right"
|
||||||
|
| "home"
|
||||||
|
| "end"
|
||||||
|
| "pageup"
|
||||||
|
| "pagedown"
|
||||||
|
| "ctrlc"
|
||||||
|
| "ctrld"
|
||||||
|
| "ctrlz"
|
||||||
|
| "f1"
|
||||||
|
| "f2"
|
||||||
|
| "f3"
|
||||||
|
| "f4"
|
||||||
|
| "f5"
|
||||||
|
| "f6"
|
||||||
|
| "f7"
|
||||||
|
| "f8"
|
||||||
|
| "f9"
|
||||||
|
| "f10"
|
||||||
|
| "f11"
|
||||||
|
| "f12";
|
||||||
|
|
||||||
|
export type ModifierKey = "ctrl" | "alt";
|
||||||
|
|
||||||
|
const KEY_SEQUENCES: Record<SpecialKey, string> = {
|
||||||
|
escape: "\x1B",
|
||||||
|
tab: "\t",
|
||||||
|
ctrl: "",
|
||||||
|
alt: "",
|
||||||
|
up: "\x1B[A",
|
||||||
|
down: "\x1B[B",
|
||||||
|
right: "\x1B[C",
|
||||||
|
left: "\x1B[D",
|
||||||
|
home: "\x1B[H",
|
||||||
|
end: "\x1B[F",
|
||||||
|
pageup: "\x1B[5~",
|
||||||
|
pagedown: "\x1B[6~",
|
||||||
|
ctrlc: "\x03",
|
||||||
|
ctrld: "\x04",
|
||||||
|
ctrlz: "\x1A",
|
||||||
|
f1: "\x1BOP",
|
||||||
|
f2: "\x1BOQ",
|
||||||
|
f3: "\x1BOR",
|
||||||
|
f4: "\x1BOS",
|
||||||
|
f5: "\x1B[15~",
|
||||||
|
f6: "\x1B[17~",
|
||||||
|
f7: "\x1B[18~",
|
||||||
|
f8: "\x1B[19~",
|
||||||
|
f9: "\x1B[20~",
|
||||||
|
f10: "\x1B[21~",
|
||||||
|
f11: "\x1B[23~",
|
||||||
|
f12: "\x1B[24~",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single character sequences with modifier prefixes
|
||||||
|
const MODIFIER_PREFIXES: Record<string, { ctrl: string; alt: string; ctrlAlt: string }> = {
|
||||||
|
// Letters
|
||||||
|
a: { ctrl: "\x01", alt: "\x1Ba", ctrlAlt: "\x1B\x01" },
|
||||||
|
b: { ctrl: "\x02", alt: "\x1Bb", ctrlAlt: "\x1B\x02" },
|
||||||
|
c: { ctrl: "\x03", alt: "\x1Bc", ctrlAlt: "\x1B\x03" },
|
||||||
|
d: { ctrl: "\x04", alt: "\x1Bd", ctrlAlt: "\x1B\x04" },
|
||||||
|
e: { ctrl: "\x05", alt: "\x1Be", ctrlAlt: "\x1B\x05" },
|
||||||
|
f: { ctrl: "\x06", alt: "\x1Bf", ctrlAlt: "\x1B\x06" },
|
||||||
|
g: { ctrl: "\x07", alt: "\x1Bg", ctrlAlt: "\x1B\x07" },
|
||||||
|
h: { ctrl: "\x08", alt: "\x1Bh", ctrlAlt: "\x1B\x08" },
|
||||||
|
i: { ctrl: "\x09", alt: "\x1Bi", ctrlAlt: "\x1B\x09" },
|
||||||
|
j: { ctrl: "\x0A", alt: "\x1Bj", ctrlAlt: "\x1B\x0A" },
|
||||||
|
k: { ctrl: "\x0B", alt: "\x1Bk", ctrlAlt: "\x1B\x0B" },
|
||||||
|
l: { ctrl: "\x0C", alt: "\x1Bl", ctrlAlt: "\x1B\x0C" },
|
||||||
|
m: { ctrl: "\x0D", alt: "\x1Bm", ctrlAlt: "\x1B\x0D" },
|
||||||
|
n: { ctrl: "\x0E", alt: "\x1Bn", ctrlAlt: "\x1B\x0E" },
|
||||||
|
o: { ctrl: "\x0F", alt: "\x1Bo", ctrlAlt: "\x1B\x0F" },
|
||||||
|
p: { ctrl: "\x10", alt: "\x1Bp", ctrlAlt: "\x1B\x10" },
|
||||||
|
q: { ctrl: "\x11", alt: "\x1Bq", ctrlAlt: "\x1B\x11" },
|
||||||
|
r: { ctrl: "\x12", alt: "\x1Br", ctrlAlt: "\x1B\x12" },
|
||||||
|
s: { ctrl: "\x13", alt: "\x1Bs", ctrlAlt: "\x1B\x13" },
|
||||||
|
t: { ctrl: "\x14", alt: "\x1Bt", ctrlAlt: "\x1B\x14" },
|
||||||
|
u: { ctrl: "\x15", alt: "\x1Bu", ctrlAlt: "\x1B\x15" },
|
||||||
|
v: { ctrl: "\x16", alt: "\x1Bv", ctrlAlt: "\x1B\x16" },
|
||||||
|
w: { ctrl: "\x17", alt: "\x1Bw", ctrlAlt: "\x1B\x17" },
|
||||||
|
x: { ctrl: "\x18", alt: "\x1Bx", ctrlAlt: "\x1B\x18" },
|
||||||
|
y: { ctrl: "\x19", alt: "\x1By", ctrlAlt: "\x1B\x19" },
|
||||||
|
z: { ctrl: "\x1A", alt: "\x1Bz", ctrlAlt: "\x1B\x1A" },
|
||||||
|
// Numbers
|
||||||
|
"0": { ctrl: "0", alt: "\x1B0", ctrlAlt: "\x1B0" },
|
||||||
|
"1": { ctrl: "1", alt: "\x1B1", ctrlAlt: "\x1B1" },
|
||||||
|
"2": { ctrl: "\x00", alt: "\x1B2", ctrlAlt: "\x1B\x00" },
|
||||||
|
"3": { ctrl: "\x1B", alt: "\x1B3", ctrlAlt: "\x1B\x1B" },
|
||||||
|
"4": { ctrl: "\x1C", alt: "\x1B4", ctrlAlt: "\x1B\x1C" },
|
||||||
|
"5": { ctrl: "\x1D", alt: "\x1B5", ctrlAlt: "\x1B\x1D" },
|
||||||
|
"6": { ctrl: "\x1E", alt: "\x1B6", ctrlAlt: "\x1B\x1E" },
|
||||||
|
"7": { ctrl: "\x1F", alt: "\x1B7", ctrlAlt: "\x1B\x1F" },
|
||||||
|
"8": { ctrl: "\x7F", alt: "\x1B8", ctrlAlt: "\x1B\x7F" },
|
||||||
|
"9": { ctrl: "9", alt: "\x1B9", ctrlAlt: "\x1B9" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSequenceWithModifier(
|
||||||
|
key: SpecialKey,
|
||||||
|
activeModifier: ModifierKey | null
|
||||||
|
): { sequence: string; clearModifier: boolean } | null {
|
||||||
|
// Handle modifier keys (one-shot)
|
||||||
|
if (key === "ctrl" || key === "alt") {
|
||||||
|
return null; // Modifiers don't send anything themselves
|
||||||
|
}
|
||||||
|
|
||||||
|
const sequence = KEY_SEQUENCES[key];
|
||||||
|
if (!sequence) return null;
|
||||||
|
|
||||||
|
// Check if we have an active modifier and the key is a single character
|
||||||
|
if (activeModifier && sequence.length === 1) {
|
||||||
|
const char = sequence;
|
||||||
|
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
|
||||||
|
if (mapping) {
|
||||||
|
return { sequence: mapping[activeModifier], clearModifier: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sequence, clearModifier: !!activeModifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyModifierToChar(
|
||||||
|
char: string,
|
||||||
|
modifier: ModifierKey
|
||||||
|
): string | null {
|
||||||
|
if (char.length !== 1) return null;
|
||||||
|
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
|
||||||
|
if (!mapping) return null;
|
||||||
|
return mapping[modifier];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { KEY_SEQUENCES };
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
interface VirtualKeyboardState {
|
||||||
|
isOpen: boolean;
|
||||||
|
height: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVirtualKeyboard() {
|
||||||
|
const [state, setState] = useState<VirtualKeyboardState>({
|
||||||
|
isOpen: false,
|
||||||
|
height: 0,
|
||||||
|
viewportHeight: typeof window !== "undefined" ? window.innerHeight : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateKeyboardState = useCallback(() => {
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
|
||||||
|
if (visualViewport) {
|
||||||
|
const viewportHeight = visualViewport.height;
|
||||||
|
const keyboardHeight = windowHeight - viewportHeight;
|
||||||
|
const isOpen = keyboardHeight > 100; // Threshold to avoid false positives
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isOpen,
|
||||||
|
height: keyboardHeight,
|
||||||
|
viewportHeight,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: compare window height to a stored reference
|
||||||
|
// This is less reliable but works on older browsers
|
||||||
|
const currentHeight = windowHeight;
|
||||||
|
const isOpen = currentHeight < state.viewportHeight - 100;
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
isOpen,
|
||||||
|
height: isOpen ? prev.viewportHeight - currentHeight : 0,
|
||||||
|
viewportHeight: isOpen ? prev.viewportHeight : currentHeight,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [state.viewportHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
|
||||||
|
if (visualViewport) {
|
||||||
|
visualViewport.addEventListener("resize", updateKeyboardState);
|
||||||
|
visualViewport.addEventListener("scroll", updateKeyboardState);
|
||||||
|
} else {
|
||||||
|
window.addEventListener("resize", updateKeyboardState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
updateKeyboardState();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (visualViewport) {
|
||||||
|
visualViewport.removeEventListener("resize", updateKeyboardState);
|
||||||
|
visualViewport.removeEventListener("scroll", updateKeyboardState);
|
||||||
|
} else {
|
||||||
|
window.removeEventListener("resize", updateKeyboardState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [updateKeyboardState]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -2,13 +2,15 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||||
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
|
import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { updateUserConfig } from "../api/settings";
|
import { updateUserConfig } from "../api/settings";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
|
import { SessionList } from "../components/session-list";
|
||||||
|
|
||||||
type HomeStatus = "loading" | "ready" | "error";
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -29,11 +31,8 @@ export const HomePage = () => {
|
|||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState("");
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
const [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 [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||||
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||||
|
|
||||||
const loadHome = useCallback(async () => {
|
const loadHome = useCallback(async () => {
|
||||||
@@ -59,6 +58,49 @@ export const HomePage = () => {
|
|||||||
void loadHome();
|
void loadHome();
|
||||||
}, [loadHome]);
|
}, [loadHome]);
|
||||||
|
|
||||||
|
// Poll tunnel health every 30 seconds for running instances
|
||||||
|
useEffect(() => {
|
||||||
|
const checkHealth = async () => {
|
||||||
|
const runningSessions = safeSessions.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,
|
||||||
|
container_status: "unknown",
|
||||||
|
container_health: null,
|
||||||
|
container_exit_code: null,
|
||||||
|
tunnel_status: "error",
|
||||||
|
tunnel_status_code: null,
|
||||||
|
probe_status: "error",
|
||||||
|
last_probe_output: null,
|
||||||
|
error: "check failed",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkHealth();
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void checkHealth();
|
||||||
|
}, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [safeSessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
setRepositories([]);
|
setRepositories([]);
|
||||||
@@ -82,29 +124,10 @@ export const HomePage = () => {
|
|||||||
[safeSessions]
|
[safeSessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const recentSessions = useMemo(
|
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||||
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
[safeSessions]
|
setSelectedProject("");
|
||||||
);
|
await loadHome();
|
||||||
|
|
||||||
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) => {
|
const handleOpen = (session: SessionView) => {
|
||||||
@@ -133,7 +156,9 @@ export const HomePage = () => {
|
|||||||
setActionBusy(session.id);
|
setActionBusy(session.id);
|
||||||
try {
|
try {
|
||||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||||
await loadHome();
|
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||||
|
} catch {
|
||||||
|
// error - session remains in state
|
||||||
} finally {
|
} finally {
|
||||||
setActionBusy(null);
|
setActionBusy(null);
|
||||||
}
|
}
|
||||||
@@ -149,6 +174,16 @@ export const HomePage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStart = async (session: SessionView) => {
|
||||||
|
setActionBusy(session.id);
|
||||||
|
try {
|
||||||
|
await startInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
await loadHome();
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack home-page">
|
<section className="stack home-page">
|
||||||
<header className="home-hero card">
|
<header className="home-hero card">
|
||||||
@@ -196,45 +231,20 @@ export const HomePage = () => {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Open sessions</p>
|
<p className="eyebrow">Open sessions</p>
|
||||||
<h2>{activeSessions.length}</h2>
|
<h2>{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activeSessions.length === 0 ? (
|
<SessionList
|
||||||
<p className="muted">No active sessions right now.</p>
|
sessions={safeSessions}
|
||||||
) : (
|
onOpen={handleOpen}
|
||||||
<div className="home-session-grid">
|
onStop={handleStop}
|
||||||
{activeSessions.map((session) => (
|
onDelete={handleDelete}
|
||||||
<article className="card session-card" key={session.id}>
|
onRecreateTunnel={handleRecreateTunnel}
|
||||||
<div className="stack-sm">
|
actionBusyId={actionBusy}
|
||||||
<div className="row row-tight">
|
tunnelHealth={tunnelHealth}
|
||||||
<h3>{session.display_name}</h3>
|
showGrouping={false}
|
||||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
emptyMessage="No active sessions right now."
|
||||||
</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>
|
</section>
|
||||||
|
|
||||||
<section className="card stack home-section">
|
<section className="card stack home-section">
|
||||||
@@ -271,62 +281,33 @@ export const HomePage = () => {
|
|||||||
<h2>Start a session</h2>
|
<h2>Start a session</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form className="stack create-session-form" onSubmit={handleCreate}>
|
<CreateSessionForm
|
||||||
<div className="form-row">
|
projects={projects}
|
||||||
<label className="form-field">
|
repositories={repositories}
|
||||||
Project
|
toolTypes={toolTypes}
|
||||||
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
|
onProjectChange={(projectId) => setSelectedProject(projectId)}
|
||||||
<option value="">Select project...</option>
|
onSuccess={handleCreateSuccess}
|
||||||
{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>
|
</section>
|
||||||
|
|
||||||
{recentSessions.length > 0 && (
|
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
|
||||||
<section className="card stack home-section">
|
<section className="card stack home-section">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Recent sessions</p>
|
<p className="eyebrow">Recent sessions</p>
|
||||||
<h2>{recentSessions.length}</h2>
|
<h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="recent-sessions-list">
|
<SessionList
|
||||||
{recentSessions.map((session) => (
|
sessions={safeSessions}
|
||||||
<article className="recent-session-item" key={session.id}>
|
onOpen={handleOpen}
|
||||||
<div className="recent-session-info">
|
onStart={handleStart}
|
||||||
<span className="recent-session-name">{session.display_name}</span>
|
onDelete={handleDelete}
|
||||||
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
|
actionBusyId={actionBusy}
|
||||||
</div>
|
showGrouping={false}
|
||||||
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
maxRecent={5}
|
||||||
</article>
|
emptyMessage="No recent sessions."
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export const RepoWorkspace = () => {
|
|||||||
currentBranch={currentBranch}
|
currentBranch={currentBranch}
|
||||||
branches={branches}
|
branches={branches}
|
||||||
hasRemote={Boolean(selectedRepo?.remote_url)}
|
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||||
|
isMirror={Boolean(selectedRepo?.is_mirror)}
|
||||||
onBranchChange={(branch) => {
|
onBranchChange={(branch) => {
|
||||||
setCurrentBranch(branch);
|
setCurrentBranch(branch);
|
||||||
const newParams = new URLSearchParams(searchParams);
|
const newParams = new URLSearchParams(searchParams);
|
||||||
@@ -253,6 +254,8 @@ export const RepoWorkspace = () => {
|
|||||||
<InstanceList
|
<InstanceList
|
||||||
projectId={projectId!}
|
projectId={projectId!}
|
||||||
repoId={selectedRepoId}
|
repoId={selectedRepoId}
|
||||||
|
projectName={project?.name}
|
||||||
|
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
||||||
toolTypes={toolTypes}
|
toolTypes={toolTypes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+138
-435
@@ -9,17 +9,18 @@ import {
|
|||||||
type Session,
|
type Session,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
stopInstance,
|
stopInstance,
|
||||||
startInstance,
|
|
||||||
checkInstanceHealth,
|
checkInstanceHealth,
|
||||||
recreateInstanceTunnel,
|
recreateInstanceTunnel,
|
||||||
} from "../api/sessions";
|
} 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 { getUserConfig, updateUserConfig } from "../api/settings";
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
|
import { SessionList } from "../components/session-list";
|
||||||
|
import { SessionCard } from "../components/session-card";
|
||||||
|
import type { InstanceHealth } from "../api/sessions";
|
||||||
|
|
||||||
type SessionsStatus = "loading" | "ready" | "error";
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
type CreateStatus = "idle" | "creating" | "error";
|
|
||||||
|
|
||||||
export const SessionsPage = () => {
|
export const SessionsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -30,28 +31,14 @@ export const SessionsPage = () => {
|
|||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
|
||||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||||
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
|
||||||
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
|
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, {
|
|
||||||
healthy: boolean;
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||||
container_status: string;
|
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||||
container_health: string | null;
|
const [loadingAction, setLoadingAction] = useState<string>("");
|
||||||
tunnel_status: string;
|
|
||||||
tunnel_status_code: number | null;
|
|
||||||
probe_status: string;
|
|
||||||
last_probe_output: string | null;
|
|
||||||
error: string | null;
|
|
||||||
}>>({});
|
|
||||||
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
|
||||||
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -96,11 +83,14 @@ export const SessionsPage = () => {
|
|||||||
void loadToolTypes();
|
void loadToolTypes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Poll health every 30 seconds for active instances
|
|
||||||
|
|
||||||
|
// Poll health every 30 seconds for active web-enabled instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkHealth = async () => {
|
const checkHealth = async () => {
|
||||||
const activeSessions = sessions.filter(
|
const activeSessions = sessions.filter(
|
||||||
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
|
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
|
||||||
|
&& s.tool_type_interfaces?.includes("web")
|
||||||
);
|
);
|
||||||
for (const session of activeSessions) {
|
for (const session of activeSessions) {
|
||||||
try {
|
try {
|
||||||
@@ -125,7 +115,7 @@ export const SessionsPage = () => {
|
|||||||
probe_status: "unknown",
|
probe_status: "unknown",
|
||||||
last_probe_output: null,
|
last_probe_output: null,
|
||||||
error: "check failed",
|
error: "check failed",
|
||||||
},
|
} as InstanceHealth,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,78 +143,75 @@ export const SessionsPage = () => {
|
|||||||
void loadRepos();
|
void loadRepos();
|
||||||
}, [selectedProject]);
|
}, [selectedProject]);
|
||||||
|
|
||||||
const activeSessions = useMemo(
|
|
||||||
() => sessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)),
|
|
||||||
[sessions]
|
|
||||||
);
|
|
||||||
|
|
||||||
const recentSessions = useMemo(
|
|
||||||
() => sessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5),
|
|
||||||
[sessions]
|
|
||||||
);
|
|
||||||
|
|
||||||
const lastSession = useMemo(
|
const lastSession = useMemo(
|
||||||
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
||||||
[sessions, lastSessionId]
|
[sessions, lastSessionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||||
e.preventDefault();
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
setCreateError(null);
|
setSelectedProject("");
|
||||||
|
await loadSessions();
|
||||||
|
};
|
||||||
|
|
||||||
if (!selectedProject || !selectedRepo || !selectedToolType) {
|
const handleStop = async (session: Session) => {
|
||||||
setCreateError("Project, repository, and tool type are required");
|
setLoadingSessionId(session.id);
|
||||||
return;
|
setLoadingAction("Stopping...");
|
||||||
}
|
|
||||||
|
|
||||||
setCreateStatus("creating");
|
|
||||||
try {
|
try {
|
||||||
const instance = await createInstance(
|
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||||
selectedProject,
|
|
||||||
selectedRepo,
|
|
||||||
selectedToolType,
|
|
||||||
displayName || undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-start the instance
|
|
||||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
|
||||||
|
|
||||||
await updateUserConfig({ last_session_id: instance.id });
|
|
||||||
setCreateStatus("idle");
|
|
||||||
setSelectedProject("");
|
|
||||||
setSelectedRepo("");
|
|
||||||
setSelectedToolType("");
|
|
||||||
setDisplayName("");
|
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} catch {
|
} catch {
|
||||||
setCreateStatus("error");
|
// ignore
|
||||||
setCreateError("Failed to create session");
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
setLoadingAction("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
const handleDelete = async (session: Session) => {
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
setLoadingAction("Deleting...");
|
||||||
try {
|
try {
|
||||||
await stopInstance(projectId, repoId, sessionId);
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||||
setStopConfirmId(null);
|
setDirtyDeleteSession(null);
|
||||||
await loadSessions();
|
setDirtyDeleteFiles([]);
|
||||||
} catch {
|
|
||||||
setStopConfirmId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (sessionId: string, projectId: string, repoId: string) => {
|
|
||||||
try {
|
|
||||||
await deleteInstance(projectId, repoId, sessionId);
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
// Remove from local state immediately
|
// Remove from local state immediately
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
||||||
|
if (axiosError.response?.status === 409) {
|
||||||
|
const detail = axiosError.response.data?.detail;
|
||||||
|
if (detail?.changed_files) {
|
||||||
|
setDirtyDeleteSession(session);
|
||||||
|
setDirtyDeleteFiles(detail.changed_files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForceDelete = async (session: Session) => {
|
||||||
|
setLoadingSessionId(session.id);
|
||||||
|
setLoadingAction("Force deleting...");
|
||||||
|
try {
|
||||||
|
await deleteInstance(session.project_id, session.repository_id, session.id, true);
|
||||||
|
setDirtyDeleteSession(null);
|
||||||
|
setDirtyDeleteFiles([]);
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||||
} catch {
|
} catch {
|
||||||
setDeleteConfirmId(null);
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
setLoadingAction("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRecreateTunnel = async (session: Session) => {
|
const handleRecreateTunnel = async (session: Session) => {
|
||||||
setRecreatingId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
|
setLoadingAction("Recreating tunnel...");
|
||||||
try {
|
try {
|
||||||
await recreateInstanceTunnel(
|
await recreateInstanceTunnel(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -236,7 +223,8 @@ export const SessionsPage = () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
setRecreatingId(null);
|
setLoadingSessionId(null);
|
||||||
|
setLoadingAction("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,15 +238,6 @@ export const SessionsPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResumeLast = async () => {
|
|
||||||
if (!lastSession) return;
|
|
||||||
// Find the project and repo IDs
|
|
||||||
const project = projects.find((p) => p.name === lastSession.project_name);
|
|
||||||
if (project) {
|
|
||||||
navigate(`/projects/${project.id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack sessions-page">
|
<section className="stack sessions-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -283,364 +262,88 @@ export const SessionsPage = () => {
|
|||||||
{lastSession && (
|
{lastSession && (
|
||||||
<div className="last-session-section">
|
<div className="last-session-section">
|
||||||
<h2>Last Session</h2>
|
<h2>Last Session</h2>
|
||||||
<div className="card last-session-card">
|
<SessionCard
|
||||||
<div className="last-session-info">
|
session={lastSession}
|
||||||
<h3>{lastSession.display_name}</h3>
|
onOpen={handleOpen}
|
||||||
<p className="muted">
|
onDelete={handleDelete}
|
||||||
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
|
isBusy={loadingSessionId === lastSession.id}
|
||||||
</p>
|
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
||||||
{lastSession.url && (
|
/>
|
||||||
<p className="session-url">
|
|
||||||
<a href={lastSession.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{lastSession.url}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
|
|
||||||
</div>
|
|
||||||
<div className="last-session-actions">
|
|
||||||
{lastSession.url ? (
|
|
||||||
<a
|
|
||||||
href={lastSession.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="primary-button"
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button className="primary-button" onClick={handleResumeLast} type="button">
|
|
||||||
<Icon name="play" size="sm" />
|
|
||||||
Resume
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Sessions */}
|
{/* Session List */}
|
||||||
<div className="active-sessions-section">
|
<div className={`sessions-list-wrapper ${loadingSessionId ? "dimmed" : ""}`}>
|
||||||
<h2>
|
{loadingSessionId && (
|
||||||
Active Sessions
|
<div className="loading-overlay">
|
||||||
{activeSessions.length > 0 && (
|
<div className="loading-content">
|
||||||
<span className="badge">{activeSessions.length}</span>
|
<Icon name="loading" size="lg" />
|
||||||
)}
|
<p>{loadingAction}</p>
|
||||||
</h2>
|
</div>
|
||||||
{activeSessions.length === 0 ? (
|
|
||||||
<p className="muted">No active sessions</p>
|
|
||||||
) : (
|
|
||||||
<div className="sessions-grid">
|
|
||||||
{activeSessions.map((session) => (
|
|
||||||
<div className="card session-card" key={session.id}>
|
|
||||||
<div className="session-info">
|
|
||||||
<h4>{session.display_name}</h4>
|
|
||||||
<p className="muted">
|
|
||||||
{session.tool_type_name} · {session.project_name}
|
|
||||||
</p>
|
|
||||||
{session.url && (
|
|
||||||
<p className="session-url">
|
|
||||||
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{session.url}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
|
||||||
{session.status === "starting" && (
|
|
||||||
<span className="status-badge starting">starting...</span>
|
|
||||||
)}
|
|
||||||
{session.status === "probing" && (
|
|
||||||
<span className="status-badge probing">checking...</span>
|
|
||||||
)}
|
|
||||||
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
|
||||||
<span className="status-badge error">tunnel error</span>
|
|
||||||
)}
|
|
||||||
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
|
|
||||||
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
|
|
||||||
)}
|
|
||||||
{tunnelHealth[session.id]?.last_probe_output && (
|
|
||||||
<div className="probe-output-section">
|
|
||||||
<button
|
|
||||||
className="probe-toggle"
|
|
||||||
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="info" size="sm" />
|
|
||||||
{expandedProbeId === session.id ? "Hide probe output" : "Show probe output"}
|
|
||||||
</button>
|
|
||||||
{expandedProbeId === session.id && (
|
|
||||||
<pre className="probe-output">
|
|
||||||
{tunnelHealth[session.id].last_probe_output}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="session-actions">
|
|
||||||
{session.url ? (
|
|
||||||
<a
|
|
||||||
href={session.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="secondary-button small"
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => handleOpen(session)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
Open
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
|
||||||
<button
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => void handleRecreateTunnel(session)}
|
|
||||||
type="button"
|
|
||||||
disabled={recreatingId === session.id}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
{recreatingId === session.id ? "Recreating..." : "Recreate Tunnel"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{stopConfirmId === session.id ? (
|
|
||||||
<div className="stop-confirm-inline">
|
|
||||||
<span className="confirm-text">Stop?</span>
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={() =>
|
|
||||||
void handleStop(
|
|
||||||
session.id,
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => setStopConfirmId(null)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => setStopConfirmId(session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="stop" size="sm" />
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{deleteConfirmId === session.id ? (
|
|
||||||
<div className="delete-confirm-inline">
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={() =>
|
|
||||||
void handleDelete(
|
|
||||||
session.id,
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => setDeleteConfirmId(null)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button small danger-text"
|
|
||||||
onClick={() => setDeleteConfirmId(session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<SessionList
|
||||||
|
sessions={sessions}
|
||||||
|
onOpen={handleOpen}
|
||||||
|
onStop={handleStop}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onRecreateTunnel={handleRecreateTunnel}
|
||||||
|
actionBusyId={loadingSessionId}
|
||||||
|
tunnelHealth={tunnelHealth}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Sessions */}
|
|
||||||
{recentSessions.length > 0 && (
|
|
||||||
<div className="recent-sessions-section">
|
|
||||||
<h2>Recent Sessions</h2>
|
|
||||||
<div className="recent-sessions-list">
|
|
||||||
{recentSessions.map((session) => (
|
|
||||||
<div className="recent-session-item" key={session.id}>
|
|
||||||
<div className="recent-session-info">
|
|
||||||
<span className="recent-session-name">{session.display_name}</span>
|
|
||||||
<span className="muted">
|
|
||||||
{session.tool_type_name} · {session.project_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="recent-session-actions">
|
|
||||||
{session.url ? (
|
|
||||||
<a
|
|
||||||
href={session.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="ghost-button small"
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => handleOpen(session)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{deleteConfirmId === session.id ? (
|
|
||||||
<div className="delete-confirm-inline">
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={() =>
|
|
||||||
void handleDelete(
|
|
||||||
session.id,
|
|
||||||
session.project_id,
|
|
||||||
session.repository_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => setDeleteConfirmId(null)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button small danger-text"
|
|
||||||
onClick={() => setDeleteConfirmId(session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Session */}
|
{/* Create Session */}
|
||||||
<div className="create-session-section">
|
<div className="create-session-section">
|
||||||
<h2>Create New Session</h2>
|
<h2>Create New Session</h2>
|
||||||
<form onSubmit={handleCreate} className="card stack create-session-form">
|
<CreateSessionForm
|
||||||
<div className="form-row">
|
projects={projects}
|
||||||
<label className="form-field">
|
repositories={repositories}
|
||||||
Project
|
toolTypes={toolTypes}
|
||||||
<select
|
onProjectChange={(projectId) => {
|
||||||
value={selectedProject}
|
setSelectedProject(projectId);
|
||||||
onChange={(e) => {
|
}}
|
||||||
setSelectedProject(e.target.value);
|
onSuccess={handleCreateSuccess}
|
||||||
setSelectedRepo("");
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Select project...</option>
|
|
||||||
{projects.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="form-field">
|
|
||||||
Repository
|
|
||||||
<select
|
|
||||||
value={selectedRepo}
|
|
||||||
onChange={(e) => setSelectedRepo(e.target.value)}
|
|
||||||
disabled={!selectedProject}
|
|
||||||
>
|
|
||||||
<option value="">Select repository...</option>
|
|
||||||
{repositories.map((r) => (
|
|
||||||
<option key={r.id} value={r.id}>
|
|
||||||
{r.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="form-field">
|
|
||||||
Tool Type
|
|
||||||
<select
|
|
||||||
value={selectedToolType}
|
|
||||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Select tool...</option>
|
|
||||||
{toolTypes.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{t.display_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="form-field">
|
|
||||||
Display Name (optional)
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
placeholder="My Development Environment"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{createError && <p className="error-text">{createError}</p>}
|
|
||||||
|
|
||||||
<div className="form-actions">
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
type="submit"
|
|
||||||
disabled={createStatus === "creating"}
|
|
||||||
>
|
|
||||||
{createStatus === "creating" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Creating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Create Session
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dirty Delete Confirmation Modal */}
|
||||||
|
{dirtyDeleteSession && (
|
||||||
|
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Uncommitted Changes</h3>
|
||||||
|
<p>
|
||||||
|
The repository <strong>{dirtyDeleteSession.repository_name}</strong> has
|
||||||
|
uncommitted changes. Deleting this session will permanently lose these
|
||||||
|
changes.
|
||||||
|
</p>
|
||||||
|
<div className="changed-files-list">
|
||||||
|
<h4>Changed files:</h4>
|
||||||
|
<ul>
|
||||||
|
{dirtyDeleteFiles.map((file, idx) => (
|
||||||
|
<li key={idx}>{file}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => setDirtyDeleteSession(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="danger-button"
|
||||||
|
onClick={() => void handleForceDelete(dirtyDeleteSession)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Force Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ type SettingsStatus = "loading" | "ready" | "error";
|
|||||||
const TABS = [
|
const TABS = [
|
||||||
{ label: "General", path: "general" },
|
{ label: "General", path: "general" },
|
||||||
{ label: "SSH Keys", path: "ssh-keys" },
|
{ label: "SSH Keys", path: "ssh-keys" },
|
||||||
{ label: "Tool Types", path: "tool-types" },
|
|
||||||
{ label: "Tool Configs", path: "tool-configs" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
@@ -106,7 +104,7 @@ export const SettingsPage = () => {
|
|||||||
<p className="eyebrow">Configuration</p>
|
<p className="eyebrow">Configuration</p>
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
<p className="muted">General preferences and SSH keys.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="settings-tabs" aria-label="Settings sections">
|
<nav className="settings-tabs" aria-label="Settings sections">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
export const SSHKeysPage = () => {
|
export const SSHKeysPage = () => {
|
||||||
@@ -10,6 +10,13 @@ export const SSHKeysPage = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [newKeyName, setNewKeyName] = useState("");
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||||
|
const [signatures, setSignatures] = useState<Record<string, string>>({});
|
||||||
|
const [signing, setSigning] = useState<Record<string, boolean>>({});
|
||||||
|
const [verifyPayloads, setVerifyPayloads] = useState<Record<string, string>>({});
|
||||||
|
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
||||||
|
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
||||||
|
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadKeys();
|
loadKeys();
|
||||||
@@ -59,6 +66,42 @@ export const SSHKeysPage = () => {
|
|||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSign(keyId: string) {
|
||||||
|
const payload = signPayloads[keyId];
|
||||||
|
if (!payload?.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
||||||
|
const result = await signPayload(keyId, { payload: payload.trim() });
|
||||||
|
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to sign payload");
|
||||||
|
} finally {
|
||||||
|
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerify(keyId: string) {
|
||||||
|
const payload = verifyPayloads[keyId];
|
||||||
|
const signature = verifySignatures[keyId];
|
||||||
|
if (!payload?.trim() || !signature?.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setVerifying((prev) => ({ ...prev, [keyId]: true }));
|
||||||
|
const result = await verifySignature(keyId, {
|
||||||
|
payload: payload.trim(),
|
||||||
|
signature: signature.trim(),
|
||||||
|
});
|
||||||
|
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to verify signature");
|
||||||
|
} finally {
|
||||||
|
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
if (loading) return <div>Loading...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,6 +176,110 @@ export const SSHKeysPage = () => {
|
|||||||
Copy Full Key
|
Copy Full Key
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="key-signing">
|
||||||
|
<h4>Sign Payload</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={signPayloads[key.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSignPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter payload to sign..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSign(key.id)}
|
||||||
|
disabled={signing[key.id] || !signPayloads[key.id]?.trim()}
|
||||||
|
className="primary-button"
|
||||||
|
>
|
||||||
|
{signing[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Signing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Sign
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{signatures[key.id] && (
|
||||||
|
<div className="signature-result">
|
||||||
|
<label>Signature (base64):</label>
|
||||||
|
<code>{signatures[key.id]}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(signatures[key.id])}
|
||||||
|
className="secondary-button"
|
||||||
|
>
|
||||||
|
<Icon name="copy" size="sm" />
|
||||||
|
Copy Signature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="key-verification">
|
||||||
|
<h4>Verify Signature</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={verifyPayloads[key.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVerifyPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter payload..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={verifySignatures[key.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVerifySignatures((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter base64 signature..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleVerify(key.id)}
|
||||||
|
disabled={
|
||||||
|
verifying[key.id] ||
|
||||||
|
!verifyPayloads[key.id]?.trim() ||
|
||||||
|
!verifySignatures[key.id]?.trim()
|
||||||
|
}
|
||||||
|
className="primary-button"
|
||||||
|
>
|
||||||
|
{verifying[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="success" size="sm" />
|
||||||
|
Verify
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{verifyResults[key.id] !== undefined && verifyResults[key.id] !== null && (
|
||||||
|
<div className={`verify-result ${verifyResults[key.id] ? "valid" : "invalid"}`}>
|
||||||
|
{verifyResults[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="success" size="sm" />
|
||||||
|
Signature is valid
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="error" size="sm" />
|
||||||
|
Signature is invalid
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { TerminalComponent } from "../components/terminal";
|
import { TerminalComponent } from "../components/terminal";
|
||||||
import { Icon } from "../components/icon";
|
import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
|
||||||
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
|
||||||
export const TerminalPage: React.FC = () => {
|
export const TerminalPage: React.FC = () => {
|
||||||
const { instanceId } = useParams<{ instanceId: string }>();
|
const { instanceId } = useParams<{ instanceId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useMobileViewport();
|
||||||
|
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
return (
|
return (
|
||||||
@@ -16,6 +18,16 @@ export const TerminalPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<MobileTerminalWrapper
|
||||||
|
instanceId={instanceId}
|
||||||
|
onBack={() => navigate(-1)}
|
||||||
|
onClose={() => navigate(-1)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="terminal-page">
|
<section className="terminal-page">
|
||||||
<div className="terminal-page-header">
|
<div className="terminal-page-header">
|
||||||
@@ -24,7 +36,6 @@ export const TerminalPage: React.FC = () => {
|
|||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="arrow-left" size="sm" />
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<h1>Terminal</h1>
|
<h1>Terminal</h1>
|
||||||
@@ -32,6 +43,7 @@ export const TerminalPage: React.FC = () => {
|
|||||||
<TerminalComponent
|
<TerminalComponent
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
onClose={() => navigate(-1)}
|
onClose={() => navigate(-1)}
|
||||||
|
isMobile={false}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
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,380 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createToolType,
|
|
||||||
deleteToolType,
|
|
||||||
listToolTypes,
|
|
||||||
updateToolType,
|
|
||||||
type CreateToolTypeRequest,
|
|
||||||
type UpdateToolTypeRequest,
|
|
||||||
} from "../api/tool_types";
|
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
import type { ToolType } from "../api/tool_types";
|
|
||||||
|
|
||||||
type ToolTypesStatus = "loading" | "ready" | "error";
|
|
||||||
type DialogMode = "none" | "create" | "edit";
|
|
||||||
|
|
||||||
export const ToolTypesPage = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
|
||||||
const [editingToolType, setEditingToolType] = useState<ToolType | null>(null);
|
|
||||||
const [formName, setFormName] = useState("");
|
|
||||||
const [formDisplayName, setFormDisplayName] = useState("");
|
|
||||||
const [formDescription, setFormDescription] = useState("");
|
|
||||||
const [formCategory, setFormCategory] = useState("");
|
|
||||||
const [formInterfaces, setFormInterfaces] = useState<string[]>([]);
|
|
||||||
const [formPort, setFormPort] = useState("");
|
|
||||||
const [formTemplate, setFormTemplate] = useState("");
|
|
||||||
const [formVariables, setFormVariables] = useState("");
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadToolTypes = useCallback(async () => {
|
|
||||||
setStatus("loading");
|
|
||||||
try {
|
|
||||||
const data = await listToolTypes();
|
|
||||||
setToolTypes(data);
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setToolTypes([]);
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadToolTypes();
|
|
||||||
}, [loadToolTypes]);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setFormName("");
|
|
||||||
setFormDisplayName("");
|
|
||||||
setFormDescription("");
|
|
||||||
setFormCategory("");
|
|
||||||
setFormInterfaces([]);
|
|
||||||
setFormPort("");
|
|
||||||
setFormTemplate("");
|
|
||||||
setFormVariables("");
|
|
||||||
setFormError(null);
|
|
||||||
setEditingToolType(null);
|
|
||||||
setDialogMode("create");
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (toolType: ToolType) => {
|
|
||||||
setFormName(toolType.name);
|
|
||||||
setFormDisplayName(toolType.display_name);
|
|
||||||
setFormDescription(toolType.description ?? "");
|
|
||||||
setFormCategory(toolType.category ?? "");
|
|
||||||
setFormInterfaces(toolType.interfaces ?? []);
|
|
||||||
setFormPort(toolType.default_port?.toString() ?? "");
|
|
||||||
setFormTemplate(toolType.compose_template ?? "");
|
|
||||||
setFormVariables(toolType.required_variables.join(", "));
|
|
||||||
setFormError(null);
|
|
||||||
setEditingToolType(toolType);
|
|
||||||
setDialogMode("edit");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
setDialogMode("none");
|
|
||||||
setEditingToolType(null);
|
|
||||||
setFormError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
|
|
||||||
setFormError("Name, display name, and compose template are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formPort.trim() || isNaN(Number(formPort))) {
|
|
||||||
setFormError("Default port is required and must be a number");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const variables = formVariables
|
|
||||||
.split(",")
|
|
||||||
.map((v) => v.trim())
|
|
||||||
.filter((v) => v.length > 0);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (dialogMode === "create") {
|
|
||||||
const input: CreateToolTypeRequest = {
|
|
||||||
name: formName.trim(),
|
|
||||||
display_name: formDisplayName.trim(),
|
|
||||||
description: formDescription.trim() || undefined,
|
|
||||||
category: formCategory.trim() || undefined,
|
|
||||||
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
|
||||||
default_port: Number(formPort),
|
|
||||||
compose_template: formTemplate.trim(),
|
|
||||||
required_variables: variables,
|
|
||||||
};
|
|
||||||
await createToolType(input);
|
|
||||||
} else if (dialogMode === "edit" && editingToolType) {
|
|
||||||
const input: UpdateToolTypeRequest = {
|
|
||||||
display_name: formDisplayName.trim(),
|
|
||||||
description: formDescription.trim() || undefined,
|
|
||||||
category: formCategory.trim() || undefined,
|
|
||||||
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
|
||||||
default_port: Number(formPort),
|
|
||||||
compose_template: formTemplate.trim(),
|
|
||||||
required_variables: variables,
|
|
||||||
};
|
|
||||||
await updateToolType(editingToolType.id, input);
|
|
||||||
}
|
|
||||||
closeDialog();
|
|
||||||
await loadToolTypes();
|
|
||||||
} catch (err) {
|
|
||||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
|
||||||
const detail = axiosError?.response?.data?.detail || "Failed to save tool type";
|
|
||||||
setFormError(detail);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await deleteToolType(id);
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
await loadToolTypes();
|
|
||||||
} catch {
|
|
||||||
alert("Failed to delete tool type");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<p>Loading tool types...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "error") {
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<p className="text-error">Failed to load tool types.</p>
|
|
||||||
<button onClick={loadToolTypes}>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
<div className="page-header" style={{ marginBottom: "1rem" }}>
|
|
||||||
<div>
|
|
||||||
<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" />
|
|
||||||
Create Tool Type
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{toolTypes.length === 0 ? (
|
|
||||||
<p>No tool types found.</p>
|
|
||||||
) : (
|
|
||||||
<div className="card-grid">
|
|
||||||
{toolTypes.map((toolType) => (
|
|
||||||
<div key={toolType.id} className="card">
|
|
||||||
<div className="card-header">
|
|
||||||
<h3>{toolType.display_name}</h3>
|
|
||||||
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
|
||||||
</div>
|
|
||||||
<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">
|
|
||||||
{!toolType.is_builtin && (
|
|
||||||
<>
|
|
||||||
<button onClick={() => openEdit(toolType)} className="button-secondary">
|
|
||||||
<Icon name="edit" size="sm" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirmId(toolType.id)}
|
|
||||||
className="button-danger"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{deleteConfirmId === toolType.id && (
|
|
||||||
<div className="dialog-overlay">
|
|
||||||
<div className="dialog">
|
|
||||||
<p>Delete tool type "{toolType.display_name}"?</p>
|
|
||||||
<div className="dialog-actions">
|
|
||||||
<button onClick={() => handleDelete(toolType.id)} className="button-danger">
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setDeleteConfirmId(null)}>
|
|
||||||
<Icon name="cancel" size="sm" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dialogMode !== "none" && (
|
|
||||||
<div className="dialog-overlay">
|
|
||||||
<div className="dialog">
|
|
||||||
<h2>{dialogMode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Name (unique identifier)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formName}
|
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
|
||||||
disabled={dialogMode === "edit"}
|
|
||||||
placeholder="e.g., code-server"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Display Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formDisplayName}
|
|
||||||
onChange={(e) => setFormDisplayName(e.target.value)}
|
|
||||||
placeholder="e.g., VS Code Server"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Description</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formDescription}
|
|
||||||
onChange={(e) => setFormDescription(e.target.value)}
|
|
||||||
placeholder="Optional description"
|
|
||||||
/>
|
|
||||||
</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">
|
|
||||||
<label>Compose Template (YAML)</label>
|
|
||||||
<textarea
|
|
||||||
value={formTemplate}
|
|
||||||
onChange={(e) => setFormTemplate(e.target.value)}
|
|
||||||
rows={10}
|
|
||||||
placeholder="version: '3.8' services: app: image: ..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Required Variables (comma-separated)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formVariables}
|
|
||||||
onChange={(e) => setFormVariables(e.target.value)}
|
|
||||||
placeholder="REPO_PATH, TOOL_NAME"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formError && <p className="text-error">{formError}</p>}
|
|
||||||
|
|
||||||
<div className="dialog-actions">
|
|
||||||
<button type="submit">
|
|
||||||
{dialogMode === "create" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Create
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="save" size="sm" />
|
|
||||||
Update
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={closeDialog} className="button-secondary">
|
|
||||||
<Icon name="cancel" size="sm" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -13,7 +13,8 @@ const mockToolTypes = [
|
|||||||
display_name: "VS Code Server",
|
display_name: "VS Code Server",
|
||||||
description: "VS Code in browser",
|
description: "VS Code in browser",
|
||||||
category: "editor",
|
category: "editor",
|
||||||
interfaces: ["web"],
|
interface_type: "web",
|
||||||
|
requires_port: true,
|
||||||
default_port: 8443,
|
default_port: 8443,
|
||||||
definition_type: "compose",
|
definition_type: "compose",
|
||||||
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
|
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
|
||||||
@@ -32,7 +33,8 @@ const mockToolTypes = [
|
|||||||
display_name: "Custom Tool",
|
display_name: "Custom Tool",
|
||||||
description: "My custom tool",
|
description: "My custom tool",
|
||||||
category: "utility",
|
category: "utility",
|
||||||
interfaces: ["terminal"],
|
interface_type: "terminal",
|
||||||
|
requires_port: false,
|
||||||
default_port: 8080,
|
default_port: 8080,
|
||||||
definition_type: "dockerfile",
|
definition_type: "dockerfile",
|
||||||
compose_template: null,
|
compose_template: null,
|
||||||
@@ -153,164 +155,10 @@ describe("ToolWorkshopPage", () => {
|
|||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
fireEvent.click(screen.getByText("VS Code Server"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /configs/i })).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 }));
|
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||||
@@ -321,8 +169,8 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||||
|
|
||||||
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("e.g., OPENAI_API_KEY")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
|
expect(screen.getByPlaceholderText(/Enter value/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates config with advanced fields", async () => {
|
it("creates config with advanced fields", async () => {
|
||||||
@@ -337,6 +185,12 @@ describe("ToolWorkshopPage", () => {
|
|||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("VS Code Server"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -345,16 +199,16 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/key/i), {
|
fireEvent.change(screen.getByPlaceholderText("e.g., OPENAI_API_KEY"), {
|
||||||
target: { value: "MY_CONFIG" },
|
target: { value: "MY_CONFIG" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/value/i), {
|
fireEvent.change(screen.getByPlaceholderText(/Enter value/i), {
|
||||||
target: { value: "my-value" },
|
target: { value: "my-value" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/port override/i), {
|
fireEvent.change(screen.getByPlaceholderText("e.g., 8080"), {
|
||||||
target: { value: "9090" },
|
target: { value: "9090" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/start command/i), {
|
fireEvent.change(screen.getByPlaceholderText("e.g., npm start"), {
|
||||||
target: { value: "python app.py" },
|
target: { value: "python app.py" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -384,6 +238,12 @@ describe("ToolWorkshopPage", () => {
|
|||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("VS Code Server"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -392,8 +252,8 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||||
|
|
||||||
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("e.g., my-dotfiles")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("e.g., /home/user")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates config folder successfully", async () => {
|
it("creates config folder successfully", async () => {
|
||||||
@@ -408,6 +268,12 @@ describe("ToolWorkshopPage", () => {
|
|||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("VS Code Server"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -416,10 +282,10 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
fireEvent.change(screen.getByPlaceholderText("e.g., my-dotfiles"), {
|
||||||
target: { value: "new-folder" },
|
target: { value: "new-folder" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText("Mount Path *"), {
|
fireEvent.change(screen.getByPlaceholderText("e.g., /home/user"), {
|
||||||
target: { value: "/home/dev" },
|
target: { value: "/home/dev" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -447,6 +313,12 @@ describe("ToolWorkshopPage", () => {
|
|||||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("VS Code Server"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,17 +14,13 @@ import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
|||||||
import { TerminalPage } from "./pages/terminal";
|
import { TerminalPage } from "./pages/terminal";
|
||||||
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||||
import { ToolConfigsPage } from "./pages/tool-configs";
|
import { SessionsPage } from "./pages/sessions";
|
||||||
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="/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={
|
||||||
@@ -44,10 +40,9 @@ export const AppRouter = () => {
|
|||||||
<Route index element={<Navigate to="general" replace />} />
|
<Route index element={<Navigate to="general" replace />} />
|
||||||
<Route path="general" element={<GeneralSettingsTab />} />
|
<Route path="general" element={<GeneralSettingsTab />} />
|
||||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
<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 path="*" element={<Navigate to="general" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="sessions" element={<SessionsPage />} />
|
||||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
+726
-3
@@ -166,6 +166,9 @@ a {
|
|||||||
.shell-content {
|
.shell-content {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
@@ -2482,7 +2485,8 @@ a.nav-item,
|
|||||||
.terminal-page {
|
.terminal-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
@@ -2588,16 +2592,28 @@ a.nav-item,
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-container .xterm {
|
.terminal-container .xterm {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-container .xterm-viewport {
|
.terminal-container canvas {
|
||||||
background: #1e1e1e !important;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile terminal container - no padding to maximize space */
|
||||||
|
.terminal-wrapper.mobile .terminal-container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure xterm viewport fills container properly */
|
||||||
/* Responsive terminal */
|
/* Responsive terminal */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.terminal-page {
|
.terminal-page {
|
||||||
@@ -2652,9 +2668,15 @@ a.nav-item,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.active-sessions-section {
|
.active-sessions-section {
|
||||||
|
position: relative;
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active-sessions-section.dimmed {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.active-sessions-section h2 {
|
.active-sessions-section h2 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2713,10 +2735,175 @@ a.nav-item,
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Session Card - Unified Component */
|
||||||
|
.session-card-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-title h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-status-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-meta .icon {
|
||||||
|
margin-right: var(--space-1);
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-url {
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-url a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-url a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session List - Unified Component */
|
||||||
|
.session-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-group-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action labels - hidden on mobile */
|
||||||
|
.action-label {
|
||||||
|
margin-left: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.action-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-actions {
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-actions button,
|
||||||
|
.session-card-actions a {
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.recent-sessions-section {
|
.recent-sessions-section {
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading overlay for sessions */
|
||||||
|
.sessions-grid {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-grid.dimmed {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
background: rgba(255, 254, 249, 0.7);
|
||||||
|
border-radius: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-6);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--space-2);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content .icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.recent-sessions-list {
|
.recent-sessions-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2763,9 +2950,15 @@ a.nav-item,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.create-session-section {
|
.create-session-section {
|
||||||
|
position: relative;
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.create-session-section.dimmed {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.create-session-form {
|
.create-session-form {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
@@ -2776,6 +2969,65 @@ a.nav-item,
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Workflow Step Styles */
|
||||||
|
.workflow-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step.complete {
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step.active .workflow-step-header {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step-number {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-muted);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step.active .workflow-step-number {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step.complete .workflow-step-number {
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2805,3 +3057,474 @@ a.nav-item,
|
|||||||
background: var(--danger-light, #fee2e2);
|
background: var(--danger-light, #fee2e2);
|
||||||
color: var(--danger, #dc2626);
|
color: var(--danger, #dc2626);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Mobile Terminal Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.mobile-terminal-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"header"
|
||||||
|
"content"
|
||||||
|
"keys";
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh; /* Dynamic viewport height for mobile */
|
||||||
|
background: #1e1e1e;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Terminal Header */
|
||||||
|
.mobile-terminal-header {
|
||||||
|
grid-area: header;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-bottom: 1px solid #3e3e3e;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease, height 0.3s ease, padding 0.3s ease, margin 0.3s ease;
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header.hidden {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-width: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header.visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-left,
|
||||||
|
.mobile-terminal-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #d4d4d4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #3e3e3e;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-button:hover {
|
||||||
|
background: #3e3e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-status.connecting {
|
||||||
|
background: #f5f543;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-status.connected {
|
||||||
|
background: #0dbc79;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-header-status.disconnected,
|
||||||
|
.mobile-terminal-header-status.error {
|
||||||
|
background: #cd3131;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Terminal Content */
|
||||||
|
.mobile-terminal-content {
|
||||||
|
grid-area: content;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal wrapper - fills content area */
|
||||||
|
.terminal-wrapper.mobile {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-wrapper.mobile .terminal-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* xterm fills container */
|
||||||
|
.terminal-wrapper.mobile .terminal-container .xterm {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-wrapper.mobile .terminal-container .xterm-viewport {
|
||||||
|
flex: 1;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Special Keys Strip */
|
||||||
|
.special-keys-strip {
|
||||||
|
grid-area: keys;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-top: 1px solid #3e3e3e;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-strip.hidden {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-strip.visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-strip::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-key-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
background: #3e3e3e;
|
||||||
|
border: 1px solid #4e4e4e;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-key-button:active {
|
||||||
|
background: #4e4e4e;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-key-more {
|
||||||
|
background: #2472c8;
|
||||||
|
border-color: #2472c8;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-key-more:active {
|
||||||
|
background: #1e5fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-key-button.active-modifier {
|
||||||
|
background: #f5f543;
|
||||||
|
color: #1e1e1e;
|
||||||
|
border-color: #f5f543;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 0 8px rgba(245, 245, 67, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Special Keys Panel */
|
||||||
|
.special-keys-panel-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-panel {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-top: 1px solid #3e3e3e;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
padding: var(--space-4);
|
||||||
|
width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slideUp 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-panel-section {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-panel-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.special-keys-panel-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #3e3e3e;
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal Component Updates */
|
||||||
|
/* Note: .terminal-wrapper.mobile is defined above with position: absolute to fill grid cell */
|
||||||
|
|
||||||
|
.terminal-wrapper.mobile .terminal-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header-button {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header-button:hover {
|
||||||
|
background: #3e3e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-reconnect {
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: #2472c8;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-reset-confirm {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-reset-confirm-content {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: var(--space-4);
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-reset-confirm-content p {
|
||||||
|
margin: 0 0 var(--space-4) 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-reset-confirm-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-reset-confirm-button {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-reset-confirm-button.cancel {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-reset-confirm-button.confirm {
|
||||||
|
background: #cd3131;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-hidden-input {
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable zoom on mobile terminal */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-terminal-wrapper {
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal-wrapper * {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
touch-action: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Menu Overlay */
|
||||||
|
.mobile-menu-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-close {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
right: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AppShell mobile menu */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.shell-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 260px;
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
padding-top: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav.mobile-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ 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
|
- /data/instances:/data/instances
|
||||||
- avatar_uploads:/app/uploads
|
- avatar_uploads:/app/uploads
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -117,7 +117,6 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
repo_data:
|
repo_data:
|
||||||
instance_data:
|
|
||||||
avatar_uploads:
|
avatar_uploads:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+1
-2
@@ -58,7 +58,7 @@ services:
|
|||||||
INSTANCE_BASE_PATH: /data/instances
|
INSTANCE_BASE_PATH: /data/instances
|
||||||
volumes:
|
volumes:
|
||||||
- repo_data:/data/repos
|
- repo_data:/data/repos
|
||||||
- instance_data:/data/instances
|
- /data/instances:/data/instances
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -92,7 +92,6 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
repo_data:
|
repo_data:
|
||||||
instance_data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Terminal API
|
||||||
|
|
||||||
|
The Terminal API provides WebSocket-based terminal access to running tool instances.
|
||||||
|
|
||||||
|
## WebSocket Endpoint
|
||||||
|
|
||||||
|
### Connect to Terminal
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /ws/tool-instances/{instance_id}/terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Establishes a WebSocket connection to an interactive terminal session inside a running tool instance container.
|
||||||
|
|
||||||
|
**Authentication:** Requires valid session cookie.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `instance_id` (string, UUID): The tool instance ID
|
||||||
|
|
||||||
|
**Connection Flow:**
|
||||||
|
1. Client connects to WebSocket endpoint
|
||||||
|
2. Server authenticates user and verifies instance ownership
|
||||||
|
3. Server creates or reattaches to existing terminal session
|
||||||
|
4. Server sends `{"type": "status", "status": "connected"}` message
|
||||||
|
5. Bidirectional communication begins
|
||||||
|
|
||||||
|
**Message Types:**
|
||||||
|
|
||||||
|
#### Client to Server
|
||||||
|
|
||||||
|
**Terminal Input (bytes or string)**
|
||||||
|
- Send raw bytes for terminal input (key presses)
|
||||||
|
- Send text for terminal input (will be encoded as UTF-8)
|
||||||
|
|
||||||
|
**Resize Command (JSON)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "resize",
|
||||||
|
"cols": 80,
|
||||||
|
"rows": 24
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reset Command (JSON)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "reset"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Kills the current terminal session and starts a fresh one.
|
||||||
|
|
||||||
|
**Pong Response (JSON)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "pong"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Sent automatically in response to server ping messages.
|
||||||
|
|
||||||
|
#### Server to Client
|
||||||
|
|
||||||
|
**Terminal Output (bytes)**
|
||||||
|
Raw terminal output as binary data (Blob in browser).
|
||||||
|
|
||||||
|
**Status Messages (JSON)**
|
||||||
|
```json
|
||||||
|
{"type": "status", "status": "connected"}
|
||||||
|
{"type": "status", "status": "resetting"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ping Messages (JSON)**
|
||||||
|
```json
|
||||||
|
{"type": "ping"}
|
||||||
|
```
|
||||||
|
Sent every 30 seconds to detect disconnections. Client should respond with `{"type": "pong"}`.
|
||||||
|
|
||||||
|
### Session Persistence
|
||||||
|
|
||||||
|
Terminal sessions persist across WebSocket disconnections:
|
||||||
|
- When a client disconnects, the terminal session remains active
|
||||||
|
- On reconnection, the client reattaches to the existing session
|
||||||
|
- Buffered output is replayed to the client on reconnection
|
||||||
|
- Sessions are cleaned up after 30 minutes of inactivity
|
||||||
|
|
||||||
|
### Concurrent Connections
|
||||||
|
|
||||||
|
Only one WebSocket connection is allowed per terminal session:
|
||||||
|
- New connections close existing connections with code 4000
|
||||||
|
- Previous client receives "New connection established" reason
|
||||||
|
|
||||||
|
## HTTP Endpoints
|
||||||
|
|
||||||
|
### Reset Terminal Session
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset
|
||||||
|
```
|
||||||
|
|
||||||
|
Resets the terminal session for a tool instance, killing the current shell and starting fresh.
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `project_id` (string, UUID): Project ID
|
||||||
|
- `repo_id` (string, UUID): Repository ID
|
||||||
|
- `instance_id` (string, UUID): Instance ID
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Terminal session reset successfully",
|
||||||
|
"instance_id": "...",
|
||||||
|
"session_id": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `404 Not Found`: Instance not found
|
||||||
|
- `400 Bad Request`: Instance is not running
|
||||||
|
- `500 Internal Server Error`: Failed to reset terminal session
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
WebSocket close codes:
|
||||||
|
- `1000`: Normal closure
|
||||||
|
- `4000`: Error/reset
|
||||||
|
- `4001`: Invalid instance ID
|
||||||
|
- `4003`: Unauthorized/Forbidden
|
||||||
|
- `4004`: Instance not found or not running
|
||||||
|
|
||||||
|
## Heartbeat
|
||||||
|
|
||||||
|
The server sends ping messages every 30 seconds. If no ping is received for 60 seconds, the client should assume the connection is dead and reconnect.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Terminal Troubleshooting Guide
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Cannot Connect to Terminal
|
||||||
|
|
||||||
|
**Symptom:** Terminal shows "Connection closed" or "Error" status immediately.
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Verify the tool instance is running:
|
||||||
|
- Check instance status in the UI
|
||||||
|
- Start the instance if it's stopped
|
||||||
|
|
||||||
|
2. Check browser console for errors:
|
||||||
|
- Open browser DevTools (F12)
|
||||||
|
- Look for WebSocket connection errors
|
||||||
|
- Check for CORS or authentication errors
|
||||||
|
|
||||||
|
3. Verify network connectivity:
|
||||||
|
- Ensure you can reach the API server
|
||||||
|
- Check if WebSocket connections are blocked by firewall/proxy
|
||||||
|
- Try accessing from a different network
|
||||||
|
|
||||||
|
**If the issue persists:**
|
||||||
|
- Reset the terminal session
|
||||||
|
- Refresh the page
|
||||||
|
- Check server logs for errors
|
||||||
|
|
||||||
|
### Terminal Freezes or Becomes Unresponsive
|
||||||
|
|
||||||
|
**Symptom:** Terminal accepts no input or stops updating.
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Reset the terminal:**
|
||||||
|
- Click the Reset button in the terminal header
|
||||||
|
- Confirm the reset action
|
||||||
|
- Wait for the new session to start
|
||||||
|
|
||||||
|
2. **Check for stuck processes:**
|
||||||
|
- Try Ctrl+C to interrupt any running process
|
||||||
|
- If that doesn't work, reset the terminal
|
||||||
|
|
||||||
|
3. **Browser issues:**
|
||||||
|
- Close and reopen the browser tab
|
||||||
|
- Clear browser cache and cookies
|
||||||
|
- Try a different browser
|
||||||
|
|
||||||
|
### Output Not Showing
|
||||||
|
|
||||||
|
**Symptom:** Commands execute but no output appears.
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Check terminal focus:
|
||||||
|
- Click inside the terminal area
|
||||||
|
- Look for the cursor indicator
|
||||||
|
|
||||||
|
2. Resize the terminal:
|
||||||
|
- The terminal may need a resize event to render properly
|
||||||
|
- Try resizing the browser window slightly
|
||||||
|
|
||||||
|
3. Reset the terminal session
|
||||||
|
|
||||||
|
### Reconnection Loop
|
||||||
|
|
||||||
|
**Symptom:** Terminal keeps disconnecting and reconnecting repeatedly.
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Check instance health:
|
||||||
|
- The instance may be unhealthy or restarting
|
||||||
|
- Check instance logs for errors
|
||||||
|
|
||||||
|
2. Network stability:
|
||||||
|
- Unstable network causes repeated disconnections
|
||||||
|
- Try on a more stable connection
|
||||||
|
|
||||||
|
3. Multiple tabs:
|
||||||
|
- Only one tab can connect to a terminal session
|
||||||
|
- Close other tabs with the same terminal open
|
||||||
|
|
||||||
|
## Diagnostic Steps
|
||||||
|
|
||||||
|
### Check WebSocket Connection
|
||||||
|
|
||||||
|
1. Open browser DevTools (F12)
|
||||||
|
2. Go to Network tab
|
||||||
|
3. Filter by "WS" (WebSocket)
|
||||||
|
4. Look for the terminal WebSocket connection
|
||||||
|
5. Check:
|
||||||
|
- Connection status (should be 101 Switching Protocols)
|
||||||
|
- Messages tab for ping/pong traffic
|
||||||
|
- Any error messages in the connection
|
||||||
|
|
||||||
|
### Verify Terminal Session
|
||||||
|
|
||||||
|
To check if a terminal session exists on the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check server logs for session activity
|
||||||
|
docker logs hq-api | grep -i "terminal"
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
- "Terminal session ready" - session created successfully
|
||||||
|
- "Reattaching to existing terminal session" - reconnecting to existing session
|
||||||
|
- "Cleaning up idle terminal session" - session expired
|
||||||
|
|
||||||
|
### Test Basic Connectivity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test if the WebSocket endpoint is reachable
|
||||||
|
curl -i -N \
|
||||||
|
-H "Connection: Upgrade" \
|
||||||
|
-H "Upgrade: websocket" \
|
||||||
|
-H "Sec-WebSocket-Key: test" \
|
||||||
|
-H "Sec-WebSocket-Version: 13" \
|
||||||
|
https://your-api-domain/ws/tool-instances/test/terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: HTTP 400 (invalid instance ID) or redirect to auth
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
### WebSocket Close Codes
|
||||||
|
|
||||||
|
- **1000**: Normal closure - connection closed cleanly
|
||||||
|
- **4000**: Generic error - check server logs
|
||||||
|
- **4001**: Invalid instance ID - verify the instance exists
|
||||||
|
- **4003**: Unauthorized - session expired or invalid
|
||||||
|
- **4004**: Instance not running - start the instance first
|
||||||
|
|
||||||
|
### HTTP Status Codes
|
||||||
|
|
||||||
|
- **404**: Instance not found - verify instance ID
|
||||||
|
- **400**: Instance not running - start the instance
|
||||||
|
- **500**: Server error - check server logs
|
||||||
|
|
||||||
|
## Resetting Everything
|
||||||
|
|
||||||
|
If all else fails:
|
||||||
|
|
||||||
|
1. **Reset terminal session:**
|
||||||
|
```
|
||||||
|
POST /api/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Restart the tool instance:**
|
||||||
|
- Stop the instance
|
||||||
|
- Start the instance again
|
||||||
|
- Reconnect to the terminal
|
||||||
|
|
||||||
|
3. **Clear browser data:**
|
||||||
|
- Clear cookies for the domain
|
||||||
|
- Clear local storage
|
||||||
|
- Hard refresh the page (Ctrl+F5)
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
If issues persist:
|
||||||
|
|
||||||
|
1. Collect diagnostic information:
|
||||||
|
- Browser console logs
|
||||||
|
- Network tab WebSocket messages
|
||||||
|
- Server logs (`docker logs hq-api`)
|
||||||
|
- Instance status and health
|
||||||
|
|
||||||
|
2. Check the [Terminal API documentation](/docs/api/terminal.md) for protocol details
|
||||||
|
|
||||||
|
3. Report issues with:
|
||||||
|
- Steps to reproduce
|
||||||
|
- Expected vs actual behavior
|
||||||
|
- Browser and OS version
|
||||||
|
- Instance type and configuration
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Terminal Sessions
|
||||||
|
|
||||||
|
Terminal sessions provide interactive shell access to your running tool instances directly in the browser.
|
||||||
|
|
||||||
|
## Persistent Sessions
|
||||||
|
|
||||||
|
Terminal sessions are **persistent** - they survive browser refreshes, network interruptions, and tab switches.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
- When you open a terminal, a shell session starts inside the tool instance container
|
||||||
|
- If you close the browser or lose connection, the session keeps running
|
||||||
|
- When you reconnect, you reattach to the same session with all previous output preserved
|
||||||
|
- Sessions automatically clean up after 30 minutes of inactivity
|
||||||
|
|
||||||
|
### Reconnecting
|
||||||
|
|
||||||
|
If your connection drops:
|
||||||
|
1. The terminal shows "Reconnecting..." status
|
||||||
|
2. The client automatically attempts to reconnect with exponential backoff
|
||||||
|
3. On successful reconnection, buffered output is replayed
|
||||||
|
4. You can continue working where you left off
|
||||||
|
|
||||||
|
## Resetting the Terminal
|
||||||
|
|
||||||
|
If your terminal becomes unresponsive or you want a fresh start:
|
||||||
|
|
||||||
|
1. Click the **Reset** button in the terminal header
|
||||||
|
2. Confirm the reset action
|
||||||
|
3. The current shell is killed and a new one starts
|
||||||
|
4. All terminal history is cleared
|
||||||
|
|
||||||
|
**Note:** Resetting only affects the terminal session, not the tool instance itself. Any files you've created remain intact.
|
||||||
|
|
||||||
|
## Mobile Terminal
|
||||||
|
|
||||||
|
On mobile devices, the terminal includes:
|
||||||
|
- Special keys panel (Ctrl, Alt, Tab, arrows, etc.)
|
||||||
|
- Font size controls
|
||||||
|
- Auto-hiding header for maximum screen space
|
||||||
|
- Touch-friendly interface
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
Standard terminal shortcuts work as expected:
|
||||||
|
- `Ctrl+C`: Send interrupt signal
|
||||||
|
- `Ctrl+D`: Send EOF (close shell if empty)
|
||||||
|
- `Ctrl+L`: Clear screen
|
||||||
|
- `Ctrl+Z`: Suspend process
|
||||||
|
|
||||||
|
Special keys can be accessed via the special keys panel on mobile or by using modifier combinations.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Connection Issues
|
||||||
|
|
||||||
|
**"Connection closed" error:**
|
||||||
|
- The tool instance may have stopped - check the instance status
|
||||||
|
- Network issues - the client will auto-reconnect
|
||||||
|
- Session timeout - sessions expire after 30 minutes of inactivity
|
||||||
|
|
||||||
|
**Terminal not responding:**
|
||||||
|
- Try resetting the terminal using the Reset button
|
||||||
|
- Check if the tool instance is still running
|
||||||
|
- Refresh the page to force reconnection
|
||||||
|
|
||||||
|
### Display Issues
|
||||||
|
|
||||||
|
**Text not visible:**
|
||||||
|
- Adjust font size using +/- buttons
|
||||||
|
- Check if the terminal has focus (click inside it)
|
||||||
|
- Try resizing the browser window
|
||||||
|
|
||||||
|
**Characters not appearing:**
|
||||||
|
- Ensure the terminal has focus
|
||||||
|
- Check if a modifier key is stuck (Ctrl, Alt)
|
||||||
|
- Reset the terminal if stuck
|
||||||
|
|
||||||
|
## Session Limits
|
||||||
|
|
||||||
|
- **One connection per terminal:** Only one browser tab can connect to a terminal session at a time. Opening a new connection closes the old one.
|
||||||
|
- **30-minute idle timeout:** Sessions without activity are automatically cleaned up
|
||||||
|
- **Buffer size:** Up to 10KB of output is buffered for replay on reconnection
|
||||||
@@ -62,6 +62,17 @@ services:
|
|||||||
- `{{TOOL_NAME}}` - Unique name for the container
|
- `{{TOOL_NAME}}` - Unique name for the container
|
||||||
- `{{REPO_PATH}}` - Path to the repository
|
- `{{REPO_PATH}}` - Path to the repository
|
||||||
|
|
||||||
|
#### Git Requirement for Clone Mode
|
||||||
|
|
||||||
|
When users create instances in **clone mode** (fresh repository copy instead of bind mount), the container image must have `git` installed. This enables git operations (push, pull, branch) inside the container.
|
||||||
|
|
||||||
|
**Built-in types with git:**
|
||||||
|
- VS Code Server: Includes git
|
||||||
|
- Jupyter Notebook: Includes git
|
||||||
|
- OpenCode: Installs git during startup
|
||||||
|
|
||||||
|
**Custom tool types:** Ensure your base image includes git (e.g., `apt-get install -y git` in Dockerfile).
|
||||||
|
|
||||||
#### Validating Templates
|
#### Validating Templates
|
||||||
|
|
||||||
The system validates templates:
|
The system validates templates:
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
# Session Branch Selection Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace free-text branch input with a dropdown of available branches and add "Create new branch" functionality in the session creation form.
|
||||||
|
|
||||||
|
**Architecture:** Frontend fetches branches from existing API, displays them in a dropdown with a "Create new branch..." option. When creating a new branch, frontend sends both base branch and new branch name to backend. Backend clones the base branch then creates a local branch in the cloned workspace.
|
||||||
|
|
||||||
|
**Tech Stack:** React + TypeScript (frontend), FastAPI + Python (backend), Git via subprocess
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `apps/web/src/api/git_repositories.ts` — Add `listRepositoryBranches` API function
|
||||||
|
- `apps/web/src/pages/sessions.tsx` — Replace branch input with dropdown + new branch form
|
||||||
|
- `apps/api/src/api/tool_instances.py` — Extend `CreateInstanceRequest`, add local branch creation
|
||||||
|
- `apps/web/src/api/sessions.ts` — Update `createInstance` signature to accept `newBranch`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Branch Listing API to Frontend
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/src/api/git_repositories.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add Branch types and listRepositoryBranches function**
|
||||||
|
|
||||||
|
Add after the existing imports and before `export interface CommitHistoryEntry`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Branch {
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
last_commit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchesResponse {
|
||||||
|
branches: Branch[];
|
||||||
|
default_branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRepositoryBranches(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string
|
||||||
|
): Promise<BranchesResponse> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/src/api/git_repositories.ts
|
||||||
|
git commit -m "feat: add branch listing API function"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Update Backend Request Model and Clone Logic
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/api/src/api/tool_instances.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend CreateInstanceRequest with new_branch field**
|
||||||
|
|
||||||
|
Change the `CreateInstanceRequest` class (around line 55-64):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CreateInstanceRequest(BaseModel):
|
||||||
|
"""Request body for creating a tool instance."""
|
||||||
|
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||||
|
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
||||||
|
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
|
||||||
|
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')")
|
||||||
|
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add local branch creation after clone**
|
||||||
|
|
||||||
|
After the clone block (around line 248), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Create new local branch if requested
|
||||||
|
if data.clone_mode == "clone" and data.new_branch:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr)
|
||||||
|
raise RuntimeError(f"Failed to create branch: {result.stderr}")
|
||||||
|
logger.info("Created local branch %s in cloned repository", data.new_branch)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to create local branch: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create local branch: {exc}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update ToolInstance record to store new branch name**
|
||||||
|
|
||||||
|
Change the instance creation (around line 321):
|
||||||
|
|
||||||
|
```python
|
||||||
|
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/api/src/api/tool_instances.py
|
||||||
|
git commit -m "feat: support creating local branch during session creation
|
||||||
|
|
||||||
|
- Add new_branch field to CreateInstanceRequest
|
||||||
|
- Run git checkout -b after cloning when new_branch is provided
|
||||||
|
- Store new branch name in ToolInstance record"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Update Frontend API to Support newBranch
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/src/api/sessions.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update createInstance signature**
|
||||||
|
|
||||||
|
Change the function signature (around line 44):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function createInstance(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
toolTypeId: string,
|
||||||
|
displayName?: string,
|
||||||
|
cloneMode?: string,
|
||||||
|
branch?: string,
|
||||||
|
newBranch?: string
|
||||||
|
): Promise<ToolInstance> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
|
{
|
||||||
|
tool_type_id: toolTypeId,
|
||||||
|
display_name: displayName,
|
||||||
|
clone_mode: cloneMode || "mount",
|
||||||
|
branch: branch || undefined,
|
||||||
|
new_branch: newBranch || undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/src/api/sessions.ts
|
||||||
|
git commit -m "feat: add newBranch parameter to createInstance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update Session Creation UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/src/pages/sessions.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add new imports**
|
||||||
|
|
||||||
|
Add to existing imports:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { listRepositoryBranches, type Branch } from "../api/git_repositories";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add state variables**
|
||||||
|
|
||||||
|
Add after `const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);` (around line 44):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
|
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
|
||||||
|
const [newBranchName, setNewBranchName] = useState("");
|
||||||
|
const [baseBranch, setBaseBranch] = useState("");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add branch loading effect**
|
||||||
|
|
||||||
|
Add after the `loadSshKeys` effect (around line 117):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
const loadBranches = async () => {
|
||||||
|
if (!selectedRepo || !selectedProject || cloneMode !== "clone") {
|
||||||
|
setBranches([]);
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setNewBranchName("");
|
||||||
|
setBaseBranch("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoadingBranches(true);
|
||||||
|
try {
|
||||||
|
const data = await listRepositoryBranches(selectedProject, selectedRepo);
|
||||||
|
setBranches(data.branches);
|
||||||
|
const defaultBranch = data.default_branch;
|
||||||
|
setBaseBranch(defaultBranch);
|
||||||
|
if (!branch || !data.branches.find((b) => b.name === branch)) {
|
||||||
|
setBranch(defaultBranch);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setBranches([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBranches(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadBranches();
|
||||||
|
}, [selectedRepo, selectedProject, cloneMode]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace branch input with dropdown**
|
||||||
|
|
||||||
|
Replace the branch input section (around lines 686-696):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{cloneMode === "clone" && (
|
||||||
|
<>
|
||||||
|
<label className="form-field">
|
||||||
|
Branch
|
||||||
|
{isLoadingBranches ? (
|
||||||
|
<span className="muted">Loading branches...</span>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={isCreatingNewBranch ? "__new__" : branch}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === "__new__") {
|
||||||
|
setIsCreatingNewBranch(true);
|
||||||
|
setNewBranchName("");
|
||||||
|
} else {
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setBranch(value);
|
||||||
|
setBaseBranch(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name} {b.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__new__">Create new branch...</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isCreatingNewBranch && (
|
||||||
|
<>
|
||||||
|
<label className="form-field">
|
||||||
|
New Branch Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newBranchName}
|
||||||
|
onChange={(e) => setNewBranchName(e.target.value)}
|
||||||
|
placeholder="feature/my-new-branch"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Base Branch
|
||||||
|
<select
|
||||||
|
value={baseBranch}
|
||||||
|
onChange={(e) => setBaseBranch(e.target.value)}
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name} {b.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRepo && (
|
||||||
|
<div className="form-field ssh-key-info">
|
||||||
|
{(() => {
|
||||||
|
const repo = repositories.find((r) => r.id === selectedRepo);
|
||||||
|
if (!repo) return null;
|
||||||
|
if (repo.ssh_key_id) {
|
||||||
|
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
|
||||||
|
return (
|
||||||
|
<span className="success-text">
|
||||||
|
SSH key: {key?.name || "Assigned"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="warning-text">
|
||||||
|
No SSH key assigned to this repository. Clone mode requires an SSH key.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update handleCreate to pass newBranch**
|
||||||
|
|
||||||
|
Change the `createInstance` call (around line 210):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const instance = await createInstance(
|
||||||
|
selectedProject,
|
||||||
|
selectedRepo,
|
||||||
|
selectedToolType,
|
||||||
|
displayName || undefined,
|
||||||
|
cloneMode,
|
||||||
|
isCreatingNewBranch ? baseBranch : branch,
|
||||||
|
isCreatingNewBranch ? newBranchName : undefined
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Reset new branch state on successful creation**
|
||||||
|
|
||||||
|
In the success handler (around line 227), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setNewBranchName("");
|
||||||
|
setBaseBranch("");
|
||||||
|
setBranches([]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/src/pages/sessions.tsx
|
||||||
|
git commit -m "feat: add branch dropdown and new branch creation UI
|
||||||
|
|
||||||
|
- Replace free-text branch input with dropdown of available branches
|
||||||
|
- Add 'Create new branch...' option with name and base branch inputs
|
||||||
|
- Load branches from API when repository is selected in clone mode
|
||||||
|
- Pass newBranch parameter to createInstance API"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Verify and Test
|
||||||
|
|
||||||
|
- [ ] **Step 1: Check TypeScript compilation**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web && npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Step 2: Check Python syntax**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/api && python -m py_compile src/api/tool_instances.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No errors
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run backend tests if available**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/api && pytest src/tests/ -v -k "instance" || echo "No instance tests found"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "test: verify branch selection implementation compiles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec Coverage Check
|
||||||
|
|
||||||
|
| Spec Requirement | Task |
|
||||||
|
|---|---|
|
||||||
|
| Branch dropdown with available branches | Task 4 |
|
||||||
|
| "Create new branch..." option | Task 4 |
|
||||||
|
| New branch name input | Task 4 |
|
||||||
|
| Base branch dropdown | Task 4 |
|
||||||
|
| Backend clone + local branch creation | Task 2 |
|
||||||
|
| Frontend API integration | Task 1, 3 |
|
||||||
|
|
||||||
|
## Placeholder Scan
|
||||||
|
|
||||||
|
- No TBD, TODO, or "implement later" references
|
||||||
|
- All code is complete and copy-paste ready
|
||||||
|
- No vague instructions like "add appropriate error handling"
|
||||||
|
|
||||||
|
## Type Consistency Check
|
||||||
|
|
||||||
|
- `Branch` interface used consistently in Task 1 and Task 4
|
||||||
|
- `new_branch` / `newBranch` naming consistent between frontend and backend
|
||||||
|
- `CreateInstanceRequest` fields match API call in `sessions.ts`
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Session Branch Selection with New Branch Creation
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the free-text branch input in the session creation form with a dropdown of available branches from the repository. Add the ability to create a new local branch at clone time by selecting "Create new branch..." from the dropdown.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current session creation UI (`apps/web/src/pages/sessions.tsx`) has a free-text input for the branch name when "Clone fresh copy" mode is selected. Users must manually type the branch name, which is error-prone and doesn't show what branches are available.
|
||||||
|
|
||||||
|
The backend already has:
|
||||||
|
- A `GET /projects/{project_id}/repositories/{repo_id}/branches` endpoint that returns all branches and the default branch
|
||||||
|
- A `clone_repository` service that clones a specific branch
|
||||||
|
- Branch creation APIs for the original repository
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
|
||||||
|
#### 1. Branch API Integration
|
||||||
|
|
||||||
|
Add a new API function in `apps/web/src/api/git_repositories.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Branch {
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
last_commit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchesResponse {
|
||||||
|
branches: Branch[];
|
||||||
|
default_branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRepositoryBranches(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string
|
||||||
|
): Promise<BranchesResponse> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. UI State Management
|
||||||
|
|
||||||
|
In `apps/web/src/pages/sessions.tsx`, add state for:
|
||||||
|
- `branches`: `Branch[]` — loaded when a repository is selected and clone mode is active
|
||||||
|
- `isLoadingBranches`: `boolean`
|
||||||
|
- `isCreatingNewBranch`: `boolean` — toggled when "Create new branch..." is selected
|
||||||
|
- `newBranchName`: `string` — the name for the new branch
|
||||||
|
- `baseBranch`: `string` — the base branch for the new branch
|
||||||
|
|
||||||
|
#### 3. Branch Loading
|
||||||
|
|
||||||
|
When a repository is selected and clone mode is "clone", fetch branches:
|
||||||
|
- Call `listRepositoryBranches(selectedProject, selectedRepo)`
|
||||||
|
- Set `baseBranch` to `default_branch` from the response
|
||||||
|
- If the current `branch` state is not in the list, reset it to `default_branch`
|
||||||
|
|
||||||
|
#### 4. Branch Dropdown
|
||||||
|
|
||||||
|
Replace the free-text input with a `<select>`:
|
||||||
|
- Options populated from `branches` state
|
||||||
|
- Default branch marked visually: `"main (default)"`
|
||||||
|
- Last option: `"Create new branch..."` (disabled separator style or as a real option)
|
||||||
|
- When selected, set `isCreatingNewBranch = true`
|
||||||
|
|
||||||
|
#### 5. New Branch Form
|
||||||
|
|
||||||
|
When `isCreatingNewBranch` is true, show:
|
||||||
|
- **New branch name** input (required, validated for valid git branch name)
|
||||||
|
- **Base branch** dropdown (populated from `branches`, defaulting to `default_branch`)
|
||||||
|
|
||||||
|
#### 6. Form Submission
|
||||||
|
|
||||||
|
Update `handleCreate` to handle new branch creation:
|
||||||
|
- If `isCreatingNewBranch` is true, pass `newBranchName` and `baseBranch` to the API
|
||||||
|
- The `branch` parameter sent to the API should be:
|
||||||
|
- `newBranchName` if creating a new branch
|
||||||
|
- The selected existing branch otherwise
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
|
||||||
|
#### 1. Update `CreateInstanceRequest`
|
||||||
|
|
||||||
|
In `apps/api/src/api/tool_instances.py`, extend the request model:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CreateInstanceRequest(BaseModel):
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||||
|
display_name: str | None = Field(default=None, description="Optional display name")
|
||||||
|
clone_mode: str = Field(default="mount", description="'mount' or 'clone'")
|
||||||
|
branch: str | None = Field(default="main", description="Branch to clone")
|
||||||
|
new_branch: str | None = Field(default=None, description="Create a new branch from 'branch' after clone")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Update Clone Logic
|
||||||
|
|
||||||
|
In `create_instance`, after cloning:
|
||||||
|
- If `data.new_branch` is provided:
|
||||||
|
1. Clone the `data.branch` (base branch) as usual
|
||||||
|
2. Run `git -C <clone_path> checkout -b <new_branch>` to create the local branch
|
||||||
|
3. Store `new_branch` in the `branch` field of the ToolInstance record
|
||||||
|
|
||||||
|
#### 3. Update `clone_repository` Service
|
||||||
|
|
||||||
|
No changes needed — it already clones a specific branch. The new branch creation happens after clone.
|
||||||
|
|
||||||
|
#### 4. Database Schema
|
||||||
|
|
||||||
|
No changes needed — the existing `branch` field on `ToolInstance` can store the new branch name.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User selects repo + "Clone fresh copy"
|
||||||
|
→ Frontend fetches branches from GET /branches
|
||||||
|
→ User selects "Create new branch..."
|
||||||
|
→ User fills: newBranchName="feature-x", baseBranch="dev"
|
||||||
|
→ Frontend sends: { branch: "dev", new_branch: "feature-x", ... }
|
||||||
|
→ Backend clones "dev" branch
|
||||||
|
→ Backend runs: git checkout -b feature-x
|
||||||
|
→ Instance record stores branch="feature-x"
|
||||||
|
→ Container starts with the new branch checked out
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- **Branch fetch fails**: Show error, fallback to free-text input
|
||||||
|
- **Invalid branch name**: Frontend validation (regex for valid git branch names)
|
||||||
|
- **New branch creation fails**: Backend returns 400 with git error message
|
||||||
|
- **Branch already exists locally**: Backend handles gracefully (git checkout -b will fail if branch exists)
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
1. **Frontend**: Test branch dropdown loads correctly, "Create new branch" toggle works, form submission sends correct payload
|
||||||
|
2. **Backend**: Test instance creation with `new_branch` parameter, verify git command runs correctly
|
||||||
|
3. **Integration**: End-to-end test creating a session with a new branch
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `apps/web/src/api/git_repositories.ts` — Add `listRepositoryBranches` function
|
||||||
|
- `apps/web/src/pages/sessions.tsx` — Replace branch input with dropdown + new branch form
|
||||||
|
- `apps/api/src/api/tool_instances.py` — Extend `CreateInstanceRequest` and clone logic
|
||||||
|
- `apps/api/src/services/clone.py` — Add `create_local_branch` helper (optional)
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
- **Local branch only**: The new branch is created in the cloned workspace only, not pushed to the remote. This is intentional — it's a disposable work branch.
|
||||||
|
- **No branch deletion**: When the instance is deleted, the branch is lost with the clone. This matches the "disposable" mental model.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-24
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The current system has tool configs for tool-type runtime fields and config folders for user-owned mounted files. Tool instance start currently discovers applicable tool configs and active config folders automatically, writes files/env vars into the instance directory, modifies Docker Compose, and starts the container. This creates useful building blocks but not a single user-facing launch profile that can be selected, composed, previewed, scoped to project/tool, or disabled for a launch.
|
||||||
|
|
||||||
|
The target model is a config profile: a user-owned, selectable launch configuration that owns UTF-8 text files, mount roots, plain environment variables, and runtime hints. A profile can include other profiles in an ordered graph. Launch chooses one profile or `None`; included profiles provide stacking without making the start form multi-select.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Provide one primary config abstraction for reusable launch setup.
|
||||||
|
- Allow one selected profile, or no profile, at session start.
|
||||||
|
- Allow ordered profile composition through includes with loop detection.
|
||||||
|
- Support portable, project-specific, tool-specific, and project+tool profiles through optional `project_id` and `tool_type_id` references.
|
||||||
|
- Resolve compatible defaults by specificity, falling back to the first created compatible profile.
|
||||||
|
- Store the selected profile on the instance so restart behavior is predictable.
|
||||||
|
- Replace legacy active config folder auto-mounting; compatibility with old config folder behavior is not required.
|
||||||
|
- Provide a settings editor for profile env vars, mount roots, text files, include order, defaults, and basic runtime hints.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Secret storage or masking for env vars/files in v1.
|
||||||
|
- Binary file upload/editing in v1.
|
||||||
|
- Selecting multiple profiles directly at launch.
|
||||||
|
- Preserving legacy config folder semantics.
|
||||||
|
- Cross-user shared profiles.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Config profiles own files directly
|
||||||
|
|
||||||
|
Profiles will own their file content instead of referencing the existing `ConfigFolder` model. Reuse comes from profile composition: a profile such as `OpenCode Kimi` can include `Git identity` and `Shell defaults`.
|
||||||
|
|
||||||
|
Alternative considered: keep `ConfigFolder` as a reusable file-bundle primitive. This adds another concept (`profile -> folder -> files`) and makes the UI harder to explain. Direct file ownership keeps the model centered on one abstraction.
|
||||||
|
|
||||||
|
### Scope is derived from optional project/tool references
|
||||||
|
|
||||||
|
Profiles do not need a separate scope enum. Scope is inferred from nullable references:
|
||||||
|
|
||||||
|
- portable: no project and no tool
|
||||||
|
- tool: tool only
|
||||||
|
- project: project only
|
||||||
|
- project+tool: both project and tool
|
||||||
|
|
||||||
|
This avoids storing redundant state and naturally supports `Headquarter OpenCode` profiles.
|
||||||
|
|
||||||
|
### Launch selects one profile, composition happens inside profiles
|
||||||
|
|
||||||
|
The start UI will expose a single config profile selector with `None` as an option. Profiles may include other profiles in ordered composition, so advanced stacking happens in the profile editor rather than the launch form.
|
||||||
|
|
||||||
|
Alternative considered: allow selecting multiple profiles at launch. This is more flexible but makes start behavior harder to understand and raises ordering questions for every launch.
|
||||||
|
|
||||||
|
### Includes use an ordered graph with cycle detection
|
||||||
|
|
||||||
|
Profile includes will be represented as ordered edges. Resolution processes included profiles in position order, then applies the selected profile itself. Later layers override earlier layers. Cycles must be rejected when saving include relationships and guarded against again during launch resolution.
|
||||||
|
|
||||||
|
### Mounts use target roots with relative UTF-8 text files
|
||||||
|
|
||||||
|
Each profile mount has a target path, mode (`ro` or `rw`), and a map/list of relative file paths to UTF-8 text content. The resolver stages each resolved mount into the instance directory and adds Docker bind mounts to the compose file.
|
||||||
|
|
||||||
|
Alternative considered: store absolute container paths on every file. Mount roots better match Docker volume behavior, simplify editing, and make merge/conflict rules clearer.
|
||||||
|
|
||||||
|
### Deterministic override rules
|
||||||
|
|
||||||
|
Resolution order is:
|
||||||
|
|
||||||
|
1. tool defaults already provided by the tool type/compose template
|
||||||
|
2. included profiles in configured order, recursively resolved
|
||||||
|
3. selected profile itself
|
||||||
|
4. start-time/runtime overrides if a future workflow exposes them
|
||||||
|
|
||||||
|
For env vars and runtime hints, later values replace earlier values. For mounts with the same target path, file maps are merged and later relative file paths win. For mount mode conflicts on the same target path, the later layer wins.
|
||||||
|
|
||||||
|
### Defaults are selected by specificity
|
||||||
|
|
||||||
|
The default selector will prefer explicit defaults by specificity:
|
||||||
|
|
||||||
|
1. project+tool
|
||||||
|
2. project
|
||||||
|
3. tool
|
||||||
|
4. global/user
|
||||||
|
5. first created compatible profile
|
||||||
|
6. none
|
||||||
|
|
||||||
|
If no explicit default exists, the first created compatible profile becomes the default launch selection. Users can still choose `None` to disable all profile config for a launch.
|
||||||
|
|
||||||
|
### Instance stores selected profile
|
||||||
|
|
||||||
|
Tool instances store the selected config profile ID, or null when `None` was selected. Restart uses the stored selection to avoid changing behavior when the user's default profile changes later.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- Existing config folder users may lose automatic mounts because legacy compatibility is explicitly out of scope. Mitigation: this is an accepted breaking change and can be handled by manually recreating profiles.
|
||||||
|
- Direct profile-owned files may duplicate content across profiles. Mitigation: include relationships provide reusable file-only profiles without adding another model.
|
||||||
|
- Plain env vars can contain secrets. Mitigation: label v1 env vars as non-secret/plain text and defer secret storage to a later change.
|
||||||
|
- Profile graph resolution can become complex. Mitigation: keep launch selection single-profile, use ordered includes, test cycle detection and override ordering thoroughly.
|
||||||
|
- Mount conflicts may surprise users. Mitigation: provide a resolved preview showing final env vars, mount targets, and overridden files before launch or in profile details.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
- Add config profile tables and profile selection fields without preserving config folder behavior.
|
||||||
|
- Stop applying all active config folders during instance start.
|
||||||
|
- Apply selected config profile resolution during instance start/restart.
|
||||||
|
- Existing tool config runtime fields can remain available until replaced by profile runtime hints, but reusable file/env launch behavior moves to config profiles.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should runtime hints initially include all existing tool config runtime fields, or only env vars/files/mounts with start command and working directory?
|
||||||
|
- Should resolved config preview be required before first implementation, or can it be shipped after CRUD/start selection?
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Tool launches need reusable user-owned configuration that can combine editable files, mount targets, environment variables, and runtime hints without forcing every tool start to be configured from scratch. The existing config folder and tool config concepts provide pieces of this, but they do not model a single selectable, composable launch profile with project/tool-aware defaults and override behavior.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add config profiles as the primary reusable launch configuration model.
|
||||||
|
- Allow exactly one config profile, or no config profile, to be selected when starting a session.
|
||||||
|
- Allow profiles to include other profiles in ordered composition, with cycle detection at save and launch resolution.
|
||||||
|
- Let profiles be portable, tool-specific, project-specific, or project+tool-specific based on optional project and tool references.
|
||||||
|
- Store profile-owned UTF-8 text files under mount roots, with relative file paths and `ro`/`rw` mount modes.
|
||||||
|
- Store plain-text environment variables and runtime hints on profiles.
|
||||||
|
- Resolve profile layers deterministically: included profiles in order, then the selected profile, with later layers overriding earlier layers.
|
||||||
|
- Select defaults by specificity: project+tool, project, tool, global, then first compatible profile unless a default is explicitly configured.
|
||||||
|
- Store the selected config profile on started instances so restarts are predictable.
|
||||||
|
- **BREAKING**: Replace legacy always-active config folder mounting with explicit config profile selection. Legacy compatibility is not required for this change.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `config-profiles`: User-owned launch profiles that compose files, mounts, env vars, runtime hints, defaults, compatibility, and include relationships.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `tool-instances`: Session creation/start behavior accepts an optional selected config profile, applies resolved profile output, supports no-profile starts, and persists the selected profile for restart behavior.
|
||||||
|
- `user-config`: User settings expose profile management/default selection surfaces for launch configuration.
|
||||||
|
- `tool-config-management`: Legacy config folder behavior is superseded by config profiles for file mounts and reusable launch setup.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Backend models and migrations for config profiles, ordered profile includes, and default selection.
|
||||||
|
- Backend APIs for profile CRUD, include management, default configuration, compatibility filtering, and resolved profile preview.
|
||||||
|
- Tool instance create/start/restart APIs and Docker compose staging logic to apply resolved profile env vars, mounts, files, and runtime hints.
|
||||||
|
- Settings UI for a small profile/file editor and default profile management.
|
||||||
|
- Start session UI to select one compatible config profile or `None` before launching.
|
||||||
|
- Tests for profile resolution, override ordering, cycle detection, compatibility filtering, defaults, and launch application.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Profile Ownership And Scope
|
||||||
|
The system SHALL manage config profiles as user-owned launch configuration records with optional project and tool type references that derive compatibility scope.
|
||||||
|
|
||||||
|
#### Scenario: Create portable profile
|
||||||
|
- **GIVEN** an authenticated user
|
||||||
|
- **WHEN** they create a config profile without a project or tool type
|
||||||
|
- **THEN** the profile is stored for that user
|
||||||
|
- **AND** the profile is compatible with any project and tool type owned or accessible by that user
|
||||||
|
|
||||||
|
#### Scenario: Create scoped profile
|
||||||
|
- **GIVEN** an authenticated user with access to a project and a tool type
|
||||||
|
- **WHEN** they create a config profile with `project_id`, `tool_type_id`, or both
|
||||||
|
- **THEN** the profile is stored with those references
|
||||||
|
- **AND** compatibility is derived from the non-null references
|
||||||
|
|
||||||
|
#### Scenario: Reject cross-user references
|
||||||
|
- **GIVEN** an authenticated user
|
||||||
|
- **WHEN** they create or update a profile with a project, tool type, include, or default reference they cannot access
|
||||||
|
- **THEN** the request is rejected
|
||||||
|
|
||||||
|
### Requirement: Profile Content
|
||||||
|
The system SHALL store profile content as plain environment variables, runtime hints, and one or more mount roots containing UTF-8 text files.
|
||||||
|
|
||||||
|
#### Scenario: Save env vars and runtime hints
|
||||||
|
- **GIVEN** an authenticated user editing a config profile
|
||||||
|
- **WHEN** they save plain-text environment variables and runtime hints such as start command, working directory, and port
|
||||||
|
- **THEN** the system persists those values on the profile
|
||||||
|
- **AND** returns them through the profile API
|
||||||
|
|
||||||
|
#### Scenario: Save mounted text files
|
||||||
|
- **GIVEN** an authenticated user editing a config profile
|
||||||
|
- **WHEN** they add a mount with an absolute `target_path`, mode `ro` or `rw`, and relative UTF-8 text file paths
|
||||||
|
- **THEN** the system persists the mount and files
|
||||||
|
- **AND** preserves file content exactly as UTF-8 text
|
||||||
|
|
||||||
|
#### Scenario: Reject unsafe file paths
|
||||||
|
- **GIVEN** an authenticated user editing a config profile mount
|
||||||
|
- **WHEN** they submit an absolute file path, an empty relative path, or a relative path containing `..`
|
||||||
|
- **THEN** the request is rejected
|
||||||
|
|
||||||
|
### Requirement: Ordered Profile Includes
|
||||||
|
The system SHALL allow a config profile to include other compatible profiles in a deterministic order.
|
||||||
|
|
||||||
|
#### Scenario: Add ordered includes
|
||||||
|
- **GIVEN** an authenticated user with multiple config profiles
|
||||||
|
- **WHEN** they configure profile A to include profile B then profile C
|
||||||
|
- **THEN** the include order is stored
|
||||||
|
- **AND** resolution processes B before C before A
|
||||||
|
|
||||||
|
#### Scenario: Reject include cycle on save
|
||||||
|
- **GIVEN** an authenticated user with profiles A and B where A already includes B
|
||||||
|
- **WHEN** they update B to include A
|
||||||
|
- **THEN** the request is rejected with a cycle error
|
||||||
|
|
||||||
|
#### Scenario: Guard against cycle during resolution
|
||||||
|
- **GIVEN** stored profile include data contains a cycle
|
||||||
|
- **WHEN** the system resolves a selected profile
|
||||||
|
- **THEN** resolution fails safely without launching a partially resolved configuration
|
||||||
|
|
||||||
|
### Requirement: Profile Resolution
|
||||||
|
The system SHALL resolve a selected profile by recursively applying included profiles in order and then applying the selected profile itself.
|
||||||
|
|
||||||
|
#### Scenario: Resolve layered env vars
|
||||||
|
- **GIVEN** profile A includes profile B then profile C
|
||||||
|
- **AND** B, C, and A define the same environment variable
|
||||||
|
- **WHEN** profile A is resolved
|
||||||
|
- **THEN** the value from A wins over C and B
|
||||||
|
- **AND** the value from C wins over B for keys not set by A
|
||||||
|
|
||||||
|
#### Scenario: Resolve mount file conflicts
|
||||||
|
- **GIVEN** multiple resolved layers define the same mount `target_path`
|
||||||
|
- **WHEN** they contain different files under that mount
|
||||||
|
- **THEN** their file trees are merged
|
||||||
|
- **AND** later layers replace earlier content for the same relative file path
|
||||||
|
|
||||||
|
#### Scenario: Resolve mount mode conflicts
|
||||||
|
- **GIVEN** multiple resolved layers define the same mount `target_path` with different modes
|
||||||
|
- **WHEN** the profile is resolved
|
||||||
|
- **THEN** the mode from the latest layer wins
|
||||||
|
|
||||||
|
### Requirement: Profile Defaults
|
||||||
|
The system SHALL select the default compatible profile by explicit default specificity, then by first created compatible profile, then no profile.
|
||||||
|
|
||||||
|
#### Scenario: Choose most specific explicit default
|
||||||
|
- **GIVEN** a user has explicit default profiles for global, tool, project, and project+tool scopes
|
||||||
|
- **WHEN** they start a matching project/tool combination
|
||||||
|
- **THEN** the project+tool default is selected
|
||||||
|
- **AND** project, tool, and global defaults are used only when no more-specific explicit default matches
|
||||||
|
|
||||||
|
#### Scenario: Fall back to first compatible profile
|
||||||
|
- **GIVEN** a user has compatible profiles but no explicit matching default
|
||||||
|
- **WHEN** they start a session for a project/tool combination
|
||||||
|
- **THEN** the oldest compatible profile is selected by default
|
||||||
|
|
||||||
|
#### Scenario: No compatible profile
|
||||||
|
- **GIVEN** a user has no compatible profile for a project/tool combination
|
||||||
|
- **WHEN** they start a session
|
||||||
|
- **THEN** the default profile selection is `None`
|
||||||
|
|
||||||
|
### Requirement: Profile Compatibility Filtering
|
||||||
|
The system SHALL list compatible config profiles for a selected project and tool type by default.
|
||||||
|
|
||||||
|
#### Scenario: List compatible profiles
|
||||||
|
- **GIVEN** an authenticated user has portable, project-specific, tool-specific, and unrelated profiles
|
||||||
|
- **WHEN** the launch UI requests profiles for a selected project and tool type
|
||||||
|
- **THEN** the response includes portable profiles and profiles matching that project, tool type, or both
|
||||||
|
- **AND** excludes unrelated project-specific or tool-specific profiles
|
||||||
|
|
||||||
|
### Requirement: Resolved Profile Preview
|
||||||
|
The system SHALL expose a resolved profile preview for a selected profile and project/tool context.
|
||||||
|
|
||||||
|
#### Scenario: Preview resolved output
|
||||||
|
- **GIVEN** an authenticated user selects a compatible config profile
|
||||||
|
- **WHEN** they request a resolved preview
|
||||||
|
- **THEN** the response includes the final environment variables, runtime hints, mount targets, mount modes, and relative file paths
|
||||||
|
- **AND** indicates overridden values where practical
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tool config supports runtime fields
|
||||||
|
The system SHALL keep tool config runtime fields available for tool configuration, while reusable launch setup for user-owned files, mounts, and env var bundles SHALL be handled by config profiles.
|
||||||
|
|
||||||
|
#### Scenario: Create config with runtime fields
|
||||||
|
- **WHEN** user creates a tool config with start_command="npm start", port=3000, working_directory="/app"
|
||||||
|
- **THEN** the config is saved with all fields populated
|
||||||
|
|
||||||
|
#### Scenario: Environment variables as JSON
|
||||||
|
- **WHEN** user sets environment_variables to {"NODE_ENV": "production", "API_KEY": "secret"}
|
||||||
|
- **THEN** the system stores and returns the config with the JSON object preserved
|
||||||
|
- **AND** reusable per-launch environment bundles are managed through config profiles
|
||||||
|
|
||||||
|
#### Scenario: Volumes as JSON
|
||||||
|
- **WHEN** user sets volumes to [{"host": "/data", "container": "/app/data", "mode": "rw"}]
|
||||||
|
- **THEN** the system stores and returns the config with the JSON array preserved
|
||||||
|
- **AND** user-owned mounted file trees are managed through config profiles
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
### Requirement: Active config folders auto-mount during launch
|
||||||
|
The system SHALL NOT automatically mount all active config folders when starting tool instances.
|
||||||
|
|
||||||
|
#### Scenario: Start instance after config profiles replace folders
|
||||||
|
- **GIVEN** a user has legacy active config folders
|
||||||
|
- **WHEN** they start a tool instance without selecting a config profile
|
||||||
|
- **THEN** those folders are not automatically mounted
|
||||||
|
- **AND** only selected config profile output is applied for user-owned launch files and mounts
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Clone mode instance creation
|
||||||
|
The system SHALL support creating tool instances with a clone mode and an optional selected config profile.
|
||||||
|
|
||||||
|
#### Scenario: Create instance in clone mode
|
||||||
|
- **GIVEN** an authenticated user with a repository that has an SSH key and remote URL
|
||||||
|
- **WHEN** they create an instance with `clone_mode: "clone"`, `branch: "main"`, and a compatible config profile selection
|
||||||
|
- **THEN** the system clones the repository into the instance directory
|
||||||
|
- **AND** the compose file uses the clone path as `REPO_PATH`
|
||||||
|
- **AND** the instance record stores `clone_mode="clone"`, `branch="main"`, and the selected config profile ID
|
||||||
|
|
||||||
|
#### Scenario: Create instance in mount mode
|
||||||
|
- **GIVEN** an authenticated user with a repository
|
||||||
|
- **WHEN** they create an instance with `clone_mode: "mount"` (or omit the field)
|
||||||
|
- **THEN** the compose file uses the host repository path as `REPO_PATH`
|
||||||
|
- **AND** the instance record stores `clone_mode="mount"`
|
||||||
|
|
||||||
|
#### Scenario: Create instance with no config profile
|
||||||
|
- **GIVEN** an authenticated user creating a tool instance
|
||||||
|
- **WHEN** they select `None` for config profile
|
||||||
|
- **THEN** the instance record stores no selected config profile
|
||||||
|
- **AND** profile-owned env vars, files, mounts, and runtime hints are not applied at start
|
||||||
|
|
||||||
|
#### Scenario: Reject incompatible config profile selection
|
||||||
|
- **GIVEN** an authenticated user creating a tool instance for a project and tool type
|
||||||
|
- **WHEN** they select a config profile scoped to a different project or tool type
|
||||||
|
- **THEN** instance creation or start is rejected
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Config profile launch application
|
||||||
|
The system SHALL apply the selected config profile's resolved output when starting a tool instance.
|
||||||
|
|
||||||
|
#### Scenario: Start instance with selected profile
|
||||||
|
- **GIVEN** a pending tool instance with a selected compatible config profile
|
||||||
|
- **WHEN** the instance is started
|
||||||
|
- **THEN** the system resolves the selected profile
|
||||||
|
- **AND** writes resolved env vars into the instance environment
|
||||||
|
- **AND** stages resolved profile files under the instance directory
|
||||||
|
- **AND** adds Docker bind mounts for resolved profile mounts
|
||||||
|
- **AND** applies supported runtime hints before starting the container
|
||||||
|
|
||||||
|
#### Scenario: Restart uses stored profile selection
|
||||||
|
- **GIVEN** an existing instance was started with config profile A
|
||||||
|
- **AND** the user's default profile later changes to profile B
|
||||||
|
- **WHEN** the instance is restarted
|
||||||
|
- **THEN** the system uses stored profile A for restart behavior
|
||||||
|
- **AND** does not switch to profile B automatically
|
||||||
|
|
||||||
|
#### Scenario: Start fails on profile resolution error
|
||||||
|
- **GIVEN** an instance references a selected config profile that cannot be resolved
|
||||||
|
- **WHEN** the instance is started
|
||||||
|
- **THEN** the start request fails before launching the container
|
||||||
|
- **AND** the error explains the profile resolution problem
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Config Profile Settings Editor
|
||||||
|
The system SHALL expose a settings interface for managing the authenticated user's config profiles.
|
||||||
|
|
||||||
|
#### Scenario: Browse config profiles in settings
|
||||||
|
- **GIVEN** an authenticated user
|
||||||
|
- **WHEN** they open settings
|
||||||
|
- **THEN** they can view their config profiles
|
||||||
|
- **AND** see each profile's name, compatibility scope, default status, and included profiles
|
||||||
|
|
||||||
|
#### Scenario: Edit profile content in settings
|
||||||
|
- **GIVEN** an authenticated user editing a config profile
|
||||||
|
- **WHEN** they update env vars, runtime hints, mounts, text files, or include order
|
||||||
|
- **THEN** the settings UI saves those changes through the config profile API
|
||||||
|
- **AND** validation errors are shown without losing the user's draft edits
|
||||||
|
|
||||||
|
#### Scenario: Configure profile defaults in settings
|
||||||
|
- **GIVEN** an authenticated user editing config profiles
|
||||||
|
- **WHEN** they mark a profile as the default for a global, project, tool, or project+tool context
|
||||||
|
- **THEN** subsequent launches for matching contexts preselect that profile according to default specificity
|
||||||
|
|
||||||
|
### Requirement: Launch Profile Selection UI
|
||||||
|
The system SHALL allow the authenticated user to select one compatible config profile or `None` when starting a session.
|
||||||
|
|
||||||
|
#### Scenario: Preselect default compatible profile
|
||||||
|
- **GIVEN** an authenticated user has a default compatible profile for a selected project and tool type
|
||||||
|
- **WHEN** they open the start session form
|
||||||
|
- **THEN** the form preselects that profile
|
||||||
|
- **AND** also offers `None` as a selectable option
|
||||||
|
|
||||||
|
#### Scenario: Select no profile
|
||||||
|
- **GIVEN** an authenticated user starts a session
|
||||||
|
- **WHEN** they choose `None` in the config profile selector
|
||||||
|
- **THEN** the start request records no selected config profile
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## 1. Sequential Foundation
|
||||||
|
|
||||||
|
- [ ] 1.1 Backend data model and migrations. Add config profile, ordered include, mount/file, default selection, and tool instance selected-profile storage. Remove launch-time reliance on legacy active config folder auto-mounting. Depends on: none. Parallel with: none.
|
||||||
|
|
||||||
|
## 2. Parallel Backend Core After Foundation
|
||||||
|
|
||||||
|
- [ ] 2.1 Profile resolver service. Implement recursive ordered include resolution, deterministic merge rules, save-independent cycle protection, and resolved output structures for env vars, runtime hints, mounts, file trees, and override metadata. Depends on: 1.1. Parallel with: 2.2, 2.3.
|
||||||
|
- [ ] 2.2 Profile CRUD, validation, compatibility, and default APIs. Implement user-owned CRUD, access checks, path/content validation, ordered include save validation, compatibility-filtered listing, and default profile selection APIs. Depends on: 1.1. Parallel with: 2.1, 2.3.
|
||||||
|
- [ ] 2.3 Instance API profile selection plumbing. Update instance create/start request and persistence paths to accept one compatible config profile ID or null, reject incompatible selections, and preserve `None`. Depends on: 1.1. Parallel with: 2.1, 2.2.
|
||||||
|
|
||||||
|
## 3. Sequential Backend Integration
|
||||||
|
|
||||||
|
- [ ] 3.1 Resolved profile preview API. Return final env vars, runtime hints, mount targets, mount modes, relative file paths, and practical override indicators. Depends on: 2.1, 2.2. Parallel with: none.
|
||||||
|
- [ ] 3.2 Launch and restart profile application. Apply resolved profile output during start by writing env vars, staging files, adding Docker bind mounts, applying supported runtime hints, skipping profile output for `None`, and using the stored profile on restart instead of current defaults. Depends on: 2.1, 2.3. Parallel with: none.
|
||||||
|
|
||||||
|
## 4. Parallel Frontend After Backend Contracts
|
||||||
|
|
||||||
|
- [ ] 4.1 Frontend config profile API client and types. Add client methods/types for profile CRUD, includes, defaults, compatible listing, preview, and instance profile selection payloads. Depends on: 2.2, 2.3, 3.1. Parallel with: none.
|
||||||
|
- [ ] 4.2 Settings profile management UI. Add settings surfaces for browsing profiles, editing env vars/runtime hints/mounts/text files/include order/defaults, and preserving draft edits on validation errors. Depends on: 4.1. Parallel with: 4.3.
|
||||||
|
- [ ] 4.3 Launch profile selection UI. Update session start UI to load compatible profiles for the selected project/tool, preselect the resolved default, offer `None`, and submit selected profile ID or null. Depends on: 4.1. Parallel with: 4.2.
|
||||||
|
|
||||||
|
## 5. Parallel Test Suites
|
||||||
|
|
||||||
|
- [ ] 5.1 Backend profile API and resolver tests. Cover CRUD, ownership, path validation, compatibility filtering, default precedence, include ordering, save-time cycle rejection, resolution-time cycle protection, and deterministic override behavior. Depends on: 2.1, 2.2, 3.1. Parallel with: 5.2, 5.3.
|
||||||
|
- [ ] 5.2 Backend instance launch tests. Cover selected profile start/restart behavior, incompatible profile rejection, `None` selection, env var writing, file staging, Docker mount application, runtime hints, and legacy active config folders not auto-mounting. Depends on: 3.2. Parallel with: 5.1, 5.3.
|
||||||
|
- [ ] 5.3 Frontend profile UI tests. Cover profile settings editing, validation error handling, compatible profile loading, default preselection, `None` selection, and submit payloads. Depends on: 4.2, 4.3. Parallel with: 5.1, 5.2.
|
||||||
|
|
||||||
|
## 6. Sequential Quality Gates
|
||||||
|
|
||||||
|
- [ ] 6.1 Run backend quality gates for touched API/model/services code and fix failures. Depends on: 5.1, 5.2. Parallel with: none.
|
||||||
|
- [ ] 6.2 Run frontend quality gates for touched settings/session UI code and fix failures. Depends on: 5.3. Parallel with: none.
|
||||||
|
- [ ] 6.3 Run final OpenSpec status and implementation checklist review. Depends on: 6.1, 6.2. Parallel with: none.
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Currently, tool types use a JSON `interfaces` array (e.g., `["web"]`, `["terminal"]`, `["web", "terminal"]`) to define what interfaces a tool supports. This was designed for flexibility but in practice:
|
||||||
|
1. No tool needs both web and terminal simultaneously
|
||||||
|
2. Terminal tools don't expose ports or need tunneling
|
||||||
|
3. The UI shows checkboxes for both, allowing invalid multi-select combinations
|
||||||
|
|
||||||
|
The database migration `0008_tool_type_category` added the `interfaces` JSON column. All existing records use `["web"]` or `["terminal"]` as the first (and only) element.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Replace `interfaces` array with single `interface_type` string column
|
||||||
|
- Add `requires_port` boolean to indicate if port/tunnel config is relevant
|
||||||
|
- Update UI to use dropdown instead of checkboxes
|
||||||
|
- Conditionally hide port fields for terminal tools
|
||||||
|
- Migrate existing data safely
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- No changes to tool instance runtime behavior
|
||||||
|
- No changes to tunnel/port infrastructure
|
||||||
|
- No changes to existing tool configs (port_override remains in schema)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**Decision: Replace interfaces array with single interface_type string**
|
||||||
|
- Rationale: Simplifies model, API, and UI. No legitimate use case for multiple interfaces.
|
||||||
|
- Alternative: Keep array but enforce single item — rejected because it keeps unnecessary complexity
|
||||||
|
|
||||||
|
**Decision: Add requires_port boolean instead of inferring from interface_type**
|
||||||
|
- Rationale: Explicit is better than implicit. Future interface types may have different port needs.
|
||||||
|
- Alternative: Infer from interface_type === "web" — rejected for flexibility
|
||||||
|
|
||||||
|
**Decision: Default requires_port = true for existing records, then update per actual type**
|
||||||
|
- Rationale: Most existing tools are web-based. Safer default.
|
||||||
|
- Migration will inspect existing interfaces[0] to set correct value.
|
||||||
|
|
||||||
|
**Decision: Keep port_override in tool_configs schema**
|
||||||
|
- Rationale: Even terminal tools might need port overrides in edge cases. The UI just hides it.
|
||||||
|
- Alternative: Remove column — rejected to avoid destructive migration
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Risk]** Existing API consumers expect `interfaces` array
|
||||||
|
- **Mitigation:** This is a **BREAKING** change. Update frontend simultaneously. Document in changelog.
|
||||||
|
- **[Risk]** Data migration fails for unexpected interfaces values
|
||||||
|
- **Mitigation:** Migration takes first array element. Add fallback to "web" with requires_port=true.
|
||||||
|
- **[Risk]** Tests break across backend and frontend
|
||||||
|
- **Mitigation:** Update all test fixtures and assertions in single commit.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Create Alembic migration to:
|
||||||
|
- Add `interface_type` string column (nullable temporarily)
|
||||||
|
- Add `requires_port` boolean column (default true)
|
||||||
|
- Migrate data: `interface_type = interfaces[0]`, `requires_port = (interfaces[0] == "web")`
|
||||||
|
- Drop `interfaces` column
|
||||||
|
- Make `interface_type` non-nullable
|
||||||
|
2. Update Pydantic schemas (Create/Update/Response)
|
||||||
|
3. Update SQLAlchemy model
|
||||||
|
4. Update frontend types and API client
|
||||||
|
5. Update tool workshop form (dropdown + conditional fields)
|
||||||
|
6. Update built-in seed data
|
||||||
|
7. Update tests
|
||||||
|
8. Run full test suite
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Currently, tool types support multiple interfaces (e.g., `web` and `terminal` simultaneously), but in practice each tool serves a single purpose and should have one clear interface type. Additionally, terminal tools don't need ports or tunneling capabilities, yet the UI always shows port configuration. This creates confusion and allows invalid configurations.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **BREAKING**: Change `interfaces` from an array (`["web"]`) to a single string (`"web"` or `"terminal"`) in the ToolType model, API, and frontend
|
||||||
|
- Add `requires_port` boolean field to ToolType model — `true` for web tools, `false` for terminal tools
|
||||||
|
- Update frontend UI to use a dropdown for interface type selection (single choice)
|
||||||
|
- Conditionally show/hide port-related fields based on `requires_port`
|
||||||
|
- Add database migration to convert existing `interfaces` arrays to single values and set `requires_port`
|
||||||
|
- Update built-in tool types (code-server, jupyter-notebook) to use new schema
|
||||||
|
- Update tool workshop page to reflect the single-type dropdown and conditional port visibility
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `tool-type-single-interface`: Enforce single interface type per tool with dropdown selection
|
||||||
|
- `tool-type-port-visibility`: Conditionally show port/tunnel config based on tool interface type
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `tool-types-definition`: Update model and API to replace `interfaces` array with single `interface_type` string and add `requires_port`
|
||||||
|
- `frontend-foundation`: Update tool workshop UI for single interface dropdown and conditional port fields
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Database: Migration to change `interfaces` JSON column to `interface_type` string + add `requires_port` boolean
|
||||||
|
- Backend API: Update Pydantic schemas, SQLAlchemy model, validation logic
|
||||||
|
- Frontend: Update TypeScript types, tool workshop form, API client
|
||||||
|
- Existing tool configs: No direct impact, but port_override field becomes irrelevant for terminal tools
|
||||||
|
- Tests: Update test data and assertions for new schema
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tool Interface Type Dropdown
|
||||||
|
The tool workshop SHALL provide a dropdown for selecting a single interface type.
|
||||||
|
|
||||||
|
#### Scenario: Interface type dropdown
|
||||||
|
- GIVEN the tool workshop page
|
||||||
|
- WHEN a user creates or edits a tool type
|
||||||
|
- THEN the interface type field is a dropdown (not checkboxes)
|
||||||
|
- AND the options are "web" and "terminal"
|
||||||
|
- AND only one option can be selected
|
||||||
|
|
||||||
|
### Requirement: Conditional Port Fields
|
||||||
|
The tool workshop SHALL conditionally show or hide port-related fields based on the selected interface type.
|
||||||
|
|
||||||
|
#### Scenario: Web tool shows port fields
|
||||||
|
- GIVEN a tool type with interface type "web"
|
||||||
|
- WHEN the user views the tool editor
|
||||||
|
- THEN the Default Port field is visible and required
|
||||||
|
- AND port-related config fields are shown
|
||||||
|
|
||||||
|
#### Scenario: Terminal tool hides port fields
|
||||||
|
- GIVEN a tool type with interface type "terminal"
|
||||||
|
- WHEN the user views the tool editor
|
||||||
|
- THEN the Default Port field is hidden
|
||||||
|
- AND port-related config fields are hidden or disabled
|
||||||
|
|
||||||
|
#### Scenario: Changing interface type updates visibility
|
||||||
|
- GIVEN a user changes interface type from "web" to "terminal"
|
||||||
|
- WHEN the change is applied
|
||||||
|
- THEN port fields are immediately hidden
|
||||||
|
- AND any port value is preserved but not validated
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Port Configuration Visibility
|
||||||
|
The system SHALL control whether port configuration is relevant for a tool type.
|
||||||
|
|
||||||
|
#### Scenario: Web tool requires port
|
||||||
|
- GIVEN a tool type with `requires_port` = true
|
||||||
|
- WHEN the tool type is displayed in the UI
|
||||||
|
- THEN port configuration fields are shown
|
||||||
|
- AND default_port is validated as required
|
||||||
|
|
||||||
|
#### Scenario: Terminal tool does not require port
|
||||||
|
- GIVEN a tool type with `requires_port` = false
|
||||||
|
- WHEN the tool type is displayed in the UI
|
||||||
|
- THEN port configuration fields are hidden
|
||||||
|
- AND default_port validation is skipped
|
||||||
|
- AND port_override in tool configs is not shown
|
||||||
|
|
||||||
|
### Requirement: Port Validation Based on requires_port
|
||||||
|
The API SHALL validate port fields conditionally based on requires_port.
|
||||||
|
|
||||||
|
#### Scenario: Validate port for web tools
|
||||||
|
- GIVEN a tool type with `requires_port` = true
|
||||||
|
- WHEN creating or updating without a default_port
|
||||||
|
- THEN the system returns 400 Bad Request
|
||||||
|
|
||||||
|
#### Scenario: Skip port validation for terminal tools
|
||||||
|
- GIVEN a tool type with `requires_port` = false
|
||||||
|
- WHEN creating or updating without a default_port
|
||||||
|
- THEN the request succeeds
|
||||||
|
- AND default_port defaults to 0 or null
|
||||||
|
|
||||||
|
### Requirement: UI Conditional Rendering
|
||||||
|
The frontend SHALL conditionally render port-related UI elements.
|
||||||
|
|
||||||
|
#### Scenario: Hide port in tool list
|
||||||
|
- GIVEN a terminal tool type
|
||||||
|
- WHEN displayed in the tool workshop list
|
||||||
|
- THEN port information is not shown
|
||||||
|
|
||||||
|
#### Scenario: Hide port in editor
|
||||||
|
- GIVEN a terminal tool type being edited
|
||||||
|
- WHEN the editor form is rendered
|
||||||
|
- THEN the Default Port field is hidden
|
||||||
|
- AND the readiness probe fields are shown (still relevant)
|
||||||
|
|
||||||
|
#### Scenario: Show port for web tools
|
||||||
|
- GIVEN a web tool type being edited
|
||||||
|
- WHEN the editor form is rendered
|
||||||
|
- THEN the Default Port field is visible and required
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Single Interface Type Enforcement
|
||||||
|
The system SHALL enforce that each tool type has exactly one interface type.
|
||||||
|
|
||||||
|
#### Scenario: Create with single interface
|
||||||
|
- GIVEN a tool type creation request with `interface_type` = "web"
|
||||||
|
- WHEN the request is processed
|
||||||
|
- THEN the tool type is created successfully
|
||||||
|
- AND the interface type is stored as a single string
|
||||||
|
|
||||||
|
#### Scenario: Reject multiple interfaces
|
||||||
|
- GIVEN a legacy request with `interfaces` array
|
||||||
|
- WHEN the request is processed
|
||||||
|
- THEN the system returns 400 Bad Request
|
||||||
|
- AND the error message indicates that `interface_type` (string) should be used instead
|
||||||
|
|
||||||
|
### Requirement: Interface Type Validation
|
||||||
|
The system SHALL validate that interface_type is one of the allowed values.
|
||||||
|
|
||||||
|
#### Scenario: Valid interface types
|
||||||
|
- GIVEN interface_type values "web" or "terminal"
|
||||||
|
- WHEN a tool type is created or updated
|
||||||
|
- THEN the request is accepted
|
||||||
|
|
||||||
|
#### Scenario: Invalid interface type
|
||||||
|
- GIVEN interface_type value "ssh"
|
||||||
|
- WHEN a tool type is created or updated
|
||||||
|
- THEN the system returns 400 Bad Request
|
||||||
|
|
||||||
|
### Requirement: Data Migration
|
||||||
|
The system SHALL migrate existing tool types from interfaces array to single interface_type.
|
||||||
|
|
||||||
|
#### Scenario: Migrate existing records
|
||||||
|
- GIVEN existing tool types with interfaces = ["web"] or ["terminal"]
|
||||||
|
- WHEN the migration runs
|
||||||
|
- THEN each record gets interface_type = interfaces[0]
|
||||||
|
- AND requires_port is set based on the interface type
|
||||||
|
- AND the old interfaces column is removed
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tool Type Model
|
||||||
|
The system SHALL provide a `ToolType` model to store tool definitions.
|
||||||
|
|
||||||
|
#### Scenario: Model structure
|
||||||
|
- GIVEN a tool type definition
|
||||||
|
- THEN the model SHALL have:
|
||||||
|
- `id`: UUID primary key
|
||||||
|
- `name`: unique string (e.g., "code-server")
|
||||||
|
- `display_name`: human-readable string (e.g., "VS Code Server")
|
||||||
|
- `description`: optional text
|
||||||
|
- `category`: string (e.g., "editor", "notebook")
|
||||||
|
- `interface_type`: single string — "web" or "terminal"
|
||||||
|
- `requires_port`: boolean indicating if port/tunnel configuration is needed
|
||||||
|
- `compose_template`: Docker Compose YAML string
|
||||||
|
- `dockerfile_template`: Dockerfile string
|
||||||
|
- `definition_type`: string — "compose" or "dockerfile"
|
||||||
|
- `required_variables`: list of required template variables
|
||||||
|
- `is_builtin`: boolean flag for system-defined types
|
||||||
|
- `created_at`/`updated_at`: timestamps
|
||||||
|
|
||||||
|
### Requirement: CRUD API Endpoints
|
||||||
|
The system SHALL provide REST API endpoints for tool type management.
|
||||||
|
|
||||||
|
#### Scenario: Create tool type
|
||||||
|
- GIVEN an admin user
|
||||||
|
- WHEN they POST /api/tool-types with valid data
|
||||||
|
- THEN the system creates a new tool type
|
||||||
|
- AND validates `interface_type` is "web" or "terminal"
|
||||||
|
- AND validates `requires_port` is boolean
|
||||||
|
- AND validates the compose template YAML (if definition_type is "compose")
|
||||||
|
- AND validates all required variables are present in template
|
||||||
|
- AND returns 201 Created with the new tool type
|
||||||
|
|
||||||
|
#### Scenario: Update tool type
|
||||||
|
- GIVEN an admin user
|
||||||
|
- WHEN they PUT /api/tool-types/{id} with valid data
|
||||||
|
- THEN the system updates the tool type
|
||||||
|
- AND validates `interface_type` is "web" or "terminal" if provided
|
||||||
|
- AND re-validates the compose template
|
||||||
|
- AND returns 200 OK with updated tool type
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
### Requirement: Multiple interfaces support
|
||||||
|
**Reason**: Tool types now use a single `interface_type` instead of an array of interfaces. No tool legitimately needs both web and terminal interfaces simultaneously.
|
||||||
|
**Migration**: Use `interface_type` field (string) instead of `interfaces` array. Set to "web" or "terminal".
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Port requirement indication
|
||||||
|
The system SHALL allow tool types to indicate whether they require port configuration.
|
||||||
|
|
||||||
|
#### Scenario: Web tool requires port
|
||||||
|
- GIVEN a tool type with `interface_type` = "web"
|
||||||
|
- WHEN the tool type is created or updated
|
||||||
|
- THEN `requires_port` SHALL default to true
|
||||||
|
- AND port-related configuration is shown in the UI
|
||||||
|
|
||||||
|
#### Scenario: Terminal tool does not require port
|
||||||
|
- GIVEN a tool type with `interface_type` = "terminal"
|
||||||
|
- WHEN the tool type is created or updated
|
||||||
|
- THEN `requires_port` SHALL default to false
|
||||||
|
- AND port-related configuration is hidden in the UI
|
||||||
|
|
||||||
|
### Requirement: Single interface validation
|
||||||
|
The system SHALL enforce that each tool type has exactly one interface type.
|
||||||
|
|
||||||
|
#### Scenario: Invalid interface type
|
||||||
|
- GIVEN a tool type creation request with `interface_type` = "invalid"
|
||||||
|
- WHEN the request is processed
|
||||||
|
- THEN the system returns 400 Bad Request
|
||||||
|
- AND the error message indicates valid values are "web" or "terminal"
|
||||||
|
|
||||||
|
#### Scenario: Missing interface type
|
||||||
|
- GIVEN a tool type creation request without `interface_type`
|
||||||
|
- WHEN the request is processed
|
||||||
|
- THEN the system returns 400 Bad Request
|
||||||
|
- AND the error message indicates interface_type is required
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
## 1. Database Migration
|
||||||
|
|
||||||
|
- [x] 1.1 Create Alembic migration to add `interface_type` string column and `requires_port` boolean column to `tool_types` table
|
||||||
|
- [x] 1.2 Write migration logic to populate `interface_type` from `interfaces[0]` and set `requires_port` based on value
|
||||||
|
- [x] 1.3 Drop `interfaces` JSON column and make `interface_type` non-nullable
|
||||||
|
|
||||||
|
## 2. Backend Model & API Updates
|
||||||
|
|
||||||
|
- [x] 2.1 Update SQLAlchemy model (`apps/api/src/models/tool_type.py`) — replace `interfaces` list with `interface_type` string and add `requires_port` boolean
|
||||||
|
- [x] 2.2 Update Pydantic schemas (`apps/api/src/api/tool_types.py`) — `ToolTypeCreate`, `ToolTypeUpdate`, `ToolTypeResponse`
|
||||||
|
- [x] 2.3 Add validation for `interface_type` (must be "web" or "terminal")
|
||||||
|
- [x] 2.4 Update default values and built-in tool type seeding logic
|
||||||
|
- [x] 2.5 Update API tests for new schema
|
||||||
|
|
||||||
|
## 3. Frontend Type & API Updates
|
||||||
|
|
||||||
|
- [x] 3.1 Update TypeScript interfaces (`apps/web/src/api/tool_types.ts`) — replace `interfaces: string[]` with `interface_type: string` and add `requires_port: boolean`
|
||||||
|
- [x] 3.2 Update API request/response types (`CreateToolTypeRequest`, `UpdateToolTypeRequest`)
|
||||||
|
|
||||||
|
## 4. Tool Workshop UI Updates
|
||||||
|
|
||||||
|
- [x] 4.1 Replace interface checkboxes with dropdown (single-select) in tool editor
|
||||||
|
- [x] 4.2 Add conditional rendering for port field based on `requires_port` / `interface_type`
|
||||||
|
- [x] 4.3 Update tool list to show `interface_type` instead of interfaces array
|
||||||
|
- [x] 4.4 Update form state management for new fields
|
||||||
|
- [x] 4.5 Update dirty state tracking
|
||||||
|
|
||||||
|
## 5. Test Updates
|
||||||
|
|
||||||
|
- [x] 5.1 Update backend API tests (`test_tool_types_api.py`) for new schema
|
||||||
|
- [x] 5.2 Update frontend tests (`tool-workshop.test.tsx`) for dropdown and conditional fields
|
||||||
|
- [x] 5.3 Update mock data fixtures
|
||||||
|
|
||||||
|
## 6. Verification & Cleanup
|
||||||
|
|
||||||
|
- [x] 6.1 Run backend tests: `pytest apps/api/tests/`
|
||||||
|
- [x] 6.2 Run frontend typecheck: `npm run typecheck`
|
||||||
|
- [x] 6.3 Run frontend tests: `npm run test`
|
||||||
|
- [x] 6.4 Run lint: `npm run lint`
|
||||||
|
- [x] 6.5 Verify migration applies cleanly to existing database
|
||||||
|
- [x] 6.6 Update documentation if needed
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## 1. Backend - SSH Existence Check
|
||||||
|
|
||||||
|
- [x] 1.1 Add `git ls-remote` preflight to repository creation in `git_repositories.py`
|
||||||
|
- [x] 1.2 Build SSH clone URL from `owner` and `repo` for `git.commumedia.org`
|
||||||
|
- [x] 1.3 Return a clear error when the repository is missing or inaccessible
|
||||||
|
|
||||||
|
## 2. Frontend - Structured Clone Form
|
||||||
|
|
||||||
|
- [x] 2.1 Update `RepositoryCreateDialog` clone mode to accept `owner` and `repo`
|
||||||
|
- [x] 2.2 Keep advanced full-URL paste flow and blank repository fallback
|
||||||
|
- [x] 2.3 Reuse the shared dialog from repository settings and repositories page
|
||||||
|
|
||||||
|
## 3. Validation and Docs
|
||||||
|
|
||||||
|
- [x] 3.1 Update repository docs to explain SSH-only owner/repo input
|
||||||
|
- [x] 3.2 Add tests for success, missing repo, and URL fallback behavior
|
||||||
|
|
||||||
|
## 4. Quality Gates
|
||||||
|
|
||||||
|
- [x] 4.1 Run backend and frontend targeted tests
|
||||||
|
- [x] 4.2 Run frontend typecheck and lint where applicable
|
||||||
|
- [x] 4.3 Commit and push changes
|
||||||
+1
-1
@@ -16,4 +16,4 @@
|
|||||||
|
|
||||||
## 4. Quality Gates
|
## 4. Quality Gates
|
||||||
|
|
||||||
- [ ] 4.1 Run targeted API tests
|
- [x] 4.1 Run targeted API tests
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user