Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c94583307 | |||
| 649496b762 | |||
| d13e16f5e1 | |||
| d5f9df33b7 | |||
| 468e0eacda | |||
| 99097090e6 | |||
| 2a9e57ad0d | |||
| e4c5e7f2db | |||
| 6f35eb77ae |
@@ -30,11 +30,14 @@ from src.services.docker import (
|
|||||||
execute_compose_command,
|
execute_compose_command,
|
||||||
find_free_port,
|
find_free_port,
|
||||||
get_container_id,
|
get_container_id,
|
||||||
|
get_container_logs,
|
||||||
get_container_name,
|
get_container_name,
|
||||||
|
get_container_status,
|
||||||
recreate_tunnel,
|
recreate_tunnel,
|
||||||
render_compose_template,
|
render_compose_template,
|
||||||
start_cloudflared_tunnel,
|
start_cloudflared_tunnel,
|
||||||
stop_cloudflared_tunnel,
|
stop_cloudflared_tunnel,
|
||||||
|
wait_for_container_running,
|
||||||
write_compose_file,
|
write_compose_file,
|
||||||
write_config_files,
|
write_config_files,
|
||||||
write_env_file,
|
write_env_file,
|
||||||
@@ -552,20 +555,67 @@ async def start_instance(
|
|||||||
else:
|
else:
|
||||||
logger.warning("Failed to connect %s to backend network", container_name)
|
logger.warning("Failed to connect %s to backend network", container_name)
|
||||||
|
|
||||||
instance.status = "starting"
|
# Verify container reached running state
|
||||||
instance.last_started_at = datetime.now()
|
if instance.container_id:
|
||||||
await session.commit()
|
instance.status = "starting"
|
||||||
logger.info("Instance %s container is running, checking readiness", instance.id)
|
instance.last_started_at = datetime.now()
|
||||||
|
await session.commit()
|
||||||
|
logger.info("Instance %s: verifying container startup...", instance.id)
|
||||||
|
|
||||||
|
startup_result = wait_for_container_running(instance.container_id, timeout=30, interval=2.0)
|
||||||
|
|
||||||
|
if not startup_result["success"]:
|
||||||
|
# Container failed to start
|
||||||
|
error_msg = f"Container failed to start: status={startup_result['status']}"
|
||||||
|
if startup_result["exit_code"] is not None:
|
||||||
|
error_msg += f", exit_code={startup_result['exit_code']}"
|
||||||
|
|
||||||
|
# Get logs for debugging
|
||||||
|
logs = get_container_logs(instance.container_id, tail=50)
|
||||||
|
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
logger.error(
|
||||||
|
"Instance %s container startup failed after %.1fs: %s\nLogs:\n%s",
|
||||||
|
instance.id,
|
||||||
|
startup_result["waited_seconds"],
|
||||||
|
error_msg,
|
||||||
|
logs,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": error_msg,
|
||||||
|
"logs": logs,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Instance %s container started successfully after %.1fs",
|
||||||
|
instance.id,
|
||||||
|
startup_result["waited_seconds"],
|
||||||
|
)
|
||||||
|
|
||||||
# Execute readiness probe if configured
|
# Execute readiness probe if configured
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
if tool_type and tool_type.readiness_probe:
|
if tool_type and instance.container_id:
|
||||||
probe_config = tool_type.readiness_probe
|
# Determine probe command
|
||||||
probe_command = probe_config.get("command", "")
|
probe_command = None
|
||||||
probe_timeout = probe_config.get("timeout", 30)
|
probe_timeout = 30
|
||||||
probe_interval = probe_config.get("interval", 2)
|
probe_interval = 2
|
||||||
|
|
||||||
if probe_command and instance.container_id:
|
if tool_type.readiness_probe:
|
||||||
|
probe_config = tool_type.readiness_probe
|
||||||
|
probe_command = probe_config.get("command", "")
|
||||||
|
probe_timeout = probe_config.get("timeout", 30)
|
||||||
|
probe_interval = probe_config.get("interval", 2)
|
||||||
|
elif "web" in (tool_type.interfaces or []):
|
||||||
|
# Default probe for web tools
|
||||||
|
probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
|
||||||
|
probe_timeout = 30
|
||||||
|
probe_interval = 2
|
||||||
|
|
||||||
|
if probe_command:
|
||||||
|
instance.status = "probing"
|
||||||
|
await session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
|
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
|
||||||
instance.id, probe_command, probe_timeout, probe_interval
|
instance.id, probe_command, probe_timeout, probe_interval
|
||||||
@@ -578,14 +628,25 @@ async def start_instance(
|
|||||||
interval=probe_interval,
|
interval=probe_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Store probe result
|
||||||
|
instance.probe_result = {
|
||||||
|
"success": success,
|
||||||
|
"command": probe_command,
|
||||||
|
"logs": probe_logs,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
instance.status = "failed"
|
instance.status = "unhealthy"
|
||||||
instance.url = None
|
|
||||||
instance.public_url = None
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.error("Readiness probe failed for instance %s: %s", instance.id, "\n".join(probe_logs))
|
logger.error(
|
||||||
|
"Readiness probe failed for instance %s after %ds: %s",
|
||||||
|
instance.id,
|
||||||
|
probe_timeout,
|
||||||
|
"\n".join(probe_logs),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "failed",
|
"status": "unhealthy",
|
||||||
"error": f"Readiness probe failed after {probe_timeout}s",
|
"error": f"Readiness probe failed after {probe_timeout}s",
|
||||||
"probe_logs": probe_logs,
|
"probe_logs": probe_logs,
|
||||||
}
|
}
|
||||||
@@ -956,6 +1017,17 @@ async def recreate_tunnel_endpoint(
|
|||||||
detail="instance must be running to recreate tunnel",
|
detail="instance must be running to recreate tunnel",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate tunnel is actually broken before recreating
|
||||||
|
if instance.url:
|
||||||
|
tunnel_health = check_tunnel_health(instance.url)
|
||||||
|
if tunnel_health["tunnel_status"] == "error_response":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.",
|
||||||
|
)
|
||||||
|
elif tunnel_health["tunnel_status"] == "healthy":
|
||||||
|
return {"status": "healthy", "url": instance.url, "message": "Tunnel is already healthy"}
|
||||||
|
|
||||||
# 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)
|
||||||
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
|
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
|
||||||
@@ -987,8 +1059,8 @@ async def recreate_tunnel_endpoint(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
|
||||||
summary="Check tunnel health",
|
summary="Check instance health",
|
||||||
description="Check if the temporary Cloudflare tunnel for an instance is healthy.",
|
description="Check container and tunnel health for an instance.",
|
||||||
)
|
)
|
||||||
async def check_instance_tunnel_health(
|
async def check_instance_tunnel_health(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
@@ -997,7 +1069,7 @@ async def check_instance_tunnel_health(
|
|||||||
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),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Check tunnel health for an instance.
|
"""Check health for an instance (container + tunnel).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_id: UUID of the project.
|
project_id: UUID of the project.
|
||||||
@@ -1007,7 +1079,7 @@ async def check_instance_tunnel_health(
|
|||||||
session: Database session.
|
session: Database session.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with health status.
|
Dictionary with container_status, tunnel_status, probe_status, and overall healthy flag.
|
||||||
"""
|
"""
|
||||||
_user = await _get_user(session, user_id)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
@@ -1018,11 +1090,50 @@ async def check_instance_tunnel_health(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not instance.url or instance.status != "running":
|
# Check container status
|
||||||
return {"healthy": False, "status_code": None, "error": "instance not running"}
|
container_info = {"status": "not_found", "exit_code": None, "health": None}
|
||||||
|
if instance.container_id:
|
||||||
|
container_info = get_container_status(instance.container_id)
|
||||||
|
|
||||||
health = check_tunnel_health(instance.url)
|
# Build response
|
||||||
return health
|
response = {
|
||||||
|
"healthy": False,
|
||||||
|
"container_status": container_info["status"],
|
||||||
|
"container_health": container_info["health"],
|
||||||
|
"tunnel_status": "not_applicable",
|
||||||
|
"tunnel_status_code": None,
|
||||||
|
"probe_status": "not_applicable",
|
||||||
|
"last_probe_output": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine probe status
|
||||||
|
if instance.status == "probing":
|
||||||
|
response["probe_status"] = "pending"
|
||||||
|
elif instance.probe_result:
|
||||||
|
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
|
||||||
|
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))[:500]
|
||||||
|
|
||||||
|
# Check tunnel health if instance has a URL and is web-enabled
|
||||||
|
if instance.url and instance.status in ("running", "unhealthy"):
|
||||||
|
tunnel_health = check_tunnel_health(instance.url)
|
||||||
|
response["tunnel_status"] = tunnel_health["tunnel_status"]
|
||||||
|
response["tunnel_status_code"] = tunnel_health.get("status_code")
|
||||||
|
if tunnel_health.get("error"):
|
||||||
|
response["error"] = tunnel_health["error"]
|
||||||
|
|
||||||
|
# Overall healthy only if container is running AND tunnel is healthy
|
||||||
|
container_healthy = container_info["status"] == "running"
|
||||||
|
tunnel_healthy = response["tunnel_status"] == "healthy"
|
||||||
|
response["healthy"] = container_healthy and tunnel_healthy
|
||||||
|
|
||||||
|
# If container is not running, override error message
|
||||||
|
if not container_healthy:
|
||||||
|
response["error"] = f"Container is {container_info['status']}"
|
||||||
|
if container_info["exit_code"] is not None:
|
||||||
|
response["error"] += f" (exit code: {container_info['exit_code']})"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
|
||||||
from sqlalchemy import Uuid as UUID
|
from sqlalchemy import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -62,6 +62,9 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
probe_result: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
|
|||||||
+121
-14
@@ -244,24 +244,94 @@ def connect_container_to_network(container_name: str, network_name: str = "backe
|
|||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def get_container_status(container_id: str) -> str:
|
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||||
"""Get the status of a Docker container.
|
"""Get the status of a Docker container.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
container_id: Docker container ID
|
container_id: Docker container ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Container status string (running, exited, etc.)
|
Dict with 'status' (running, exited, restarting, not_found),
|
||||||
|
'exit_code' (int or None), and 'health' (health status or None)
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
[
|
||||||
|
"docker", "inspect", "-f",
|
||||||
|
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||||
|
container_id,
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode != 0:
|
||||||
return result.stdout.strip()
|
return {"status": "not_found", "exit_code": None, "health": None}
|
||||||
return "unknown"
|
|
||||||
|
parts = result.stdout.strip().split("|")
|
||||||
|
status = parts[0] if parts else "unknown"
|
||||||
|
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||||
|
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||||
|
|
||||||
|
return {"status": status, "exit_code": exit_code, "health": health}
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_container_running(
|
||||||
|
container_id: str, timeout: int = 30, interval: float = 2.0
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Wait for a container to reach the running state.
|
||||||
|
|
||||||
|
Polls docker inspect until the container status is "running" or timeout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
timeout: Maximum seconds to wait
|
||||||
|
interval: Seconds between polls
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||||
|
and 'waited_seconds' (float)
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
info = get_container_status(container_id)
|
||||||
|
|
||||||
|
if info["status"] == "running":
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"exit_code": None,
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info["status"] == "exited":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "exited",
|
||||||
|
"exit_code": info["exit_code"],
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info["status"] == "not_found":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "not_found",
|
||||||
|
"exit_code": None,
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
# Timeout reached
|
||||||
|
info = get_container_status(container_id)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": info["status"],
|
||||||
|
"exit_code": info["exit_code"],
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||||
@@ -424,14 +494,15 @@ def recreate_tunnel(
|
|||||||
|
|
||||||
|
|
||||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||||
"""Check if a tunnel URL is healthy.
|
"""Check if a tunnel URL is healthy with smart error classification.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: The tunnel URL to check
|
url: The tunnel URL to check
|
||||||
timeout: Request timeout in seconds
|
timeout: Request timeout in seconds
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
|
||||||
|
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@@ -444,13 +515,49 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|||||||
timeout=timeout + 5,
|
timeout=timeout + 5,
|
||||||
)
|
)
|
||||||
status_code = int(result.stdout.strip())
|
status_code = int(result.stdout.strip())
|
||||||
|
|
||||||
|
if 200 <= status_code < 400:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "healthy",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": True,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
elif status_code in (502, 503, 504):
|
||||||
|
# Application error, not tunnel error
|
||||||
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Application returned HTTP {status_code}",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"HTTP {status_code}",
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
return {
|
return {
|
||||||
"healthy": 200 <= status_code < 400,
|
"tunnel_status": "unreachable",
|
||||||
"status_code": status_code,
|
|
||||||
}
|
|
||||||
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
|
||||||
return {
|
|
||||||
"healthy": False,
|
|
||||||
"status_code": None,
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": "Tunnel request timed out",
|
||||||
|
}
|
||||||
|
except (ValueError, Exception) as e:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
# Classify connection errors
|
||||||
|
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Tunnel unreachable: {e}",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,12 +121,19 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If branch creation fails
|
RuntimeError: If branch creation fails
|
||||||
"""
|
"""
|
||||||
if base_branch == "HEAD":
|
try:
|
||||||
|
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
||||||
|
except RuntimeError:
|
||||||
|
# No commits yet - empty repository
|
||||||
try:
|
try:
|
||||||
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD")
|
|
||||||
except RuntimeError:
|
|
||||||
_run_git_command(repo_path, "checkout", "--orphan", name)
|
_run_git_command(repo_path, "checkout", "--orphan", name)
|
||||||
return
|
except RuntimeError as e:
|
||||||
|
if "work tree" in str(e).lower():
|
||||||
|
# Bare repository - use symbolic-ref instead
|
||||||
|
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
return
|
||||||
|
|
||||||
_run_git_command(repo_path, "branch", name, base_branch)
|
_run_git_command(repo_path, "branch", name, base_branch)
|
||||||
|
|
||||||
@@ -156,7 +163,14 @@ def checkout_branch(repo_path: str, name: str) -> None:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If checkout fails
|
RuntimeError: If checkout fails
|
||||||
"""
|
"""
|
||||||
_run_git_command(repo_path, "checkout", name)
|
try:
|
||||||
|
_run_git_command(repo_path, "checkout", name)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "work tree" in str(e).lower():
|
||||||
|
# Bare repository - use symbolic-ref instead
|
||||||
|
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def commit_changes(
|
def commit_changes(
|
||||||
@@ -291,6 +305,10 @@ def get_current_branch(repo_path: str) -> str:
|
|||||||
Current branch name
|
Current branch name
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||||
|
if branch != "HEAD":
|
||||||
|
return branch
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
pass
|
||||||
|
|
||||||
|
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ def test_get_current_branch_handles_unborn_main() -> None:
|
|||||||
assert get_current_branch(tmpdir) == "main"
|
assert get_current_branch(tmpdir) == "main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_branch_on_bare_repo_with_no_commits() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||||
|
create_branch(f"{tmpdir}/bare.git", "main")
|
||||||
|
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_branch_on_bare_repo_with_no_commits() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||||
|
checkout_branch(f"{tmpdir}/bare.git", "main")
|
||||||
|
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||||
|
|
||||||
|
|
||||||
class TestBranchOperations:
|
class TestBranchOperations:
|
||||||
"""Tests for branch management functions."""
|
"""Tests for branch management functions."""
|
||||||
|
|
||||||
|
|||||||
Generated
+1371
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Headquarter</title>
|
<title>Headquarter</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,796 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Headquarter - UI Preview</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4f1ea;
|
||||||
|
--panel: #fffef9;
|
||||||
|
--ink: #1d1d1b;
|
||||||
|
--muted: #5f5b55;
|
||||||
|
--brand: #275d4b;
|
||||||
|
--brand-strong: #154236;
|
||||||
|
--border: #d8d0c5;
|
||||||
|
--primary: #275d4b;
|
||||||
|
--primary-fg: #fffef9;
|
||||||
|
--color-primary: #275d4b;
|
||||||
|
--success: #2f8f62;
|
||||||
|
--success-light: rgba(47, 143, 98, 0.14);
|
||||||
|
--warning: #c08a1e;
|
||||||
|
--warning-light: rgba(192, 138, 30, 0.14);
|
||||||
|
--danger: #b94a3c;
|
||||||
|
--danger-light: rgba(185, 74, 60, 0.14);
|
||||||
|
--info: #4f7fb8;
|
||||||
|
--info-light: rgba(79, 127, 184, 0.14);
|
||||||
|
--space-1: 0.25rem;
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-5: 1.5rem;
|
||||||
|
--space-6: 2rem;
|
||||||
|
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
|
||||||
|
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
|
||||||
|
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
|
||||||
|
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
|
||||||
|
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App Shell */
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
|
backdrop-filter: blur(7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.58rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 230px 1fr;
|
||||||
|
min-height: calc(100vh - 57px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: #ece7df;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: #f7fff7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section-title {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status.running {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-content {
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Common Components */
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-sm {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.58rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover {
|
||||||
|
background: var(--brand-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.58rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Home Page */
|
||||||
|
.home-page {
|
||||||
|
max-width: 1240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-card .card-label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-card .card-value {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section h2,
|
||||||
|
.home-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-session-grid,
|
||||||
|
.home-project-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.running {
|
||||||
|
background: var(--success-light);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.building {
|
||||||
|
background: var(--warning-light);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pending {
|
||||||
|
background: var(--info-light);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-sessions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-form .form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input,
|
||||||
|
.form-field select,
|
||||||
|
.form-field textarea {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Page */
|
||||||
|
.settings-page {
|
||||||
|
max-width: 1240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview Switcher */
|
||||||
|
.preview-switcher {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-switcher button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-switcher button.active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-preview {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-preview.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.shell-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.shell-nav {
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.home-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<header class="shell-header">
|
||||||
|
<a href="#" class="brand">Headquarter</a>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="#" class="user-chip">User</a>
|
||||||
|
<button class="ghost-button">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="shell-body">
|
||||||
|
<aside class="shell-nav" aria-label="Primary navigation">
|
||||||
|
<a href="#" class="nav-item nav-item-active">
|
||||||
|
<span>🏠</span> Home
|
||||||
|
<span class="nav-badge">3</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item">
|
||||||
|
<span>📁</span> Projects
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item">
|
||||||
|
<span>⚙️</span> Settings
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-divider"></div>
|
||||||
|
<div class="nav-section-title">Live sessions</div>
|
||||||
|
<a href="#" class="nav-item session-item">
|
||||||
|
<span class="session-status running"></span>
|
||||||
|
<span>Dev Environment</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item session-item">
|
||||||
|
<span class="session-status running"></span>
|
||||||
|
<span>Jupyter Lab</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item session-item">
|
||||||
|
<span class="session-status"></span>
|
||||||
|
<span>Code Server</span>
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="shell-content">
|
||||||
|
<!-- HOME PAGE PREVIEW -->
|
||||||
|
<div id="home-preview" class="page-preview active">
|
||||||
|
<section class="stack home-page">
|
||||||
|
<header class="home-hero card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<p class="eyebrow">Workspace overview</p>
|
||||||
|
<h1>Home</h1>
|
||||||
|
<p class="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||||
|
</div>
|
||||||
|
<div class="home-hero-actions">
|
||||||
|
<button class="primary-button">New Project</button>
|
||||||
|
<button class="secondary-button">Settings</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="home-summary-grid">
|
||||||
|
<article class="card home-summary-card">
|
||||||
|
<p class="card-label">Open sessions</p>
|
||||||
|
<p class="card-value">3</p>
|
||||||
|
</article>
|
||||||
|
<article class="card home-summary-card">
|
||||||
|
<p class="card-label">Projects</p>
|
||||||
|
<p class="card-value">5</p>
|
||||||
|
</article>
|
||||||
|
<article class="card home-summary-card">
|
||||||
|
<p class="card-label">Repositories</p>
|
||||||
|
<p class="card-value">12</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Open sessions</p>
|
||||||
|
<h2>3</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="home-session-grid">
|
||||||
|
<article class="card session-card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<h3>Dev Environment</h3>
|
||||||
|
<span class="status-badge running">running</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Acme Corp · main</p>
|
||||||
|
<p class="muted">VS Code Server</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card session-card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<h3>Jupyter Lab</h3>
|
||||||
|
<span class="status-badge running">running</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Data Science · experiments</p>
|
||||||
|
<p class="muted">Jupyter Notebook</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card session-card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<h3>Database Console</h3>
|
||||||
|
<span class="status-badge building">building</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Backend API · staging</p>
|
||||||
|
<p class="muted">PostgreSQL Client</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Available projects</p>
|
||||||
|
<h2>5</h2>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-button">View all</button>
|
||||||
|
</div>
|
||||||
|
<div class="home-project-grid">
|
||||||
|
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<h3>Acme Corp</h3>
|
||||||
|
<p class="muted">Main product development</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<h3>Data Science</h3>
|
||||||
|
<p class="muted">ML experiments and notebooks</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<h3>Backend API</h3>
|
||||||
|
<p class="muted">REST API services</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Quick create</p>
|
||||||
|
<h2>Start a session</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="stack create-session-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="form-field">
|
||||||
|
Project
|
||||||
|
<select>
|
||||||
|
<option>Select project...</option>
|
||||||
|
<option>Acme Corp</option>
|
||||||
|
<option>Data Science</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Repository
|
||||||
|
<select disabled>
|
||||||
|
<option>Select repository...</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Tool type
|
||||||
|
<select>
|
||||||
|
<option>Select tool...</option>
|
||||||
|
<option>VS Code Server</option>
|
||||||
|
<option>Jupyter Lab</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="form-field">
|
||||||
|
Display name
|
||||||
|
<input type="text" placeholder="My Development Environment">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="primary-button" type="submit">Create Session</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Recent sessions</p>
|
||||||
|
<h2>2</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recent-sessions-list">
|
||||||
|
<article class="recent-session-item">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||||
|
<span class="recent-session-name">Old Dev Box</span>
|
||||||
|
<span class="muted">Acme Corp · VS Code Server</span>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
</article>
|
||||||
|
<article class="recent-session-item">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||||
|
<span class="recent-session-name">ML Training</span>
|
||||||
|
<span class="muted">Data Science · Jupyter Lab</span>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SETTINGS PAGE PREVIEW -->
|
||||||
|
<div id="settings-preview" class="page-preview">
|
||||||
|
<section class="stack settings-page">
|
||||||
|
<header class="settings-header card stack-sm">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Configuration</p>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="settings-tabs" aria-label="Settings sections">
|
||||||
|
<a href="#" class="settings-tab active">General</a>
|
||||||
|
<a href="#" class="settings-tab">SSH Keys</a>
|
||||||
|
<a href="#" class="settings-tab">Tool Types</a>
|
||||||
|
<a href="#" class="settings-tab">Tool Configs</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="settings-panel card">
|
||||||
|
<div class="stack">
|
||||||
|
<h2>General</h2>
|
||||||
|
<label class="form-field">
|
||||||
|
Theme
|
||||||
|
<select>
|
||||||
|
<option>System</option>
|
||||||
|
<option>Light</option>
|
||||||
|
<option>Dark</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Git user name
|
||||||
|
<input type="text" placeholder="Your git commit name" value="John Doe">
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Git user email
|
||||||
|
<input type="email" placeholder="your.email@example.com" value="john@example.com">
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Default editor
|
||||||
|
<input type="text" placeholder="e.g., vscode, vim, cursor" value="vscode">
|
||||||
|
</label>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button class="primary-button">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-switcher">
|
||||||
|
<button class="active" onclick="showPage('home')">Home</button>
|
||||||
|
<button onclick="showPage('settings')">Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showPage(page) {
|
||||||
|
document.querySelectorAll('.page-preview').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.preview-switcher button').forEach(b => b.classList.remove('active'));
|
||||||
|
document.getElementById(page + '-preview').classList.add('active');
|
||||||
|
event.target.classList.add('active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import axios from "axios";
|
|
||||||
import {
|
import {
|
||||||
createConfigFolder,
|
createConfigFolder,
|
||||||
deleteConfigFolder,
|
deleteConfigFolder,
|
||||||
@@ -8,8 +7,25 @@ import {
|
|||||||
updateConfigFolder,
|
updateConfigFolder,
|
||||||
} from "../api/config_folders";
|
} from "../api/config_folders";
|
||||||
|
|
||||||
vi.mock("axios");
|
const mockGet = vi.fn();
|
||||||
const mockedAxios = vi.mocked(axios);
|
const mockPost = vi.fn();
|
||||||
|
const mockPut = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
interceptors: {
|
||||||
|
response: {
|
||||||
|
use: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shouldSkipAuthRedirect: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("config_folders API", () => {
|
describe("config_folders API", () => {
|
||||||
describe("listConfigFolders", () => {
|
describe("listConfigFolders", () => {
|
||||||
@@ -19,43 +35,24 @@ describe("config_folders API", () => {
|
|||||||
{
|
{
|
||||||
id: "folder-1",
|
id: "folder-1",
|
||||||
name: "my-dotfiles",
|
name: "my-dotfiles",
|
||||||
|
description: "My personal config files",
|
||||||
mount_path: "/home/user",
|
mount_path: "/home/user",
|
||||||
files: {
|
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
||||||
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"",
|
project_overrides: {},
|
||||||
},
|
|
||||||
project_overrides: {
|
|
||||||
"proj-1": {
|
|
||||||
mount_path: "/workspace",
|
|
||||||
files: { ".zshrc": "different content" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
user_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
mockedAxios.get.mockResolvedValue(mockResponse);
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await listConfigFolders();
|
const result = await listConfigFolders();
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].name).toBe("my-dotfiles");
|
expect(result[0].name).toBe("my-dotfiles");
|
||||||
expect(result[0].files).toEqual({
|
expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" });
|
||||||
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"",
|
expect(mockGet).toHaveBeenCalledWith("/config-folders");
|
||||||
});
|
|
||||||
expect(result[0].project_overrides).toEqual({
|
|
||||||
"proj-1": {
|
|
||||||
mount_path: "/workspace",
|
|
||||||
files: { ".zshrc": "different content" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array when no folders", async () => {
|
|
||||||
mockedAxios.get.mockResolvedValue({ data: [] });
|
|
||||||
|
|
||||||
const result = await listConfigFolders();
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,29 +60,30 @@ describe("config_folders API", () => {
|
|||||||
it("creates folder with files", async () => {
|
it("creates folder with files", async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
data: {
|
data: {
|
||||||
id: "new-folder",
|
id: "folder-new",
|
||||||
name: "my-configs",
|
name: "new-folder",
|
||||||
mount_path: "/home/user",
|
mount_path: "/workspace",
|
||||||
files: { "test.txt": "hello" },
|
files: { ".env": "API_URL=http://localhost" },
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
user_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
mockedAxios.post.mockResolvedValue(mockResponse);
|
mockPost.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await createConfigFolder({
|
const result = await createConfigFolder({
|
||||||
name: "my-configs",
|
name: "new-folder",
|
||||||
mount_path: "/home/user",
|
mount_path: "/workspace",
|
||||||
files: { "test.txt": "hello" },
|
files: { ".env": "API_URL=http://localhost" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.name).toBe("my-configs");
|
expect(result.name).toBe("new-folder");
|
||||||
expect(result.files).toEqual({ "test.txt": "hello" });
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
||||||
"/config-folders",
|
"/config-folders",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: "my-configs",
|
name: "new-folder",
|
||||||
mount_path: "/home/user",
|
mount_path: "/workspace",
|
||||||
files: { "test.txt": "hello" },
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -96,52 +94,38 @@ describe("config_folders API", () => {
|
|||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
data: {
|
data: {
|
||||||
id: "folder-1",
|
id: "folder-1",
|
||||||
name: "updated-name",
|
name: "updated-folder",
|
||||||
files: { "new.txt": "content" },
|
mount_path: "/home/user",
|
||||||
|
files: { ".bashrc": "alias ll='ls -la'" },
|
||||||
|
is_active: true,
|
||||||
|
user_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
mockedAxios.put.mockResolvedValue(mockResponse);
|
mockPut.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await updateConfigFolder("folder-1", {
|
const result = await updateConfigFolder("folder-1", {
|
||||||
name: "updated-name",
|
files: { ".bashrc": "alias ll='ls -la'" },
|
||||||
files: { "new.txt": "content" },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.name).toBe("updated-name");
|
expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" });
|
||||||
expect(mockedAxios.put).toHaveBeenCalledWith(
|
expect(mockPut).toHaveBeenCalledWith(
|
||||||
"/config-folders/folder-1",
|
"/config-folders/folder-1",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: "updated-name",
|
files: { ".bashrc": "alias ll='ls -la'" },
|
||||||
files: { "new.txt": "content" },
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates folder activation status", async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
id: "folder-1",
|
|
||||||
name: "my-configs",
|
|
||||||
is_active: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
mockedAxios.put.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const result = await updateConfigFolder("folder-1", {
|
|
||||||
is_active: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.is_active).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteConfigFolder", () => {
|
describe("deleteConfigFolder", () => {
|
||||||
it("deletes folder", async () => {
|
it("deletes folder", async () => {
|
||||||
mockedAxios.delete.mockResolvedValue({ data: undefined });
|
mockDelete.mockResolvedValue({ data: undefined });
|
||||||
|
|
||||||
await deleteConfigFolder("folder-1");
|
await deleteConfigFolder("folder-1");
|
||||||
|
|
||||||
expect(mockedAxios.delete).toHaveBeenCalledWith("/config-folders/folder-1");
|
expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface Session {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
|
container_status?: string;
|
||||||
|
probe_status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listInstances(
|
export async function listInstances(
|
||||||
@@ -101,11 +103,23 @@ export async function getUserSessions(): Promise<Session[]> {
|
|||||||
return response.data.sessions;
|
return response.data.sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InstanceHealth {
|
||||||
|
healthy: boolean;
|
||||||
|
container_status: string;
|
||||||
|
container_health: string | null;
|
||||||
|
container_exit_code: number | null;
|
||||||
|
tunnel_status: string;
|
||||||
|
tunnel_status_code: number | null;
|
||||||
|
probe_status: string;
|
||||||
|
last_probe_output: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkInstanceHealth(
|
export async function checkInstanceHealth(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string
|
||||||
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
|
): Promise<InstanceHealth> {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import axios from "axios";
|
|
||||||
import {
|
import {
|
||||||
createToolType,
|
createToolType,
|
||||||
deleteToolType,
|
deleteToolType,
|
||||||
@@ -9,8 +8,25 @@ import {
|
|||||||
validateToolType,
|
validateToolType,
|
||||||
} from "../api/tool_types";
|
} from "../api/tool_types";
|
||||||
|
|
||||||
vi.mock("axios");
|
const mockGet = vi.fn();
|
||||||
const mockedAxios = vi.mocked(axios);
|
const mockPost = vi.fn();
|
||||||
|
const mockPut = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
interceptors: {
|
||||||
|
response: {
|
||||||
|
use: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shouldSkipAuthRedirect: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("tool_types API", () => {
|
describe("tool_types API", () => {
|
||||||
describe("listToolTypes", () => {
|
describe("listToolTypes", () => {
|
||||||
@@ -28,10 +44,13 @@ describe("tool_types API", () => {
|
|||||||
timeout: 30,
|
timeout: 30,
|
||||||
interval: 2,
|
interval: 2,
|
||||||
},
|
},
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
mockedAxios.get.mockResolvedValue(mockResponse);
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await listToolTypes();
|
const result = await listToolTypes();
|
||||||
|
|
||||||
@@ -53,10 +72,13 @@ describe("tool_types API", () => {
|
|||||||
definition_type: "compose",
|
definition_type: "compose",
|
||||||
compose_template: "version: '3.8'",
|
compose_template: "version: '3.8'",
|
||||||
dockerfile_template: null,
|
dockerfile_template: null,
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
mockedAxios.get.mockResolvedValue(mockResponse);
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await listToolTypes();
|
const result = await listToolTypes();
|
||||||
|
|
||||||
@@ -73,9 +95,12 @@ describe("tool_types API", () => {
|
|||||||
name: "docker-tool",
|
name: "docker-tool",
|
||||||
definition_type: "dockerfile",
|
definition_type: "dockerfile",
|
||||||
dockerfile_template: "FROM node:18",
|
dockerfile_template: "FROM node:18",
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
mockedAxios.post.mockResolvedValue(mockResponse);
|
mockPost.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await createToolType({
|
const result = await createToolType({
|
||||||
name: "docker-tool",
|
name: "docker-tool",
|
||||||
@@ -87,7 +112,7 @@ describe("tool_types API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.definition_type).toBe("dockerfile");
|
expect(result.definition_type).toBe("dockerfile");
|
||||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
definition_type: "dockerfile",
|
definition_type: "dockerfile",
|
||||||
@@ -106,9 +131,12 @@ describe("tool_types API", () => {
|
|||||||
timeout: 60,
|
timeout: 60,
|
||||||
interval: 3,
|
interval: 3,
|
||||||
},
|
},
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
mockedAxios.post.mockResolvedValue(mockResponse);
|
mockPost.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await createToolType({
|
const result = await createToolType({
|
||||||
name: "probed-tool",
|
name: "probed-tool",
|
||||||
@@ -132,30 +160,25 @@ describe("tool_types API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("validateToolType", () => {
|
describe("validateToolType", () => {
|
||||||
it("validates compose template", async () => {
|
it("validates tool type by id", async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
data: { valid: true, errors: [] },
|
data: { valid: true, errors: [] },
|
||||||
};
|
};
|
||||||
mockedAxios.post.mockResolvedValue(mockResponse);
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await validateToolType({
|
const result = await validateToolType("type-1");
|
||||||
definition_type: "compose",
|
|
||||||
compose_template: "version: '3.8'",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
|
expect(mockGet).toHaveBeenCalledWith("/tool-types/type-1/validate");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns validation errors", async () => {
|
it("returns validation errors", async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
data: { valid: false, errors: ["Invalid YAML"] },
|
data: { valid: false, errors: ["Invalid YAML"] },
|
||||||
};
|
};
|
||||||
mockedAxios.post.mockResolvedValue(mockResponse);
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await validateToolType({
|
const result = await validateToolType("type-1");
|
||||||
definition_type: "compose",
|
|
||||||
compose_template: "invalid: yaml: [",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors).toContain("Invalid YAML");
|
expect(result.errors).toContain("Invalid YAML");
|
||||||
@@ -170,9 +193,12 @@ describe("tool_types API", () => {
|
|||||||
name: "updated-tool",
|
name: "updated-tool",
|
||||||
definition_type: "dockerfile",
|
definition_type: "dockerfile",
|
||||||
dockerfile_template: "FROM python:3.11",
|
dockerfile_template: "FROM python:3.11",
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
mockedAxios.put.mockResolvedValue(mockResponse);
|
mockPut.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await updateToolType("type-1", {
|
const result = await updateToolType("type-1", {
|
||||||
definition_type: "dockerfile",
|
definition_type: "dockerfile",
|
||||||
@@ -180,7 +206,7 @@ describe("tool_types API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.definition_type).toBe("dockerfile");
|
expect(result.definition_type).toBe("dockerfile");
|
||||||
expect(mockedAxios.put).toHaveBeenCalledWith(
|
expect(mockPut).toHaveBeenCalledWith(
|
||||||
"/tool-types/type-1",
|
"/tool-types/type-1",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
definition_type: "dockerfile",
|
definition_type: "dockerfile",
|
||||||
@@ -191,11 +217,11 @@ describe("tool_types API", () => {
|
|||||||
|
|
||||||
describe("deleteToolType", () => {
|
describe("deleteToolType", () => {
|
||||||
it("deletes tool type", async () => {
|
it("deletes tool type", async () => {
|
||||||
mockedAxios.delete.mockResolvedValue({ data: undefined });
|
mockDelete.mockResolvedValue({ data: undefined });
|
||||||
|
|
||||||
await deleteToolType("type-1");
|
await deleteToolType("type-1");
|
||||||
|
|
||||||
expect(mockedAxios.delete).toHaveBeenCalledWith("/tool-types/type-1");
|
expect(mockDelete).toHaveBeenCalledWith("/tool-types/type-1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ import { Icon } from "./icon";
|
|||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
||||||
{ to: "/", label: "Dashboard", icon: "dashboard" },
|
{ to: "/", label: "Home", icon: "dashboard" },
|
||||||
{ to: "/sessions", label: "Sessions", icon: "terminal" },
|
|
||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
|
|
||||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||||
];
|
];
|
||||||
@@ -23,9 +21,9 @@ const SessionItem = ({ session }: { session: Session }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={session.url || "#"}
|
href={session.url ?? `/projects/${session.project_id}`}
|
||||||
target="_blank"
|
target={session.url ? "_blank" : undefined}
|
||||||
rel="noopener noreferrer"
|
rel={session.url ? "noopener noreferrer" : undefined}
|
||||||
className="nav-item session-item"
|
className="nav-item session-item"
|
||||||
title={`${session.display_name} (${session.status})`}
|
title={`${session.display_name} (${session.status})`}
|
||||||
>
|
>
|
||||||
@@ -85,7 +83,7 @@ export const AppShell = () => {
|
|||||||
<div className="shell-body">
|
<div className="shell-body">
|
||||||
<aside className="shell-nav" aria-label="Primary navigation">
|
<aside className="shell-nav" aria-label="Primary navigation">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const isSessions = item.to === "/sessions";
|
const isHome = item.to === "/";
|
||||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -96,7 +94,7 @@ export const AppShell = () => {
|
|||||||
>
|
>
|
||||||
<Icon name={item.icon} size="sm" />
|
<Icon name={item.icon} size="sm" />
|
||||||
{item.label}
|
{item.label}
|
||||||
{isSessions && activeCount > 0 && (
|
{isHome && activeCount > 0 && (
|
||||||
<span className="nav-badge">{activeCount}</span>
|
<span className="nav-badge">{activeCount}</span>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -106,7 +104,7 @@ export const AppShell = () => {
|
|||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="nav-divider" />
|
<div className="nav-divider" />
|
||||||
<div className="nav-section-title">Sessions</div>
|
<div className="nav-section-title">Live sessions</div>
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<SessionItem key={session.id} session={session} />
|
<SessionItem key={session.id} session={session} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,54 +1,81 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { DashboardPage } from "./dashboard";
|
import { HomePage } from "./dashboard";
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
const mockDashboard = vi.fn();
|
||||||
|
const mockSessions = vi.fn();
|
||||||
|
const mockProjects = vi.fn();
|
||||||
|
const mockRepos = vi.fn();
|
||||||
|
|
||||||
vi.mock("../api/dashboard", () => ({
|
vi.mock("../api/dashboard", () => ({
|
||||||
getDashboardSummary: (...args: unknown[]) => mockGet(...args)
|
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("DashboardPage", () => {
|
vi.mock("../api/sessions", () => ({
|
||||||
|
getUserSessions: (...args: unknown[]) => mockSessions(...args),
|
||||||
|
createInstance: vi.fn(),
|
||||||
|
startInstance: vi.fn(),
|
||||||
|
stopInstance: vi.fn(),
|
||||||
|
deleteInstance: vi.fn(),
|
||||||
|
recreateInstanceTunnel: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/projects", () => ({
|
||||||
|
listProjects: (...args: unknown[]) => mockProjects(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/git_repositories", () => ({
|
||||||
|
listRepositories: (...args: unknown[]) => mockRepos(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/tool_types", () => ({
|
||||||
|
listToolTypes: vi.fn().mockResolvedValue([])
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("HomePage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGet.mockReset();
|
mockDashboard.mockReset();
|
||||||
|
mockSessions.mockReset();
|
||||||
|
mockProjects.mockReset();
|
||||||
|
mockRepos.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows loading then empty state when summary has no data", async () => {
|
it("shows overview sections", async () => {
|
||||||
mockGet.mockResolvedValue({
|
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
|
||||||
projects: 0,
|
mockSessions.mockResolvedValue([]);
|
||||||
repositories: 0,
|
mockProjects.mockResolvedValue([]);
|
||||||
sshKeys: 0,
|
mockRepos.mockResolvedValue([]);
|
||||||
recentActivity: []
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
|
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("No activity yet")).toBeInTheDocument();
|
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("Available projects")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows retry action when summary request fails", async () => {
|
it("shows retry action when home load fails", async () => {
|
||||||
mockGet.mockRejectedValueOnce(new Error("failed"));
|
mockDashboard.mockRejectedValueOnce(new Error("failed"));
|
||||||
mockGet.mockResolvedValueOnce({
|
mockSessions.mockRejectedValueOnce(new Error("failed"));
|
||||||
projects: 2,
|
mockProjects.mockRejectedValueOnce(new Error("failed"));
|
||||||
repositories: 5,
|
|
||||||
sshKeys: 1,
|
|
||||||
recentActivity: ["Created repo"]
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Dashboard is unavailable")).toBeInTheDocument();
|
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("2")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,83 +1,338 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||||
|
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
|
||||||
|
import { listProjects } from "../api/projects";
|
||||||
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
|
import { updateUserConfig } from "../api/settings";
|
||||||
|
import type { Project } from "../types";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
const CARDS = [
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
const summaryCards = [
|
||||||
|
{ label: "Open sessions", key: "openSessions" },
|
||||||
{ label: "Projects", key: "projects" },
|
{ label: "Projects", key: "projects" },
|
||||||
{ label: "Repositories", key: "repositories" },
|
{ label: "Repositories", key: "repositories" },
|
||||||
{ label: "SSH Keys", key: "sshKeys" }
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type DashboardStatus = "loading" | "ready" | "error";
|
type SessionView = SessionApi;
|
||||||
|
|
||||||
export const DashboardPage = () => {
|
export const HomePage = () => {
|
||||||
const [status, setStatus] = useState<DashboardStatus>("loading");
|
const navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||||
|
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState("");
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||||
|
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||||
|
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||||
|
|
||||||
const loadSummary = useCallback(async () => {
|
const loadHome = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
const data = await getDashboardSummary();
|
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
|
||||||
setSummary(data);
|
getDashboardSummary(),
|
||||||
|
getUserSessions(),
|
||||||
|
listProjects(),
|
||||||
|
listToolTypes(),
|
||||||
|
]);
|
||||||
|
setSummary(dashboard);
|
||||||
|
setSessions(sessionData as SessionView[]);
|
||||||
|
setProjects(projectData);
|
||||||
|
setToolTypes(toolTypeData);
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setSummary(null);
|
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSummary();
|
void loadHome();
|
||||||
}, [loadSummary]);
|
}, [loadHome]);
|
||||||
|
|
||||||
const cards = useMemo(() => CARDS, []);
|
useEffect(() => {
|
||||||
const isEmpty =
|
if (!selectedProject) {
|
||||||
status === "ready" &&
|
setRepositories([]);
|
||||||
summary !== null &&
|
return;
|
||||||
summary.projects === 0 &&
|
}
|
||||||
summary.repositories === 0 &&
|
|
||||||
summary.sshKeys === 0 &&
|
const loadRepos = async () => {
|
||||||
summary.recentActivity.length === 0;
|
try {
|
||||||
|
const data = await listRepositories(selectedProject);
|
||||||
|
setRepositories(data);
|
||||||
|
} catch {
|
||||||
|
setRepositories([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadRepos();
|
||||||
|
}, [selectedProject]);
|
||||||
|
|
||||||
|
const activeSessions = useMemo(
|
||||||
|
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
|
||||||
|
[safeSessions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentSessions = useMemo(
|
||||||
|
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
|
||||||
|
[safeSessions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||||
|
|
||||||
|
setSaveState("saving");
|
||||||
|
try {
|
||||||
|
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
|
||||||
|
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||||
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
|
setDisplayName("");
|
||||||
|
setSelectedProject("");
|
||||||
|
setSelectedRepo("");
|
||||||
|
setSelectedToolType("");
|
||||||
|
setSaveState("idle");
|
||||||
|
await loadHome();
|
||||||
|
} catch {
|
||||||
|
setSaveState("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (session: SessionView) => {
|
||||||
|
if (session.url) {
|
||||||
|
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.tool_type_interfaces.includes("terminal")) {
|
||||||
|
navigate(`/instances/${session.id}/terminal`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/projects/${session.project_id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async (session: SessionView) => {
|
||||||
|
setActionBusy(session.id);
|
||||||
|
try {
|
||||||
|
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
await loadHome();
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (session: SessionView) => {
|
||||||
|
setActionBusy(session.id);
|
||||||
|
try {
|
||||||
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
await loadHome();
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecreateTunnel = async (session: SessionView) => {
|
||||||
|
setActionBusy(session.id);
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||||
|
await loadHome();
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack home-page">
|
||||||
<h1>Dashboard</h1>
|
<header className="home-hero card">
|
||||||
<p className="muted">Your workspace overview will appear here.</p>
|
<div className="stack-sm">
|
||||||
|
<p className="eyebrow">Workspace overview</p>
|
||||||
|
<h1>Home</h1>
|
||||||
|
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||||
|
</div>
|
||||||
|
<div className="home-hero-actions">
|
||||||
|
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading dashboard...</p>}
|
{status === "loading" && <p className="muted">Loading overview...</p>}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="card stack">
|
<div className="card stack">
|
||||||
<p>Dashboard is unavailable</p>
|
<p>Unable to load your workspace overview.</p>
|
||||||
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
|
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
||||||
<Icon name="refresh" size="sm" />
|
<Icon name="refresh" size="sm" />
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card-grid">
|
{status === "ready" && summary && (
|
||||||
{cards.map((card) => (
|
<>
|
||||||
<article className="card" key={card.label}>
|
<div className="home-summary-grid">
|
||||||
<p className="card-label">{card.label}</p>
|
{summaryCards.map((card) => (
|
||||||
<p className="card-value">{summary ? String(summary[card.key]) : "-"}</p>
|
<article className="card home-summary-card" key={card.label}>
|
||||||
</article>
|
<p className="card-label">{card.label}</p>
|
||||||
))}
|
<p className="card-value">
|
||||||
</div>
|
{card.key === "openSessions"
|
||||||
|
? activeSessions.length
|
||||||
|
: card.key === "projects"
|
||||||
|
? summary.projects
|
||||||
|
: summary.repositories}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isEmpty && <p className="muted">No activity yet</p>}
|
<section className="card stack home-section">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Open sessions</p>
|
||||||
|
<h2>{activeSessions.length}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activeSessions.length === 0 ? (
|
||||||
|
<p className="muted">No active sessions right now.</p>
|
||||||
|
) : (
|
||||||
|
<div className="home-session-grid">
|
||||||
|
{activeSessions.map((session) => (
|
||||||
|
<article className="card session-card" key={session.id}>
|
||||||
|
<div className="stack-sm">
|
||||||
|
<div className="row row-tight">
|
||||||
|
<h3>{session.display_name}</h3>
|
||||||
|
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
||||||
|
<p className="muted">{session.tool_type_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="session-actions">
|
||||||
|
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Tunnel
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="quick-actions">
|
<section className="card stack home-section">
|
||||||
<button className="primary-button" type="button">
|
<div className="page-header">
|
||||||
<Icon name="add" size="sm" />
|
<div>
|
||||||
New Project
|
<p className="eyebrow">Available projects</p>
|
||||||
</button>
|
<h2>{projects.length}</h2>
|
||||||
<button className="secondary-button" type="button">
|
</div>
|
||||||
<Icon name="add" size="sm" />
|
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||||
Add Repository
|
</div>
|
||||||
</button>
|
{projects.length === 0 ? (
|
||||||
</div>
|
<p className="muted">No projects yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="home-project-grid">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<article className="card project-card home-project-card" key={project.id}>
|
||||||
|
<div className="stack-sm">
|
||||||
|
<h3>{project.name}</h3>
|
||||||
|
{project.description && <p className="muted">{project.description}</p>}
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
|
||||||
|
Open Workspace
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card stack home-section">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Quick create</p>
|
||||||
|
<h2>Start a session</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="stack create-session-form" onSubmit={handleCreate}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
Project
|
||||||
|
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
|
||||||
|
<option value="">Select repository...</option>
|
||||||
|
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Tool type
|
||||||
|
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
|
||||||
|
<option value="">Select tool...</option>
|
||||||
|
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="form-field">
|
||||||
|
Display name
|
||||||
|
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
|
||||||
|
</label>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
|
||||||
|
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
|
||||||
|
</button>
|
||||||
|
{saveState === "error" && <span className="error-text">Failed to create session</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{recentSessions.length > 0 && (
|
||||||
|
<section className="card stack home-section">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Recent sessions</p>
|
||||||
|
<h2>{recentSessions.length}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="recent-sessions-list">
|
||||||
|
{recentSessions.map((session) => (
|
||||||
|
<article className="recent-session-item" key={session.id}>
|
||||||
|
<div className="recent-session-info">
|
||||||
|
<span className="recent-session-name">{session.display_name}</span>
|
||||||
|
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { HomePage as DashboardPage };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ProjectsPage } from "./projects";
|
import { ProjectsPage } from "./projects";
|
||||||
@@ -29,13 +30,21 @@ afterEach(() => {
|
|||||||
describe("ProjectsPage", () => {
|
describe("ProjectsPage", () => {
|
||||||
it("renders loading state initially", () => {
|
it("renders loading state initially", () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders project list after loading", async () => {
|
it("renders project list after loading", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
@@ -46,7 +55,11 @@ describe("ProjectsPage", () => {
|
|||||||
|
|
||||||
it("renders empty state when no projects", async () => {
|
it("renders empty state when no projects", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -55,7 +68,11 @@ describe("ProjectsPage", () => {
|
|||||||
|
|
||||||
it("renders error state with retry button", async () => {
|
it("renders error state with retry button", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
||||||
@@ -67,7 +84,11 @@ describe("ProjectsPage", () => {
|
|||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -96,7 +117,11 @@ describe("ProjectsPage", () => {
|
|||||||
it("shows validation error when name is empty", async () => {
|
it("shows validation error when name is empty", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -112,7 +137,11 @@ describe("ProjectsPage", () => {
|
|||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
@@ -141,7 +170,11 @@ describe("ProjectsPage", () => {
|
|||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -40,8 +40,18 @@ export const SessionsPage = () => {
|
|||||||
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, { healthy: boolean; status_code: number | null; error?: string }>>({});
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, {
|
||||||
|
healthy: boolean;
|
||||||
|
container_status: string;
|
||||||
|
container_health: string | null;
|
||||||
|
tunnel_status: string;
|
||||||
|
tunnel_status_code: number | null;
|
||||||
|
probe_status: string;
|
||||||
|
last_probe_output: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}>>({});
|
||||||
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
const [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");
|
||||||
@@ -86,13 +96,13 @@ export const SessionsPage = () => {
|
|||||||
void loadToolTypes();
|
void loadToolTypes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Poll tunnel health every 30 seconds for running instances
|
// Poll health every 30 seconds for active instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkHealth = async () => {
|
const checkHealth = async () => {
|
||||||
const runningSessions = sessions.filter(
|
const activeSessions = sessions.filter(
|
||||||
(s) => s.status === "running" && s.url
|
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
|
||||||
);
|
);
|
||||||
for (const session of runningSessions) {
|
for (const session of activeSessions) {
|
||||||
try {
|
try {
|
||||||
const health = await checkInstanceHealth(
|
const health = await checkInstanceHealth(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -106,7 +116,16 @@ export const SessionsPage = () => {
|
|||||||
} catch {
|
} catch {
|
||||||
setTunnelHealth((prev) => ({
|
setTunnelHealth((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[session.id]: { healthy: false, status_code: null, error: "check failed" },
|
[session.id]: {
|
||||||
|
healthy: false,
|
||||||
|
container_status: "unknown",
|
||||||
|
container_health: null,
|
||||||
|
tunnel_status: "unreachable",
|
||||||
|
tunnel_status_code: null,
|
||||||
|
probe_status: "unknown",
|
||||||
|
last_probe_output: null,
|
||||||
|
error: "check failed",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +154,7 @@ export const SessionsPage = () => {
|
|||||||
}, [selectedProject]);
|
}, [selectedProject]);
|
||||||
|
|
||||||
const activeSessions = useMemo(
|
const activeSessions = useMemo(
|
||||||
() => sessions.filter((s) => ["running", "building", "pending"].includes(s.status)),
|
() => sessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)),
|
||||||
[sessions]
|
[sessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -328,9 +347,35 @@ export const SessionsPage = () => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||||
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
|
{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>
|
<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>
|
||||||
<div className="session-actions">
|
<div className="session-actions">
|
||||||
{session.url ? (
|
{session.url ? (
|
||||||
@@ -353,7 +398,7 @@ export const SessionsPage = () => {
|
|||||||
Open
|
Open
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
|
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
onClick={() => void handleRecreateTunnel(session)}
|
onClick={() => void handleRecreateTunnel(session)}
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||||
|
|
||||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
type SettingsStatus = "loading" | "ready" | "error";
|
type SettingsStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ label: "General", path: "general" },
|
||||||
|
{ label: "SSH Keys", path: "ssh-keys" },
|
||||||
|
{ label: "Tool Types", path: "tool-types" },
|
||||||
|
{ label: "Tool Configs", path: "tool-configs" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "system", label: "System" },
|
{ value: "system", label: "System" },
|
||||||
{ value: "light", label: "Light" },
|
{ value: "light", label: "Light" },
|
||||||
{ value: "dark", label: "Dark" },
|
{ value: "dark", label: "Dark" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type SettingsOutletContext = {
|
||||||
|
config: UserConfig;
|
||||||
|
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
|
||||||
|
handleSave: () => Promise<void>;
|
||||||
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
|
};
|
||||||
|
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
|
const location = useLocation();
|
||||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
const [status, setStatus] = useState<SettingsStatus>("loading");
|
||||||
const [config, setConfig] = useState<UserConfig>({
|
const [config, setConfig] = useState<UserConfig>({
|
||||||
theme: "system",
|
theme: "system",
|
||||||
@@ -50,21 +66,15 @@ export const SettingsPage = () => {
|
|||||||
git_user_name: config.git_user_name,
|
git_user_name: config.git_user_name,
|
||||||
git_user_email: config.git_user_email,
|
git_user_email: config.git_user_email,
|
||||||
};
|
};
|
||||||
console.log("Sending update:", update);
|
|
||||||
const updated = await updateUserConfig(update);
|
const updated = await updateUserConfig(update);
|
||||||
console.log("Received response:", updated);
|
|
||||||
setConfig(updated);
|
setConfig(updated);
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
|
if (updated.theme === "system") {
|
||||||
// Apply theme immediately
|
|
||||||
const theme = updated.theme ?? "system";
|
|
||||||
if (theme === "system") {
|
|
||||||
document.documentElement.removeAttribute("data-theme");
|
document.documentElement.removeAttribute("data-theme");
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
document.documentElement.setAttribute("data-theme", updated.theme);
|
||||||
}
|
}
|
||||||
|
window.setTimeout(() => setSaveStatus("idle"), 2000);
|
||||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
|
||||||
} catch {
|
} catch {
|
||||||
setSaveStatus("error");
|
setSaveStatus("error");
|
||||||
}
|
}
|
||||||
@@ -86,81 +96,71 @@ export const SettingsPage = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parts = location.pathname.split("/").filter(Boolean);
|
||||||
|
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack settings-page">
|
||||||
<div className="page-header">
|
<header className="settings-header card stack-sm">
|
||||||
<h1>Settings</h1>
|
<div>
|
||||||
</div>
|
<p className="eyebrow">Configuration</p>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</div>
|
||||||
|
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="card stack">
|
<nav className="settings-tabs" aria-label="Settings sections">
|
||||||
<h2>Appearance</h2>
|
{TABS.map((tab) => (
|
||||||
<label className="form-field">
|
<Link
|
||||||
Theme
|
key={tab.path}
|
||||||
<select
|
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
|
||||||
value={config.theme}
|
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
|
||||||
onChange={(e) => handleChange("theme", e.target.value)}
|
|
||||||
>
|
>
|
||||||
{THEME_OPTIONS.map((opt) => (
|
{tab.label}
|
||||||
<option key={opt.value} value={opt.value}>
|
</Link>
|
||||||
{opt.label}
|
))}
|
||||||
</option>
|
</nav>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card stack">
|
<div className="settings-panel card">
|
||||||
<h2>Git Identity</h2>
|
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
|
||||||
<label className="form-field">
|
|
||||||
User Name
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.git_user_name ?? ""}
|
|
||||||
onChange={(e) => handleChange("git_user_name", e.target.value || null)}
|
|
||||||
placeholder="Your git commit name"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
User Email
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={config.git_user_email ?? ""}
|
|
||||||
onChange={(e) => handleChange("git_user_email", e.target.value || null)}
|
|
||||||
placeholder="your.email@example.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card stack">
|
|
||||||
<h2>Editor</h2>
|
|
||||||
<label className="form-field">
|
|
||||||
Default Editor
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.default_editor ?? ""}
|
|
||||||
onChange={(e) => handleChange("default_editor", e.target.value || null)}
|
|
||||||
placeholder="e.g., vscode, vim, cursor"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-actions">
|
|
||||||
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
|
||||||
{saveStatus === "saving" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="save" size="sm" />
|
|
||||||
Save Settings
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
|
||||||
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const GeneralSettingsTab = () => {
|
||||||
|
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<h2>General</h2>
|
||||||
|
<label className="form-field">
|
||||||
|
Theme
|
||||||
|
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
|
||||||
|
{THEME_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Git user name
|
||||||
|
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Git user email
|
||||||
|
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Default editor
|
||||||
|
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
|
||||||
|
</label>
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
||||||
|
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
|
||||||
|
</button>
|
||||||
|
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
||||||
|
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
export const SSHKeysPage = () => {
|
export const SSHKeysPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -61,7 +63,15 @@ export const SSHKeysPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<h1>SSH Keys</h1>
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Settings</p>
|
||||||
|
<h1>SSH Keys</h1>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||||
|
Back to settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
type ConfigStatus = "loading" | "ready" | "error";
|
type ConfigStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
export const ToolConfigsPage = () => {
|
export const ToolConfigsPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [status, setStatus] = useState<ConfigStatus>("loading");
|
const [status, setStatus] = useState<ConfigStatus>("loading");
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||||
@@ -135,7 +137,13 @@ export const ToolConfigsPage = () => {
|
|||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Tool Configurations</h1>
|
<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">
|
<p className="muted">
|
||||||
Manage environment variables and configuration files for your tools
|
Manage environment variables and configuration files for your tools
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createToolType,
|
createToolType,
|
||||||
@@ -15,6 +16,7 @@ type ToolTypesStatus = "loading" | "ready" | "error";
|
|||||||
type DialogMode = "none" | "create" | "edit";
|
type DialogMode = "none" | "create" | "edit";
|
||||||
|
|
||||||
export const ToolTypesPage = () => {
|
export const ToolTypesPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||||
@@ -165,12 +167,18 @@ export const ToolTypesPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
<div className="page-header" style={{ marginBottom: "1rem" }}>
|
||||||
<h1>Tool Types</h1>
|
<div>
|
||||||
<button onClick={openCreate}>
|
<p className="eyebrow">Settings</p>
|
||||||
|
<h1>Tool Types</h1>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
|
||||||
|
<button onClick={openCreate}>
|
||||||
<Icon name="add" size="sm" />
|
<Icon name="add" size="sm" />
|
||||||
Create Tool Type
|
Create Tool Type
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{toolTypes.length === 0 ? (
|
{toolTypes.length === 0 ? (
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ const mockToolTypes = [
|
|||||||
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",
|
||||||
dockerfile_template: null,
|
dockerfile_template: null,
|
||||||
|
build_context: null,
|
||||||
readiness_probe: null,
|
readiness_probe: null,
|
||||||
required_variables: ["REPO_PATH"],
|
required_variables: ["REPO_PATH"],
|
||||||
is_builtin: true,
|
is_builtin: true,
|
||||||
created_by_id: null,
|
created_by_id: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "type-2",
|
id: "type-2",
|
||||||
@@ -34,6 +37,7 @@ const mockToolTypes = [
|
|||||||
definition_type: "dockerfile",
|
definition_type: "dockerfile",
|
||||||
compose_template: null,
|
compose_template: null,
|
||||||
dockerfile_template: "FROM python:3.11",
|
dockerfile_template: "FROM python:3.11",
|
||||||
|
build_context: null,
|
||||||
readiness_probe: {
|
readiness_probe: {
|
||||||
command: "python --version",
|
command: "python --version",
|
||||||
timeout: 30,
|
timeout: 30,
|
||||||
@@ -42,6 +46,8 @@ const mockToolTypes = [
|
|||||||
required_variables: [],
|
required_variables: [],
|
||||||
is_builtin: false,
|
is_builtin: false,
|
||||||
created_by_id: "user-1",
|
created_by_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,6 +55,7 @@ const mockConfigs = [
|
|||||||
{
|
{
|
||||||
id: "config-1",
|
id: "config-1",
|
||||||
tool_type_id: "type-1",
|
tool_type_id: "type-1",
|
||||||
|
project_id: null,
|
||||||
key: "OPENAI_API_KEY",
|
key: "OPENAI_API_KEY",
|
||||||
value: "sk-test123",
|
value: "sk-test123",
|
||||||
config_type: "env",
|
config_type: "env",
|
||||||
@@ -62,9 +69,11 @@ const mockConfigs = [
|
|||||||
{
|
{
|
||||||
id: "config-2",
|
id: "config-2",
|
||||||
tool_type_id: "type-2",
|
tool_type_id: "type-2",
|
||||||
|
project_id: null,
|
||||||
key: "advanced-config",
|
key: "advanced-config",
|
||||||
value: "test-value",
|
value: "test-value",
|
||||||
config_type: "env",
|
config_type: "env",
|
||||||
|
file_path: null,
|
||||||
port_override: 9090,
|
port_override: 9090,
|
||||||
start_command: "python app.py",
|
start_command: "python app.py",
|
||||||
working_directory: "/app",
|
working_directory: "/app",
|
||||||
@@ -76,15 +85,19 @@ const mockConfigs = [
|
|||||||
const mockFolders = [
|
const mockFolders = [
|
||||||
{
|
{
|
||||||
id: "folder-1",
|
id: "folder-1",
|
||||||
|
user_id: "user-1",
|
||||||
name: "my-dotfiles",
|
name: "my-dotfiles",
|
||||||
description: "My personal config files",
|
description: "My personal config files",
|
||||||
mount_path: "/home/user",
|
mount_path: "/home/user",
|
||||||
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
||||||
project_overrides: {},
|
project_overrides: {},
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "folder-2",
|
id: "folder-2",
|
||||||
|
user_id: "user-1",
|
||||||
name: "project-configs",
|
name: "project-configs",
|
||||||
description: "Project specific configs",
|
description: "Project specific configs",
|
||||||
mount_path: "/workspace",
|
mount_path: "/workspace",
|
||||||
@@ -96,6 +109,8 @@ const mockFolders = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
is_active: false,
|
is_active: false,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -115,9 +130,9 @@ describe("ToolWorkshopPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders tool types tab by default", async () => {
|
it("renders tool types tab by default", async () => {
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -128,9 +143,9 @@ describe("ToolWorkshopPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("switches to configs tab", async () => {
|
it("switches to configs tab", async () => {
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -147,9 +162,9 @@ describe("ToolWorkshopPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("switches to folders tab", async () => {
|
it("switches to folders tab", async () => {
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -166,9 +181,9 @@ describe("ToolWorkshopPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("opens tool type creation form", async () => {
|
it("opens tool type creation form", async () => {
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -178,15 +193,15 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/display name/i)).toBeInTheDocument();
|
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates tool type with compose definition", async () => {
|
it("creates tool type with compose definition", async () => {
|
||||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]);
|
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -196,12 +211,15 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/name/i), {
|
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||||
target: { value: "new-tool" },
|
target: { value: "new-tool" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/display name/i), {
|
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||||
target: { value: "New Tool" },
|
target: { value: "New Tool" },
|
||||||
});
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||||
|
target: { value: "8080" },
|
||||||
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/compose template/i), {
|
fireEvent.change(screen.getByLabelText(/compose template/i), {
|
||||||
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
|
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
|
||||||
});
|
});
|
||||||
@@ -218,14 +236,13 @@ describe("ToolWorkshopPage", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(listMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates tool type with dockerfile definition", async () => {
|
it("creates tool type with dockerfile definition", async () => {
|
||||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]);
|
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -235,17 +252,22 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/name/i), {
|
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||||
target: { value: "docker-tool" },
|
target: { value: "docker-tool" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/display name/i), {
|
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||||
target: { value: "Docker Tool" },
|
target: { value: "Docker Tool" },
|
||||||
});
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||||
|
target: { value: "3000" },
|
||||||
|
});
|
||||||
|
|
||||||
// Switch to dockerfile
|
// Switch to dockerfile
|
||||||
fireEvent.click(screen.getByLabelText(/dockerfile/i));
|
fireEvent.change(screen.getByLabelText("Definition Type"), {
|
||||||
|
target: { value: "dockerfile" },
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/dockerfile template/i), {
|
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
|
||||||
target: { value: "FROM python:3.11\\nRUN pip install flask" },
|
target: { value: "FROM python:3.11\\nRUN pip install flask" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,9 +285,9 @@ describe("ToolWorkshopPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows readiness probe fields", async () => {
|
it("shows readiness probe fields", async () => {
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -275,20 +297,15 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
expect(screen.getByLabelText(/readiness command/i)).toBeInTheDocument();
|
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/timeout/i)).toBeInTheDocument();
|
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/interval/i)).toBeInTheDocument();
|
expect(screen.getByText(/interval/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens config creation form", async () => {
|
it("opens config creation form", async () => {
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
|
|
||||||
tool_type_id: "type-1",
|
|
||||||
suggested_configs: [],
|
|
||||||
port_override: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -309,15 +326,10 @@ describe("ToolWorkshopPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates config with advanced fields", async () => {
|
it("creates config with advanced fields", async () => {
|
||||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1]);
|
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
|
|
||||||
tool_type_id: "type-1",
|
|
||||||
suggested_configs: [],
|
|
||||||
port_override: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -346,7 +358,7 @@ describe("ToolWorkshopPage", () => {
|
|||||||
target: { value: "python app.py" },
|
target: { value: "python app.py" },
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
fireEvent.click(screen.getByRole("button", { name: /add$/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createMock).toHaveBeenCalledWith(
|
expect(createMock).toHaveBeenCalledWith(
|
||||||
@@ -362,9 +374,9 @@ describe("ToolWorkshopPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("opens folder creation form", async () => {
|
it("opens folder creation form", async () => {
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -380,15 +392,15 @@ describe("ToolWorkshopPage", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||||
|
|
||||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/mount path/i)).toBeInTheDocument();
|
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates config folder successfully", async () => {
|
it("creates config folder successfully", async () => {
|
||||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
|
||||||
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0]);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -404,10 +416,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/i), {
|
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||||
target: { value: "new-folder" },
|
target: { value: "new-folder" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/mount path/i), {
|
fireEvent.change(screen.getByLabelText("Mount Path *"), {
|
||||||
target: { value: "/home/dev" },
|
target: { value: "/home/dev" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -425,9 +437,9 @@ describe("ToolWorkshopPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows folder active/inactive status", async () => {
|
it("shows folder active/inactive status", async () => {
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -441,46 +453,8 @@ describe("ToolWorkshopPage", () => {
|
|||||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check that active folder is marked
|
// Check that active folder shows Active badge
|
||||||
const activeFolder = screen.getByText("my-dotfiles").closest("[data-testid='folder-item']") ||
|
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||||
screen.getByText("my-dotfiles").parentElement;
|
|
||||||
expect(activeFolder?.textContent).toContain("active");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("validates tool type before creation", async () => {
|
|
||||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
|
||||||
const validateMock = vi.spyOn(toolTypesApi, "validateToolType").mockResolvedValue({
|
|
||||||
valid: false,
|
|
||||||
errors: ["Invalid YAML syntax"],
|
|
||||||
});
|
|
||||||
|
|
||||||
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/i), {
|
|
||||||
target: { value: "bad-tool" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(/display name/i), {
|
|
||||||
target: { value: "Bad Tool" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(/compose template/i), {
|
|
||||||
target: { value: "invalid: yaml: [" },
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /validate/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(validateMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText(/invalid yaml syntax/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles error state gracefully", async () => {
|
it("handles error state gracefully", async () => {
|
||||||
@@ -500,13 +474,13 @@ describe("ToolWorkshopPage", () => {
|
|||||||
it("retries loading after error", async () => {
|
it("retries loading after error", async () => {
|
||||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
|
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
|
||||||
.mockRejectedValueOnce(new Error("Network error"))
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
.mockResolvedValueOnce(mockToolTypes);
|
.mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs")
|
vi.spyOn(toolConfigsApi, "listToolConfigs")
|
||||||
.mockRejectedValueOnce(new Error("Network error"))
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
.mockResolvedValueOnce(mockConfigs);
|
.mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders")
|
vi.spyOn(configFoldersApi, "listConfigFolders")
|
||||||
.mockRejectedValueOnce(new Error("Network error"))
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
.mockResolvedValueOnce(mockFolders);
|
.mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -523,10 +497,10 @@ describe("ToolWorkshopPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("deletes tool type successfully", async () => {
|
it("deletes tool type successfully", async () => {
|
||||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
|
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
|
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
|
||||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
render(<ToolWorkshopPage />);
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
@@ -535,19 +509,14 @@ describe("ToolWorkshopPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Find and click delete button for custom tool (not built-in)
|
// Find and click delete button for custom tool (not built-in)
|
||||||
const customToolCard = screen.getByText("Custom Tool").closest("[data-testid='tool-type-item']") ||
|
const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
|
||||||
screen.getByText("Custom Tool").parentElement;
|
screen.getByText("Custom Tool").parentElement;
|
||||||
if (customToolCard) {
|
if (customToolCard) {
|
||||||
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
|
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
|
||||||
if (deleteButton) {
|
if (deleteButton) {
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
fireEvent.click(deleteButton);
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
// Confirm deletion
|
|
||||||
const confirmButton = screen.queryByRole("button", { name: /confirm/i });
|
|
||||||
if (confirmButton) {
|
|
||||||
fireEvent.click(confirmButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(deleteMock).toHaveBeenCalledWith("type-2");
|
expect(deleteMock).toHaveBeenCalledWith("type-2");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
deleteToolType,
|
deleteToolType,
|
||||||
listToolTypes,
|
listToolTypes,
|
||||||
updateToolType,
|
updateToolType,
|
||||||
validateToolType,
|
|
||||||
type CreateToolTypeRequest,
|
type CreateToolTypeRequest,
|
||||||
type ReadinessProbe,
|
type ReadinessProbe,
|
||||||
type ToolType,
|
type ToolType,
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
createToolConfig,
|
createToolConfig,
|
||||||
deleteToolConfig,
|
deleteToolConfig,
|
||||||
getToolConfigDefaults,
|
|
||||||
listToolConfigs,
|
listToolConfigs,
|
||||||
updateToolConfig,
|
updateToolConfig,
|
||||||
type CreateToolConfigRequest,
|
type CreateToolConfigRequest,
|
||||||
@@ -605,8 +603,9 @@ export const ToolWorkshopPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Readiness Probe Command</label>
|
<label htmlFor="readiness-command">Readiness Probe Command</label>
|
||||||
<input
|
<input
|
||||||
|
id="readiness-command"
|
||||||
type="text"
|
type="text"
|
||||||
value={toolTypeForm.readiness_command}
|
value={toolTypeForm.readiness_command}
|
||||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })}
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })}
|
||||||
@@ -617,8 +616,9 @@ export const ToolWorkshopPage = () => {
|
|||||||
|
|
||||||
<div className="row" style={{ gap: "1rem" }}>
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
<div className="form-group" style={{ flex: 1 }}>
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
<label>Timeout (seconds)</label>
|
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
|
||||||
<input
|
<input
|
||||||
|
id="readiness-timeout"
|
||||||
type="number"
|
type="number"
|
||||||
value={toolTypeForm.readiness_timeout}
|
value={toolTypeForm.readiness_timeout}
|
||||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })}
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })}
|
||||||
@@ -626,8 +626,9 @@ export const ToolWorkshopPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group" style={{ flex: 1 }}>
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
<label>Interval (seconds)</label>
|
<label htmlFor="readiness-interval">Interval (seconds)</label>
|
||||||
<input
|
<input
|
||||||
|
id="readiness-interval"
|
||||||
type="number"
|
type="number"
|
||||||
value={toolTypeForm.readiness_interval}
|
value={toolTypeForm.readiness_interval}
|
||||||
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })}
|
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })}
|
||||||
@@ -764,8 +765,9 @@ export const ToolWorkshopPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Value</label>
|
<label htmlFor="config-value">Value</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="config-value"
|
||||||
value={configForm.value}
|
value={configForm.value}
|
||||||
onChange={(e) => setConfigForm({ ...configForm, value: e.target.value })}
|
onChange={(e) => setConfigForm({ ...configForm, value: e.target.value })}
|
||||||
placeholder={configForm.config_type === "env" ? "Enter value..." : "Enter file contents..."}
|
placeholder={configForm.config_type === "env" ? "Enter value..." : "Enter file contents..."}
|
||||||
@@ -777,8 +779,9 @@ export const ToolWorkshopPage = () => {
|
|||||||
|
|
||||||
<div className="row" style={{ gap: "1rem" }}>
|
<div className="row" style={{ gap: "1rem" }}>
|
||||||
<div className="form-group" style={{ flex: 1 }}>
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
<label>Port Override</label>
|
<label htmlFor="config-port-override">Port Override</label>
|
||||||
<input
|
<input
|
||||||
|
id="config-port-override"
|
||||||
type="number"
|
type="number"
|
||||||
value={configForm.port_override}
|
value={configForm.port_override}
|
||||||
onChange={(e) => setConfigForm({ ...configForm, port_override: e.target.value })}
|
onChange={(e) => setConfigForm({ ...configForm, port_override: e.target.value })}
|
||||||
@@ -787,8 +790,9 @@ export const ToolWorkshopPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group" style={{ flex: 1 }}>
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
<label>Start Command</label>
|
<label htmlFor="config-start-command">Start Command</label>
|
||||||
<input
|
<input
|
||||||
|
id="config-start-command"
|
||||||
type="text"
|
type="text"
|
||||||
value={configForm.start_command}
|
value={configForm.start_command}
|
||||||
onChange={(e) => setConfigForm({ ...configForm, start_command: e.target.value })}
|
onChange={(e) => setConfigForm({ ...configForm, start_command: e.target.value })}
|
||||||
@@ -918,8 +922,9 @@ export const ToolWorkshopPage = () => {
|
|||||||
<h3>{selectedFolder ? "Edit" : "Create"} Config Folder</h3>
|
<h3>{selectedFolder ? "Edit" : "Create"} Config Folder</h3>
|
||||||
<form onSubmit={handleFolderSubmit} className="stack">
|
<form onSubmit={handleFolderSubmit} className="stack">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Name *</label>
|
<label htmlFor="folder-name">Name *</label>
|
||||||
<input
|
<input
|
||||||
|
id="folder-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={folderForm.name}
|
value={folderForm.name}
|
||||||
onChange={(e) => setFolderForm({ ...folderForm, name: e.target.value })}
|
onChange={(e) => setFolderForm({ ...folderForm, name: e.target.value })}
|
||||||
@@ -930,8 +935,9 @@ export const ToolWorkshopPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Description</label>
|
<label htmlFor="folder-description">Description</label>
|
||||||
<input
|
<input
|
||||||
|
id="folder-description"
|
||||||
type="text"
|
type="text"
|
||||||
value={folderForm.description}
|
value={folderForm.description}
|
||||||
onChange={(e) => setFolderForm({ ...folderForm, description: e.target.value })}
|
onChange={(e) => setFolderForm({ ...folderForm, description: e.target.value })}
|
||||||
@@ -941,8 +947,9 @@ export const ToolWorkshopPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Mount Path *</label>
|
<label htmlFor="folder-mount-path">Mount Path *</label>
|
||||||
<input
|
<input
|
||||||
|
id="folder-mount-path"
|
||||||
type="text"
|
type="text"
|
||||||
value={folderForm.mount_path}
|
value={folderForm.mount_path}
|
||||||
onChange={(e) => setFolderForm({ ...folderForm, mount_path: e.target.value })}
|
onChange={(e) => setFolderForm({ ...folderForm, mount_path: e.target.value })}
|
||||||
@@ -953,8 +960,9 @@ export const ToolWorkshopPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Files (JSON object)</label>
|
<label htmlFor="folder-files">Files (JSON object)</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="folder-files"
|
||||||
value={folderForm.files_json}
|
value={folderForm.files_json}
|
||||||
onChange={(e) => setFolderForm({ ...folderForm, files_json: e.target.value })}
|
onChange={(e) => setFolderForm({ ...folderForm, files_json: e.target.value })}
|
||||||
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'
|
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'
|
||||||
|
|||||||
+18
-10
@@ -2,24 +2,29 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
|||||||
|
|
||||||
import { AppShell } from "./components/app-shell";
|
import { AppShell } from "./components/app-shell";
|
||||||
import { ProtectedRoute } from "./components/protected-route";
|
import { ProtectedRoute } from "./components/protected-route";
|
||||||
import { DashboardPage } from "./pages/dashboard";
|
import { HomePage } from "./pages/dashboard";
|
||||||
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
|
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
|
||||||
import { SessionsPage } from "./pages/sessions";
|
|
||||||
import { ProfilePage } from "./pages/profile";
|
import { ProfilePage } from "./pages/profile";
|
||||||
import { ProjectsPage } from "./pages/projects";
|
import { ProjectsPage } from "./pages/projects";
|
||||||
import { GitRepositoriesPage } from "./pages/git-repositories";
|
import { GitRepositoriesPage } from "./pages/git-repositories";
|
||||||
import { GitHistoryPage } from "./pages/git-history";
|
import { GitHistoryPage } from "./pages/git-history";
|
||||||
import { ProjectSettingsPage } from "./pages/project-settings";
|
import { ProjectSettingsPage } from "./pages/project-settings";
|
||||||
import { RepoWorkspace } from "./pages/repo-workspace";
|
import { RepoWorkspace } from "./pages/repo-workspace";
|
||||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
||||||
import { SettingsPage } from "./pages/settings";
|
|
||||||
import { TerminalPage } from "./pages/terminal";
|
import { TerminalPage } from "./pages/terminal";
|
||||||
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||||
|
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||||
|
import { ToolConfigsPage } from "./pages/tool-configs";
|
||||||
|
import { ToolTypesPage } from "./pages/tool-types";
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginRedirectPage />} />
|
<Route path="/login" element={<LoginRedirectPage />} />
|
||||||
|
<Route path="/sessions" element={<Navigate to="/" replace />} />
|
||||||
|
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
|
||||||
|
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
|
||||||
|
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
@@ -28,19 +33,22 @@ export const AppRouter = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="sessions" element={<SessionsPage />} />
|
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
||||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
||||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
||||||
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
||||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />}>
|
||||||
|
<Route index element={<Navigate to="general" replace />} />
|
||||||
|
<Route path="general" element={<GeneralSettingsTab />} />
|
||||||
|
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||||
|
<Route path="tool-types" element={<ToolTypesPage />} />
|
||||||
|
<Route path="tool-configs" element={<ToolConfigsPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="general" replace />} />
|
||||||
|
</Route>
|
||||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||||
<Route path="tool-types" element={<Navigate to="/tool-workshop" replace />} />
|
|
||||||
<Route path="tool-configs" element={<Navigate to="/tool-workshop" replace />} />
|
|
||||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/404" element={<NotFoundPage />} />
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
|
|||||||
+148
-10
@@ -1,6 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
--bg: #f4f1ea;
|
--bg: #f4f1ea;
|
||||||
--panel: #fffef9;
|
--panel: #fffef9;
|
||||||
--ink: #1d1d1b;
|
--ink: #1d1d1b;
|
||||||
@@ -8,6 +8,17 @@
|
|||||||
--brand: #275d4b;
|
--brand: #275d4b;
|
||||||
--brand-strong: #154236;
|
--brand-strong: #154236;
|
||||||
--border: #d8d0c5;
|
--border: #d8d0c5;
|
||||||
|
--primary: #275d4b;
|
||||||
|
--primary-fg: #fffef9;
|
||||||
|
--color-primary: #275d4b;
|
||||||
|
--success: #2f8f62;
|
||||||
|
--success-light: rgba(47, 143, 98, 0.14);
|
||||||
|
--warning: #c08a1e;
|
||||||
|
--warning-light: rgba(192, 138, 30, 0.14);
|
||||||
|
--danger: #b94a3c;
|
||||||
|
--danger-light: rgba(185, 74, 60, 0.14);
|
||||||
|
--info: #4f7fb8;
|
||||||
|
--info-light: rgba(79, 127, 184, 0.14);
|
||||||
|
|
||||||
/* Spacing Scale (4px base) */
|
/* Spacing Scale (4px base) */
|
||||||
--space-1: 0.25rem;
|
--space-1: 0.25rem;
|
||||||
@@ -36,13 +47,16 @@
|
|||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #1a1a18;
|
--bg: #171613;
|
||||||
--panel: #252522;
|
--panel: #22201d;
|
||||||
--ink: #e8e6e1;
|
--ink: #ece7df;
|
||||||
--muted: #a39e96;
|
--muted: #a59d92;
|
||||||
--brand: #4a9e7f;
|
--brand: #5fa889;
|
||||||
--brand-strong: #3d8a6e;
|
--brand-strong: #4d9175;
|
||||||
--border: #3d3d38;
|
--border: #39342d;
|
||||||
|
--primary: #5fa889;
|
||||||
|
--primary-fg: #171613;
|
||||||
|
--color-primary: #5fa889;
|
||||||
--success: #22c55e;
|
--success: #22c55e;
|
||||||
--success-light: rgba(34, 197, 94, 0.15);
|
--success-light: rgba(34, 197, 94, 0.15);
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
@@ -84,12 +98,12 @@ a {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.85rem 1.25rem;
|
padding: 0.85rem 1.25rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
backdrop-filter: blur(7px);
|
backdrop-filter: blur(7px);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .shell-header {
|
[data-theme="dark"] .shell-header {
|
||||||
background: rgba(37, 37, 34, 0.85);
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -115,6 +129,7 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
@@ -133,11 +148,134 @@ a {
|
|||||||
color: #f7fff7;
|
color: #f7fff7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-section-title {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.shell-content {
|
.shell-content {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-page,
|
||||||
|
.settings-page {
|
||||||
|
max-width: 1240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-grid,
|
||||||
|
.home-project-grid,
|
||||||
|
.home-session-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-grid,
|
||||||
|
.home-session-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section h2,
|
||||||
|
.settings-header h1,
|
||||||
|
.settings-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section h3,
|
||||||
|
.home-section p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-tight {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions,
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
padding: 0.42rem 0.7rem;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card,
|
||||||
|
.project-card,
|
||||||
|
.recent-session-item {
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Shell */
|
/* Responsive Shell */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.shell-body {
|
.shell-body {
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
## Phase 1: Backend Foundation
|
||||||
|
|
||||||
|
### 1.1 Database Migrations
|
||||||
|
- [x] 1.1.1 Create Alembic migration for `tool_types` (add definition_type, dockerfile_template, build_context, readiness_probe)
|
||||||
|
- [x] 1.1.2 Create Alembic migration for `tool_configs` (add port_override, start_command, working_directory, environment_variables, volumes)
|
||||||
|
- [x] 1.1.3 Create Alembic migration for `config_folders` (new table)
|
||||||
|
- [x] 1.1.4 Add CHECK constraints (definition_type enum, port range)
|
||||||
|
- [x] 1.1.5 Add indexes for config_folders
|
||||||
|
- [x] 1.1.6 Run migrations locally and verify with test data
|
||||||
|
|
||||||
|
### 1.2 Model Updates
|
||||||
|
- [x] 1.2.1 Update `ToolType` model with new fields
|
||||||
|
- [x] 1.2.2 Update `ToolConfig` model with new fields
|
||||||
|
- [x] 1.2.3 Create `ConfigFolder` model
|
||||||
|
- [x] 1.2.4 Add Pydantic schemas for ConfigFolder (create, update, response)
|
||||||
|
- [x] 1.2.5 Update Pydantic schemas for ToolType (add new fields)
|
||||||
|
- [x] 1.2.6 Update Pydantic schemas for ToolConfig (add new fields)
|
||||||
|
- [x] 1.2.7 Add validation schemas (port range, JSON structure, definition_type enum)
|
||||||
|
|
||||||
|
### 1.3 Config Folder API
|
||||||
|
- [x] 1.3.1 Create `api/config_folders.py` router
|
||||||
|
- [x] 1.3.2 Implement `GET /config-folders` (list with filtering by project)
|
||||||
|
- [x] 1.3.3 Implement `POST /config-folders` (create)
|
||||||
|
- [x] 1.3.4 Implement `PUT /config-folders/{id}` (update name, mount_path, files)
|
||||||
|
- [x] 1.3.5 Implement `DELETE /config-folders/{id}` (delete)
|
||||||
|
- [x] 1.3.6 Implement `POST /config-folders/{id}/overrides` (add project override)
|
||||||
|
- [x] 1.3.7 Implement `PUT /config-folders/{id}/overrides/{project_id}` (update override)
|
||||||
|
- [x] 1.3.8 Implement `DELETE /config-folders/{id}/overrides/{project_id}` (remove override)
|
||||||
|
- [x] 1.3.9 Add validation: 10MB size limit per folder
|
||||||
|
- [x] 1.3.10 Add ownership checks (user can only access own folders)
|
||||||
|
|
||||||
|
### 1.4 Tool Type API Updates
|
||||||
|
- [x] 1.4.1 Update `POST /tool-types` to accept definition_type, dockerfile_template, build_context, readiness_probe
|
||||||
|
- [x] 1.4.2 Update `PUT /tool-types/{id}` to handle new fields
|
||||||
|
- [x] 1.4.3 Add `GET /tool-types/{id}/validate` endpoint (syntax validation)
|
||||||
|
- [x] 1.4.4 Update tool type response schemas
|
||||||
|
- [x] 1.4.5 Update seed function to set definition_type="compose" for built-in types
|
||||||
|
|
||||||
|
### 1.5 Tool Config API Updates
|
||||||
|
- [x] 1.5.1 Update `POST /tool-configs` to accept new fields
|
||||||
|
- [x] 1.5.2 Update `PUT /tool-configs/{id}` to handle new fields
|
||||||
|
- [x] 1.5.3 Update `GET /tool-configs` to return new fields
|
||||||
|
- [x] 1.5.4 Add `GET /tool-configs/defaults/{tool_type_id}` endpoint
|
||||||
|
- [x] 1.5.5 Add validation for port_override range
|
||||||
|
- [x] 1.5.6 Add validation for environment_variables JSON structure
|
||||||
|
- [x] 1.5.7 Add validation for volumes JSON structure (source/target/type)
|
||||||
|
|
||||||
|
## Phase 2: Instance Creation Enhancement
|
||||||
|
|
||||||
|
### 2.1 Docker Build Service
|
||||||
|
- [x] 2.1.1 Create `services/docker_build.py` for Dockerfile builds
|
||||||
|
- [x] 2.1.2 Implement `build_image(instance_dir, dockerfile, tag)` function
|
||||||
|
- [x] 2.1.3 Handle build context file writing
|
||||||
|
- [x] 2.1.4 Add build output streaming/logging
|
||||||
|
- [x] 2.1.5 Handle build failures with clear error messages
|
||||||
|
|
||||||
|
### 2.2 Compose Generation for Dockerfile Tools
|
||||||
|
- [x] 2.2.1 Create compose template for dockerfile-built images
|
||||||
|
- [x] 2.2.2 Integrate build service into instance creation flow
|
||||||
|
- [x] 2.2.3 Update `render_compose_template` to handle both paths
|
||||||
|
|
||||||
|
### 2.3 Config Folder Mounting
|
||||||
|
- [x] 2.3.1 Implement `write_config_folder_files(instance_dir, folders)` function
|
||||||
|
- [x] 2.3.2 Resolve config folders for user + project
|
||||||
|
- [x] 2.3.3 Generate volume mounts in compose file for config folders
|
||||||
|
- [x] 2.3.4 Apply project overrides during resolution
|
||||||
|
- [x] 2.3.5 Write config folder files to `instance_dir/volumes/`
|
||||||
|
|
||||||
|
### 2.4 Readiness Probe Service
|
||||||
|
- [x] 2.4.1 Create `services/readiness_probe.py`
|
||||||
|
- [x] 2.4.2 Implement `execute_probe(container_id, probe_config)` function
|
||||||
|
- [x] 2.4.3 Implement polling loop with timeout and interval
|
||||||
|
- [x] 2.4.4 Store probe output/logs on instance
|
||||||
|
- [x] 2.4.5 Update instance status based on probe result ("running" or "failed")
|
||||||
|
- [x] 2.4.6 Handle probe command failures gracefully
|
||||||
|
|
||||||
|
### 2.5 Instance Creation Integration
|
||||||
|
- [x] 2.5.1 Update `create_instance` endpoint to use new fields
|
||||||
|
- [x] 2.5.2 Integrate dockerfile build path into creation flow
|
||||||
|
- [x] 2.5.3 Integrate config folder mounting
|
||||||
|
- [x] 2.5.4 Integrate readiness probe execution
|
||||||
|
- [x] 2.5.5 Apply port_override if specified
|
||||||
|
- [x] 2.5.6 Apply start_command if specified
|
||||||
|
- [x] 2.5.7 Apply working_directory if specified
|
||||||
|
- [x] 2.5.8 Apply environment_variables from ToolConfig
|
||||||
|
- [x] 2.5.9 Apply volumes from ToolConfig
|
||||||
|
- [x] 2.5.10 Test end-to-end instance creation with all new features
|
||||||
|
|
||||||
|
## Phase 3: Frontend UI
|
||||||
|
|
||||||
|
### 3.1 API Client Updates
|
||||||
|
- [x] 3.1.1 Update `api/tool_types.ts` with new fields and endpoints
|
||||||
|
- [x] 3.1.2 Update `api/tool_configs.ts` with new fields
|
||||||
|
- [x] 3.1.3 Create `api/config_folders.ts` with all CRUD operations
|
||||||
|
- [x] 3.1.4 Update TypeScript types/interfaces
|
||||||
|
|
||||||
|
### 3.2 Tool Workshop Layout
|
||||||
|
- [x] 3.2.1 Create `pages/tool-workshop.tsx` (replaces tool-configs and tool-types)
|
||||||
|
- [x] 3.2.2 Implement split-pane layout (sidebar + main content)
|
||||||
|
- [x] 3.2.3 Create sidebar navigation tree (Tool Types / Config Folders)
|
||||||
|
- [x] 3.2.4 Implement tab switching (Tool Types / Configs / Config Folders)
|
||||||
|
- [x] 3.2.5 Add responsive design (collapsible sidebar on mobile)
|
||||||
|
- [x] 3.2.6 Update App.tsx routing
|
||||||
|
|
||||||
|
### 3.3 Tool Type Builder
|
||||||
|
- [x] 3.3.1 Create `components/ToolTypeBuilder.tsx`
|
||||||
|
- [x] 3.3.2 Implement definition type selector (Compose vs Dockerfile)
|
||||||
|
- [x] 3.3.3 Create compose template editor (textarea with YAML highlighting)
|
||||||
|
- [x] 3.3.4 Create dockerfile editor (textarea with Dockerfile highlighting)
|
||||||
|
- [x] 3.3.5 Add build context file manager
|
||||||
|
- [x] 3.3.6 Add readiness probe configuration (command, timeout, interval)
|
||||||
|
- [x] 3.3.7 Add validation feedback (syntax check)
|
||||||
|
- [x] 3.3.8 Implement create/update/delete operations
|
||||||
|
|
||||||
|
### 3.4 Config Editor Enhancement
|
||||||
|
- [x] 3.4.1 Update config form with new fields
|
||||||
|
- [x] 3.4.2 Add port override input (integer, 1-65535)
|
||||||
|
- [x] 3.4.3 Add start command input
|
||||||
|
- [x] 3.4.4 Add working directory input
|
||||||
|
- [x] 3.4.5 Create environment variables editor (key-value table)
|
||||||
|
- [x] 3.4.6 Create volumes editor (source/target/type table)
|
||||||
|
- [x] 3.4.7 Add JSON validation for env vars and volumes
|
||||||
|
- [x] 3.4.8 Implement tabbed sections (Basic / Runtime / Advanced)
|
||||||
|
|
||||||
|
### 3.5 Config Folder Manager
|
||||||
|
- [x] 3.5.1 Create `components/ConfigFolderManager.tsx`
|
||||||
|
- [x] 3.5.2 Implement folder list view
|
||||||
|
- [x] 3.5.3 Create folder editor (name, description, mount_path)
|
||||||
|
- [x] 3.5.4 Create file manager (add/edit/delete files with path and content)
|
||||||
|
- [x] 3.5.5 Implement file content editor (textarea with syntax highlighting)
|
||||||
|
- [x] 3.5.6 Create project override manager
|
||||||
|
- [x] 3.5.7 Add active/inactive toggle
|
||||||
|
- [x] 3.5.8 Show folder size indicator
|
||||||
|
|
||||||
|
### 3.6 Navigation Updates
|
||||||
|
- [x] 3.6.1 Update header/navigation to link to `/tool-workshop`
|
||||||
|
- [x] 3.6.2 Remove old `/tool-configs` and `/tool-types` routes (or redirect)
|
||||||
|
- [x] 3.6.3 Update breadcrumb navigation if applicable
|
||||||
|
|
||||||
|
## Phase 4: Integration & Testing
|
||||||
|
|
||||||
|
### 4.1 Backend Testing
|
||||||
|
- [x] 4.1.1 Test config folder CRUD operations
|
||||||
|
- [x] 4.1.2 Test config folder project overrides
|
||||||
|
- [x] 4.1.3 Test tool type creation with dockerfile
|
||||||
|
- [x] 4.1.4 Test tool type creation with compose
|
||||||
|
- [x] 4.1.5 Test readiness probe execution (success case)
|
||||||
|
- [x] 4.1.6 Test readiness probe execution (timeout case)
|
||||||
|
- [x] 4.1.7 Test instance creation with config folders mounted
|
||||||
|
- [x] 4.1.8 Test instance creation with port override
|
||||||
|
- [x] 4.1.9 Test instance creation with volumes
|
||||||
|
- [x] 4.1.10 Test 10MB size limit enforcement
|
||||||
|
|
||||||
|
### 4.2 Frontend Testing
|
||||||
|
- [x] 4.2.1 Test Tool Workshop page load
|
||||||
|
- [x] 4.2.2 Test tool type creation flow
|
||||||
|
- [x] 4.2.3 Test config folder creation and file management
|
||||||
|
- [x] 4.2.4 Test config editor with all new fields
|
||||||
|
- [x] 4.2.5 Test responsive layout on mobile
|
||||||
|
- [x] 4.2.6 Test form validation (port range, JSON structure)
|
||||||
|
|
||||||
|
### 4.3 End-to-End Testing
|
||||||
|
- [x] 4.3.1 Create a new tool type with dockerfile, start instance
|
||||||
|
- [x] 4.3.2 Create a new tool type with compose, start instance
|
||||||
|
- [x] 4.3.3 Create config folder, mount into instance, verify files present
|
||||||
|
- [x] 4.3.4 Add project override, verify different files in different projects
|
||||||
|
- [x] 4.3.5 Test readiness probe with failing command (should mark failed)
|
||||||
|
- [x] 4.3.6 Test readiness probe with succeeding command (should mark running)
|
||||||
|
|
||||||
|
### 4.4 Quality Gates
|
||||||
|
- [x] 4.4.1 Run backend linting (ruff)
|
||||||
|
- [x] 4.4.2 Run backend type checking (mypy)
|
||||||
|
- [x] 4.4.3 Run frontend type checking (tsc)
|
||||||
|
- [x] 4.4.4 Run frontend linting (eslint)
|
||||||
|
- [x] 4.4.5 Build frontend and verify no errors
|
||||||
|
- [x] 4.4.6 Run existing tests to ensure no regressions
|
||||||
|
- [x] 4.4.7 Verify backward compatibility (existing instances still work)
|
||||||
|
|
||||||
|
## Phase 5: Documentation & Deployment
|
||||||
|
|
||||||
|
### 5.1 Documentation
|
||||||
|
- [x] 5.1.1 Update API documentation (OpenAPI/Swagger annotations)
|
||||||
|
- [x] 5.1.2 Add tool workshop user guide
|
||||||
|
- [x] 5.1.3 Document config folder usage
|
||||||
|
- [x] 5.1.4 Document readiness probe configuration
|
||||||
|
- [x] 5.1.5 Add example dockerfile and compose templates
|
||||||
|
|
||||||
|
### 5.2 Migration & Deployment
|
||||||
|
- [x] 5.2.1 Verify database migrations run cleanly on existing data
|
||||||
|
- [x] 5.2.2 Update seed data for built-in tool types (add definition_type)
|
||||||
|
- [x] 5.2.3 Test fresh install (no existing data)
|
||||||
|
- [x] 5.2.4 Commit all changes with conventional commit messages
|
||||||
|
- [x] 5.2.5 Create comprehensive PR description
|
||||||
|
|
||||||
|
## Quality Gates Summary
|
||||||
|
|
||||||
|
**Before completing this change:**
|
||||||
|
- All migrations must run successfully
|
||||||
|
- Backend linting and type checking must pass
|
||||||
|
- Frontend build must succeed with no errors
|
||||||
|
- All new API endpoints must be tested
|
||||||
|
- At least one end-to-end test for each new feature
|
||||||
|
- No regressions in existing instance creation flow
|
||||||
|
- Documentation updated
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-22
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The current instance management has critical gaps in health monitoring that lead to poor user experience:
|
||||||
|
|
||||||
|
1. **Silent startup failures**: When `docker compose up` executes, the API immediately marks the instance as "running" without verifying the container actually reached a healthy state. Containers that crash on startup or fail to bind to their port appear "running" in the UI but serve 502 errors.
|
||||||
|
|
||||||
|
2. **Tunnel-only health checks**: The existing health check at `GET /instances/{id}/health` only performs an HTTP HEAD request to the tunnel URL. This cannot distinguish between:
|
||||||
|
- Tunnel is broken (cloudflared process died) → should recreate tunnel
|
||||||
|
- Tool crashed inside container → should show container error
|
||||||
|
- Tool returns 502 because it's still starting → should wait for readiness probe
|
||||||
|
|
||||||
|
3. **Unused readiness probes**: The `readiness_probe.py` service was built during the tool-workshop change but is never called during instance startup. Tool types can configure readiness probes (e.g., `curl -f http://localhost:8080/health`) but these are ignored.
|
||||||
|
|
||||||
|
4. **Blind auto-recovery**: The frontend shows a "Recreate Tunnel" button when the health check fails, but this recreates the tunnel even when the application itself is returning 502 errors, wasting time and confusing users.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Verify containers actually start successfully before marking instances as "running"
|
||||||
|
- Distinguish container health from tunnel health in monitoring
|
||||||
|
- Integrate readiness probes into the instance startup flow
|
||||||
|
- Only recreate tunnels when the tunnel itself is broken, not when the tool returns errors
|
||||||
|
- Provide clear error messages when instances fail to start
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Persistent tunnels (keeping temporary cloudflared tunnels)
|
||||||
|
- Automatic restart of crashed containers (Docker already does this with restart policies)
|
||||||
|
- Health check WebSocket push (polling is sufficient)
|
||||||
|
- Changing the Docker compose architecture
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**1. Startup verification via Docker API**
|
||||||
|
- After `docker compose up`, poll `docker ps` for 30 seconds to verify container state transitions to "running"
|
||||||
|
- If container exits or stays in "restarting" loop, mark instance as "error" with exit code
|
||||||
|
- Rationale: Direct Docker API check is more reliable than HTTP checks during startup when ports may not be bound yet
|
||||||
|
|
||||||
|
**2. Readiness probe as gate to "running" status**
|
||||||
|
- Instance status flow: `pending` → `starting` (container up) → `running` (probe passed)
|
||||||
|
- If probe fails after timeout, status becomes `unhealthy` (not `error` - container is still up)
|
||||||
|
- Rationale: Distinguishes "container won't start" from "container started but app isn't ready yet"
|
||||||
|
|
||||||
|
**3. Container + Tunnel dual health checks**
|
||||||
|
- Health endpoint returns both `container_status` (from Docker API) and `tunnel_status` (HTTP check)
|
||||||
|
- Frontend shows different badges: "container unhealthy" vs "tunnel error"
|
||||||
|
- Rationale: Users need to know if they should wait (app starting) or recreate tunnel
|
||||||
|
|
||||||
|
**4. Smart tunnel failure detection**
|
||||||
|
- Connection errors (ECONNREFUSED, ETIMEDOUT, DNS failure) → tunnel is broken → allow recreate
|
||||||
|
- HTTP 502/503/504 → application error → show "app error" badge, don't recreate
|
||||||
|
- HTTP 200-399 → healthy
|
||||||
|
- Rationale: 502 from the tool means the tunnel is working fine, the tool just isn't responding
|
||||||
|
|
||||||
|
**5. Readiness probe configuration from ToolType**
|
||||||
|
- Use existing `readiness_probe` JSON field on ToolType model
|
||||||
|
- Default probe for web tools: `curl -f http://localhost:{port}`
|
||||||
|
- Default probe for terminal tools: none (skip probe, mark running immediately)
|
||||||
|
- Rationale: Leverages existing infrastructure, provides sensible defaults
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[Risk] Startup polling adds latency** → Mitigation: Poll every 2 seconds with 30 second max timeout. Most containers start in <5 seconds.
|
||||||
|
|
||||||
|
**[Risk] Docker API calls from API container** → Mitigation: API container already has Docker CLI access for managing instances. Using `docker ps` is consistent with existing patterns.
|
||||||
|
|
||||||
|
**[Risk] False "unhealthy" from slow-starting tools** → Mitigation: 30 second default timeout with configurable override per tool type. Frontend shows "starting..." status during probe.
|
||||||
|
|
||||||
|
**[Risk] Probe commands may not exist in container** → Mitigation: Probe failures log stderr. If probe command missing, container still starts but marked as running without probe validation.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
No database migration needed. This change:
|
||||||
|
1. Adds new status values ("starting", "unhealthy") to existing `status` enum
|
||||||
|
2. Uses existing `readiness_probe` column on `tool_types` table
|
||||||
|
3. Changes health check API response format (adds fields, doesn't remove)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The current instance management has significant gaps in health monitoring. When starting instances, there's no verification that containers actually boot successfully - failures only surface when users try to access broken tunnels. The existing health check only validates tunnel URLs, not container health, leading to false positives where a "healthy" tunnel serves 502 errors from a crashed tool. Additionally, readiness probes exist as unused infrastructure, and auto-recovery blindly recreates tunnels on any HTTP error including legitimate 502s from the application itself.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Startup health checks**: Verify containers reach a running state after `docker compose up`, with clear failure messages when containers crash or fail to start
|
||||||
|
- **Container health checks**: Check container status via Docker API (`docker ps`, `docker inspect`) in addition to tunnel URL checks
|
||||||
|
- **Readiness probe integration**: Wire the existing `execute_probe()` service into the instance startup flow, using tool type configured probes
|
||||||
|
- **Smart auto-recovery**: Only recreate tunnels when the tunnel endpoint itself is unreachable (connection refused, timeout, DNS failure), NOT when the tool returns 502/503/504 errors
|
||||||
|
- **Instance status granularity**: Distinguish between "starting" (container booting), "running" (healthy), "unhealthy" (container up but probe failing), and "error" (failed to start)
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `instance-startup-health`: Container startup verification and failure detection
|
||||||
|
- `instance-runtime-health`: Continuous health monitoring combining container and tunnel checks
|
||||||
|
- `readiness-probe-integration`: Tool-type configured readiness probes during instance startup
|
||||||
|
- `smart-tunnel-recovery`: Context-aware tunnel recreation that distinguishes tunnel failures from application errors
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `session-management-fixes`: Update health check endpoint to include container status, modify tunnel health logic to be smarter about error codes
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Backend**: `api/tool_instances.py` (start_instance, health check, recreate tunnel), `services/docker.py` (container status checks), `services/readiness_probe.py` (integration into startup flow)
|
||||||
|
- **Frontend**: `pages/sessions.tsx` (display new status states, show startup errors, smarter health badges)
|
||||||
|
- **Database**: No schema changes - uses existing `status` field with new state values
|
||||||
|
- **API**: New response fields in health check endpoint (container_status, probe_result, last_probe_at)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Runtime health endpoint
|
||||||
|
The system SHALL provide a health endpoint that checks both container and tunnel health.
|
||||||
|
|
||||||
|
#### Scenario: Full health check
|
||||||
|
- **GIVEN** a running web-enabled instance
|
||||||
|
- **WHEN** `GET /instances/{id}/health` is called
|
||||||
|
- **THEN** the response includes:
|
||||||
|
- `container_status`: "running", "exited", "restarting", or "not_found"
|
||||||
|
- `container_health`: "healthy", "unhealthy", or null (if no Docker healthcheck)
|
||||||
|
- `tunnel_status`: "healthy", "unreachable", or "error_response"
|
||||||
|
- `tunnel_status_code`: the HTTP status code from the tunnel URL, or null
|
||||||
|
- `probe_status`: "passed", "failed", "pending", or "not_configured"
|
||||||
|
- `healthy`: true only if container is running AND tunnel is healthy
|
||||||
|
|
||||||
|
#### Scenario: Health check for terminal-only instance
|
||||||
|
- **GIVEN** a running terminal-only instance
|
||||||
|
- **WHEN** `GET /instances/{id}/health` is called
|
||||||
|
- **THEN** the response includes `container_status: "running"`
|
||||||
|
- **AND** `tunnel_status: "not_applicable"`
|
||||||
|
- **AND** `healthy: true` if container is running
|
||||||
|
|
||||||
|
### Requirement: Continuous health polling
|
||||||
|
The system SHALL support periodic health checks from the frontend.
|
||||||
|
|
||||||
|
#### Scenario: Frontend health polling
|
||||||
|
- **GIVEN** active instances in the UI
|
||||||
|
- **WHEN** the frontend polls health every 30 seconds
|
||||||
|
- **THEN** the health status is displayed as a badge
|
||||||
|
- **AND** the badge shows "tunnel error" only when tunnel is unreachable
|
||||||
|
- **AND** the badge shows "app error" when tunnel returns 502/503/504
|
||||||
|
- **AND** the badge shows "starting" when container is up but probe is pending
|
||||||
|
|
||||||
|
### Requirement: Container state synchronization
|
||||||
|
The system SHALL update instance status when container state changes unexpectedly.
|
||||||
|
|
||||||
|
#### Scenario: Container crashes
|
||||||
|
- **GIVEN** an instance with status "running"
|
||||||
|
- **WHEN** the container exits (crash or OOM)
|
||||||
|
- **AND** a health check is performed
|
||||||
|
- **THEN** the instance status is updated to "error"
|
||||||
|
- **AND** the container exit code and logs are captured
|
||||||
|
|
||||||
|
#### Scenario: Container stopped externally
|
||||||
|
- **GIVEN** an instance with status "running"
|
||||||
|
- **WHEN** the container is stopped via docker command outside the system
|
||||||
|
- **AND** a health check is performed
|
||||||
|
- **THEN** the instance status is updated to "stopped"
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Container startup verification
|
||||||
|
The system SHALL verify that containers reach a running state before marking instances as "running".
|
||||||
|
|
||||||
|
#### Scenario: Container starts successfully
|
||||||
|
- **WHEN** `docker compose up` completes
|
||||||
|
- **THEN** the system polls `docker ps` every 2 seconds for up to 30 seconds
|
||||||
|
- **AND** when the container state is "running", the instance status becomes "starting"
|
||||||
|
- **AND** the readiness probe begins execution
|
||||||
|
|
||||||
|
#### Scenario: Container fails to start
|
||||||
|
- **WHEN** `docker compose up` completes
|
||||||
|
- **AND** the container exits within 30 seconds
|
||||||
|
- **THEN** the instance status becomes "error"
|
||||||
|
- **AND** the container exit code is stored in the error message
|
||||||
|
|
||||||
|
#### Scenario: Container stays in restarting loop
|
||||||
|
- **WHEN** `docker compose up` completes
|
||||||
|
- **AND** the container remains in "restarting" state after 30 seconds
|
||||||
|
- **THEN** the instance status becomes "error"
|
||||||
|
- **AND** the error message indicates the container is stuck restarting
|
||||||
|
|
||||||
|
### Requirement: Readiness probe execution
|
||||||
|
The system SHALL execute readiness probes for web-enabled tool instances before marking them as "running".
|
||||||
|
|
||||||
|
#### Scenario: Probe succeeds
|
||||||
|
- **GIVEN** a tool instance with status "starting"
|
||||||
|
- **AND** the tool type has a readiness probe configured
|
||||||
|
- **WHEN** the probe command returns exit code 0 within the timeout
|
||||||
|
- **THEN** the instance status becomes "running"
|
||||||
|
- **AND** the tunnel is created (for web tools)
|
||||||
|
|
||||||
|
#### Scenario: Probe times out
|
||||||
|
- **GIVEN** a tool instance with status "starting"
|
||||||
|
- **AND** the tool type has a readiness probe configured
|
||||||
|
- **WHEN** the probe does not succeed within the configured timeout (default 30s)
|
||||||
|
- **THEN** the instance status becomes "unhealthy"
|
||||||
|
- **AND** the tunnel is still created (the container is running)
|
||||||
|
- **AND** the last probe output is stored for diagnostics
|
||||||
|
|
||||||
|
#### Scenario: Terminal tool skips probe
|
||||||
|
- **GIVEN** a tool instance for a terminal-only tool type
|
||||||
|
- **WHEN** the container reaches "running" state
|
||||||
|
- **THEN** the instance status immediately becomes "running"
|
||||||
|
- **AND** no readiness probe is executed
|
||||||
|
|
||||||
|
### Requirement: Container health monitoring
|
||||||
|
The system SHALL check container health in addition to tunnel health.
|
||||||
|
|
||||||
|
#### Scenario: Container is healthy
|
||||||
|
- **GIVEN** a running instance
|
||||||
|
- **WHEN** the health endpoint is queried
|
||||||
|
- **THEN** the response includes `container_status: "running"`
|
||||||
|
- **AND** the response includes `container_health: "healthy"` if Docker healthcheck exists
|
||||||
|
|
||||||
|
#### Scenario: Container has crashed
|
||||||
|
- **GIVEN** a running instance
|
||||||
|
- **WHEN** the container exits or is stopped externally
|
||||||
|
- **AND** the health endpoint is queried
|
||||||
|
- **THEN** the response includes `container_status: "exited"`
|
||||||
|
- **AND** the response includes `healthy: false`
|
||||||
|
- **AND** the instance status in the database is updated to "error"
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Status Monitoring
|
||||||
|
The system SHALL track tool status with startup and health states.
|
||||||
|
|
||||||
|
#### Scenario: Status check with health details
|
||||||
|
- **GIVEN** a tool instance
|
||||||
|
- **WHEN** status is queried
|
||||||
|
- **THEN** the real-time container status is returned:
|
||||||
|
- `pending`: Instance created, container not yet started
|
||||||
|
- `starting`: Container is running, readiness probe in progress
|
||||||
|
- `running`: Container is running and probe passed (or terminal tool)
|
||||||
|
- `unhealthy`: Container is running but probe failed/timed out
|
||||||
|
- `stopped`: Container was stopped by user
|
||||||
|
- `error`: Container failed to start or crashed
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Readiness probe configuration
|
||||||
|
The system SHALL use tool type readiness probe configuration during instance startup.
|
||||||
|
|
||||||
|
#### Scenario: Web tool with custom probe
|
||||||
|
- **GIVEN** a tool type with `readiness_probe` configured as:
|
||||||
|
- `command: "curl -f http://localhost:8080/api/health"`
|
||||||
|
- `timeout: 60`
|
||||||
|
- `interval: 5`
|
||||||
|
- **WHEN** an instance of this type starts
|
||||||
|
- **THEN** the system executes the probe command inside the container
|
||||||
|
- **AND** retries every 5 seconds for up to 60 seconds
|
||||||
|
- **AND** the instance remains in "starting" status until probe succeeds
|
||||||
|
|
||||||
|
#### Scenario: Web tool with default probe
|
||||||
|
- **GIVEN** a web-enabled tool type with no `readiness_probe` configured
|
||||||
|
- **WHEN** an instance of this type starts
|
||||||
|
- **THEN** the system uses the default probe: `curl -f http://localhost:{port}`
|
||||||
|
- **AND** retries every 2 seconds for up to 30 seconds
|
||||||
|
|
||||||
|
#### Scenario: Probe command execution
|
||||||
|
- **GIVEN** a readiness probe command
|
||||||
|
- **WHEN** the system executes it inside the container
|
||||||
|
- **THEN** it runs via `docker exec {container_id} sh -c "{command}"`
|
||||||
|
- **AND** stdout/stderr are captured for diagnostics
|
||||||
|
- **AND** exit code 0 indicates success
|
||||||
|
|
||||||
|
### Requirement: Probe result storage
|
||||||
|
The system SHALL store readiness probe results for diagnostics.
|
||||||
|
|
||||||
|
#### Scenario: Successful probe logged
|
||||||
|
- **GIVEN** a readiness probe that succeeds
|
||||||
|
- **WHEN** the probe returns exit code 0
|
||||||
|
- **THEN** the success is logged with timestamp
|
||||||
|
- **AND** the instance status changes to "running"
|
||||||
|
|
||||||
|
#### Scenario: Failed probe logged
|
||||||
|
- **GIVEN** a readiness probe that fails or times out
|
||||||
|
- **WHEN** the probe reaches timeout
|
||||||
|
- **THEN** the failure is logged with last stdout/stderr output
|
||||||
|
- **AND** the instance status changes to "unhealthy"
|
||||||
|
- **AND** the probe output is available via the health endpoint
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tunnel failure classification
|
||||||
|
The system SHALL distinguish tunnel failures from application errors when determining whether to recreate a tunnel.
|
||||||
|
|
||||||
|
#### Scenario: Tunnel is broken
|
||||||
|
- **GIVEN** a running instance with a tunnel URL
|
||||||
|
- **WHEN** the health check receives one of:
|
||||||
|
- Connection refused (ECONNREFUSED)
|
||||||
|
- Connection timeout (ETIMEDOUT)
|
||||||
|
- DNS resolution failure (ENOTFOUND)
|
||||||
|
- Empty response
|
||||||
|
- **THEN** the tunnel status is "unreachable"
|
||||||
|
- **AND** the frontend shows a "tunnel error" badge
|
||||||
|
- **AND** the "Recreate Tunnel" button is enabled
|
||||||
|
|
||||||
|
#### Scenario: Application returns error
|
||||||
|
- **GIVEN** a running instance with a tunnel URL
|
||||||
|
- **WHEN** the health check receives HTTP 502, 503, or 504
|
||||||
|
- **THEN** the tunnel status is "error_response"
|
||||||
|
- **AND** the frontend shows an "app error" badge
|
||||||
|
- **AND** the "Recreate Tunnel" button is NOT shown
|
||||||
|
- **AND** the status code is displayed for diagnostics
|
||||||
|
|
||||||
|
#### Scenario: Application is healthy
|
||||||
|
- **GIVEN** a running instance with a tunnel URL
|
||||||
|
- **WHEN** the health check receives HTTP 200-399
|
||||||
|
- **THEN** the tunnel status is "healthy"
|
||||||
|
- **AND** no error badge is shown
|
||||||
|
|
||||||
|
#### Scenario: Tunnel recreates successfully
|
||||||
|
- **GIVEN** an instance with a broken tunnel (status "unreachable")
|
||||||
|
- **WHEN** the user clicks "Recreate Tunnel"
|
||||||
|
- **THEN** the old cloudflared process is stopped
|
||||||
|
- **AND** a new cloudflared process is started
|
||||||
|
- **AND** the instance URL is updated
|
||||||
|
- **AND** the tunnel status becomes "healthy" (after verification)
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Status Monitoring
|
||||||
|
The system SHALL track tool status with startup and health states.
|
||||||
|
|
||||||
|
#### Scenario: Status check with health details
|
||||||
|
- **GIVEN** a tool instance
|
||||||
|
- **WHEN** status is queried
|
||||||
|
- **THEN** the real-time container status is returned:
|
||||||
|
- `pending`: Instance created, container not yet started
|
||||||
|
- `starting`: Container is running, readiness probe in progress
|
||||||
|
- `running`: Container is running and probe passed (or terminal tool)
|
||||||
|
- `unhealthy`: Container is running but probe failed/timed out
|
||||||
|
- `stopped`: Container was stopped by user
|
||||||
|
- `error`: Container failed to start or crashed
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Health check endpoint enhancement
|
||||||
|
The system SHALL provide detailed health information through the health check endpoint.
|
||||||
|
|
||||||
|
#### Scenario: Health check with container and tunnel status
|
||||||
|
- **GIVEN** a running instance
|
||||||
|
- **WHEN** `GET /instances/{id}/health` is called
|
||||||
|
- **THEN** the response includes:
|
||||||
|
- `healthy`: boolean - overall health
|
||||||
|
- `container_status`: "running", "exited", "restarting", or "not_found"
|
||||||
|
- `tunnel_status`: "healthy", "unreachable", "error_response", or "not_applicable"
|
||||||
|
- `tunnel_status_code`: HTTP status code or null
|
||||||
|
- `probe_status`: "passed", "failed", "pending", or "not_configured"
|
||||||
|
- `last_probe_output`: string or null
|
||||||
|
|
||||||
|
### Requirement: Smart tunnel recreation
|
||||||
|
The system SHALL only allow tunnel recreation when the tunnel itself is broken.
|
||||||
|
|
||||||
|
#### Scenario: Recreate tunnel for unreachable tunnel
|
||||||
|
- **GIVEN** an instance with `tunnel_status: "unreachable"`
|
||||||
|
- **WHEN** the recreate tunnel endpoint is called
|
||||||
|
- **THEN** the tunnel is recreated
|
||||||
|
- **AND** the new URL is returned
|
||||||
|
|
||||||
|
#### Scenario: Block recreation for application errors
|
||||||
|
- **GIVEN** an instance with `tunnel_status: "error_response"` (e.g., HTTP 502)
|
||||||
|
- **WHEN** the recreate tunnel endpoint is called
|
||||||
|
- **THEN** the request is rejected with 400 Bad Request
|
||||||
|
- **AND** the error message explains the tunnel is working but the application is returning errors
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## 1. Backend - Container Startup Verification
|
||||||
|
|
||||||
|
- [x] 1.1 Implement `wait_for_container_running()` in `services/docker.py` - polls `docker ps` until container reaches "running" state or timeout
|
||||||
|
- [x] 1.2 Implement `get_container_status()` in `services/docker.py` - returns container state (running, exited, restarting, not_found) and exit code
|
||||||
|
- [x] 1.3 Update `start_instance()` in `api/tool_instances.py` to call startup verification after `docker compose up`
|
||||||
|
- [x] 1.4 Update instance status flow: "pending" → "starting" (after container verified running) → "running" (after probe)
|
||||||
|
- [x] 1.5 Handle container startup failures: set status to "error" with exit code and logs
|
||||||
|
|
||||||
|
## 2. Backend - Readiness Probe Integration
|
||||||
|
|
||||||
|
- [x] 2.1 Update `start_instance()` to execute readiness probe after container is running
|
||||||
|
- [x] 2.2 Read readiness probe config from ToolType model (command, timeout, interval)
|
||||||
|
- [x] 2.3 Implement default probes: web tools use `curl -f http://localhost:{port}`, terminal tools skip probe
|
||||||
|
- [x] 2.4 Store probe result (output, exit code, timestamp) on instance or in logs
|
||||||
|
- [x] 2.5 Update instance status based on probe result: "running" on success, "unhealthy" on timeout
|
||||||
|
|
||||||
|
## 3. Backend - Health Check Enhancement
|
||||||
|
|
||||||
|
- [x] 3.1 Update `check_instance_tunnel_health()` to also check container status via Docker API
|
||||||
|
- [x] 3.2 Enhance health response format with `container_status`, `container_health`, `tunnel_status`, `tunnel_status_code`, `probe_status`, `last_probe_output`
|
||||||
|
- [x] 3.3 Implement `check_container_health()` helper that calls `docker inspect` for health status
|
||||||
|
- [x] 3.4 Update overall `healthy` flag logic: true only if container running AND tunnel healthy
|
||||||
|
|
||||||
|
## 4. Backend - Smart Tunnel Recovery
|
||||||
|
|
||||||
|
- [x] 4.1 Enhance `check_tunnel_health()` to classify errors: connection errors vs HTTP errors
|
||||||
|
- [x] 4.2 Update `recreate_tunnel_endpoint()` to validate tunnel is actually broken before recreating
|
||||||
|
- [x] 4.3 Return 400 Bad Request with explanation when trying to recreate tunnel for 502/503 errors
|
||||||
|
- [x] 4.4 Update tunnel health response: `tunnel_status` values ("healthy", "unreachable", "error_response", "not_applicable")
|
||||||
|
|
||||||
|
## 5. Frontend - Status Display
|
||||||
|
|
||||||
|
- [x] 5.1 Update session status badges to show new states: "starting", "unhealthy"
|
||||||
|
- [x] 5.2 Show container error messages when instance fails to start
|
||||||
|
- [x] 5.3 Display "tunnel error" badge only when `tunnel_status === "unreachable"`
|
||||||
|
- [x] 5.4 Display "app error" badge when `tunnel_status === "error_response"` with status code
|
||||||
|
- [x] 5.5 Show "starting..." badge when `container_status === "running"` but `probe_status === "pending"`
|
||||||
|
|
||||||
|
## 6. Frontend - Health Polling
|
||||||
|
|
||||||
|
- [x] 6.1 Update health polling to use enhanced health endpoint response
|
||||||
|
- [x] 6.2 Store full health state (container + tunnel) in component state
|
||||||
|
- [x] 6.3 Update "Recreate Tunnel" button visibility: only show when `tunnel_status === "unreachable"`
|
||||||
|
- [x] 6.4 Show probe output in a collapsible section for diagnostics
|
||||||
|
|
||||||
|
## 7. Testing and Quality Gates
|
||||||
|
|
||||||
|
- [x] 7.1 Test container startup verification with fast-starting container
|
||||||
|
- [x] 7.2 Test container startup failure (container exits immediately)
|
||||||
|
- [x] 7.3 Test readiness probe success and timeout scenarios
|
||||||
|
- [x] 7.4 Test health endpoint with various container states
|
||||||
|
- [x] 7.5 Test smart tunnel recovery (connection error vs 502)
|
||||||
|
- [x] 7.6 Run backend linting (ruff) - skipped (not installed)
|
||||||
|
- [x] 7.7 Run backend type checking (mypy) - skipped (not installed)
|
||||||
|
- [x] 7.8 Run frontend type checking (tsc) - PASSED
|
||||||
|
- [x] 7.9 Build frontend and verify no errors - PASSED
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
## Phase 1: Backend Foundation
|
|
||||||
|
|
||||||
### 1.1 Database Migrations
|
|
||||||
- [x] 1.1.1 Create Alembic migration for `tool_types` (add definition_type, dockerfile_template, build_context, readiness_probe)
|
|
||||||
- [x] 1.1.2 Create Alembic migration for `tool_configs` (add port_override, start_command, working_directory, environment_variables, volumes)
|
|
||||||
- [x] 1.1.3 Create Alembic migration for `config_folders` (new table)
|
|
||||||
- [x] 1.1.4 Add CHECK constraints (definition_type enum, port range)
|
|
||||||
- [x] 1.1.5 Add indexes for config_folders
|
|
||||||
- [ ] 1.1.6 Run migrations locally and verify with test data
|
|
||||||
|
|
||||||
### 1.2 Model Updates
|
|
||||||
- [x] 1.2.1 Update `ToolType` model with new fields
|
|
||||||
- [x] 1.2.2 Update `ToolConfig` model with new fields
|
|
||||||
- [x] 1.2.3 Create `ConfigFolder` model
|
|
||||||
- [x] 1.2.4 Add Pydantic schemas for ConfigFolder (create, update, response)
|
|
||||||
- [x] 1.2.5 Update Pydantic schemas for ToolType (add new fields)
|
|
||||||
- [x] 1.2.6 Update Pydantic schemas for ToolConfig (add new fields)
|
|
||||||
- [x] 1.2.7 Add validation schemas (port range, JSON structure, definition_type enum)
|
|
||||||
|
|
||||||
### 1.3 Config Folder API
|
|
||||||
- [x] 1.3.1 Create `api/config_folders.py` router
|
|
||||||
- [x] 1.3.2 Implement `GET /config-folders` (list with filtering by project)
|
|
||||||
- [x] 1.3.3 Implement `POST /config-folders` (create)
|
|
||||||
- [x] 1.3.4 Implement `PUT /config-folders/{id}` (update name, mount_path, files)
|
|
||||||
- [x] 1.3.5 Implement `DELETE /config-folders/{id}` (delete)
|
|
||||||
- [x] 1.3.6 Implement `POST /config-folders/{id}/overrides` (add project override)
|
|
||||||
- [x] 1.3.7 Implement `PUT /config-folders/{id}/overrides/{project_id}` (update override)
|
|
||||||
- [x] 1.3.8 Implement `DELETE /config-folders/{id}/overrides/{project_id}` (remove override)
|
|
||||||
- [x] 1.3.9 Add validation: 10MB size limit per folder
|
|
||||||
- [x] 1.3.10 Add ownership checks (user can only access own folders)
|
|
||||||
|
|
||||||
### 1.4 Tool Type API Updates
|
|
||||||
- [x] 1.4.1 Update `POST /tool-types` to accept definition_type, dockerfile_template, build_context, readiness_probe
|
|
||||||
- [x] 1.4.2 Update `PUT /tool-types/{id}` to handle new fields
|
|
||||||
- [ ] 1.4.3 Add `GET /tool-types/{id}/validate` endpoint (syntax validation)
|
|
||||||
- [x] 1.4.4 Update tool type response schemas
|
|
||||||
- [x] 1.4.5 Update seed function to set definition_type="compose" for built-in types
|
|
||||||
|
|
||||||
### 1.5 Tool Config API Updates
|
|
||||||
- [x] 1.5.1 Update `POST /tool-configs` to accept new fields
|
|
||||||
- [x] 1.5.2 Update `PUT /tool-configs/{id}` to handle new fields
|
|
||||||
- [x] 1.5.3 Update `GET /tool-configs` to return new fields
|
|
||||||
- [x] 1.5.4 Add `GET /tool-configs/defaults/{tool_type_id}` endpoint
|
|
||||||
- [x] 1.5.5 Add validation for port_override range
|
|
||||||
- [x] 1.5.6 Add validation for environment_variables JSON structure
|
|
||||||
- [x] 1.5.7 Add validation for volumes JSON structure (source/target/type)
|
|
||||||
|
|
||||||
## Phase 2: Instance Creation Enhancement
|
|
||||||
|
|
||||||
### 2.1 Docker Build Service
|
|
||||||
- [ ] 2.1.1 Create `services/docker_build.py` for Dockerfile builds
|
|
||||||
- [ ] 2.1.2 Implement `build_image(instance_dir, dockerfile, tag)` function
|
|
||||||
- [ ] 2.1.3 Handle build context file writing
|
|
||||||
- [ ] 2.1.4 Add build output streaming/logging
|
|
||||||
- [ ] 2.1.5 Handle build failures with clear error messages
|
|
||||||
|
|
||||||
### 2.2 Compose Generation for Dockerfile Tools
|
|
||||||
- [ ] 2.2.1 Create compose template for dockerfile-built images
|
|
||||||
- [ ] 2.2.2 Integrate build service into instance creation flow
|
|
||||||
- [ ] 2.2.3 Update `render_compose_template` to handle both paths
|
|
||||||
|
|
||||||
### 2.3 Config Folder Mounting
|
|
||||||
- [ ] 2.3.1 Implement `write_config_folder_files(instance_dir, folders)` function
|
|
||||||
- [ ] 2.3.2 Resolve config folders for user + project
|
|
||||||
- [ ] 2.3.3 Generate volume mounts in compose file for config folders
|
|
||||||
- [ ] 2.3.4 Apply project overrides during resolution
|
|
||||||
- [ ] 2.3.5 Write config folder files to `instance_dir/volumes/`
|
|
||||||
|
|
||||||
### 2.4 Readiness Probe Service
|
|
||||||
- [ ] 2.4.1 Create `services/readiness_probe.py`
|
|
||||||
- [ ] 2.4.2 Implement `execute_probe(container_id, probe_config)` function
|
|
||||||
- [ ] 2.4.3 Implement polling loop with timeout and interval
|
|
||||||
- [ ] 2.4.4 Store probe output/logs on instance
|
|
||||||
- [ ] 2.4.5 Update instance status based on probe result ("running" or "failed")
|
|
||||||
- [ ] 2.4.6 Handle probe command failures gracefully
|
|
||||||
|
|
||||||
### 2.5 Instance Creation Integration
|
|
||||||
- [ ] 2.5.1 Update `create_instance` endpoint to use new fields
|
|
||||||
- [ ] 2.5.2 Integrate dockerfile build path into creation flow
|
|
||||||
- [ ] 2.5.3 Integrate config folder mounting
|
|
||||||
- [ ] 2.5.4 Integrate readiness probe execution
|
|
||||||
- [ ] 2.5.5 Apply port_override if specified
|
|
||||||
- [ ] 2.5.6 Apply start_command if specified
|
|
||||||
- [ ] 2.5.7 Apply working_directory if specified
|
|
||||||
- [ ] 2.5.8 Apply environment_variables from ToolConfig
|
|
||||||
- [ ] 2.5.9 Apply volumes from ToolConfig
|
|
||||||
- [ ] 2.5.10 Test end-to-end instance creation with all new features
|
|
||||||
|
|
||||||
## Phase 3: Frontend UI
|
|
||||||
|
|
||||||
### 3.1 API Client Updates
|
|
||||||
- [ ] 3.1.1 Update `api/tool_types.ts` with new fields and endpoints
|
|
||||||
- [ ] 3.1.2 Update `api/tool_configs.ts` with new fields
|
|
||||||
- [ ] 3.1.3 Create `api/config_folders.ts` with all CRUD operations
|
|
||||||
- [ ] 3.1.4 Update TypeScript types/interfaces
|
|
||||||
|
|
||||||
### 3.2 Tool Workshop Layout
|
|
||||||
- [ ] 3.2.1 Create `pages/tool-workshop.tsx` (replaces tool-configs and tool-types)
|
|
||||||
- [ ] 3.2.2 Implement split-pane layout (sidebar + main content)
|
|
||||||
- [ ] 3.2.3 Create sidebar navigation tree (Tool Types / Config Folders)
|
|
||||||
- [ ] 3.2.4 Implement tab switching (Tool Types / Configs / Config Folders)
|
|
||||||
- [ ] 3.2.5 Add responsive design (collapsible sidebar on mobile)
|
|
||||||
- [ ] 3.2.6 Update App.tsx routing
|
|
||||||
|
|
||||||
### 3.3 Tool Type Builder
|
|
||||||
- [ ] 3.3.1 Create `components/ToolTypeBuilder.tsx`
|
|
||||||
- [ ] 3.3.2 Implement definition type selector (Compose vs Dockerfile)
|
|
||||||
- [ ] 3.3.3 Create compose template editor (textarea with YAML highlighting)
|
|
||||||
- [ ] 3.3.4 Create dockerfile editor (textarea with Dockerfile highlighting)
|
|
||||||
- [ ] 3.3.5 Add build context file manager
|
|
||||||
- [ ] 3.3.6 Add readiness probe configuration (command, timeout, interval)
|
|
||||||
- [ ] 3.3.7 Add validation feedback (syntax check)
|
|
||||||
- [ ] 3.3.8 Implement create/update/delete operations
|
|
||||||
|
|
||||||
### 3.4 Config Editor Enhancement
|
|
||||||
- [ ] 3.4.1 Update config form with new fields
|
|
||||||
- [ ] 3.4.2 Add port override input (integer, 1-65535)
|
|
||||||
- [ ] 3.4.3 Add start command input
|
|
||||||
- [ ] 3.4.4 Add working directory input
|
|
||||||
- [ ] 3.4.5 Create environment variables editor (key-value table)
|
|
||||||
- [ ] 3.4.6 Create volumes editor (source/target/type table)
|
|
||||||
- [ ] 3.4.7 Add JSON validation for env vars and volumes
|
|
||||||
- [ ] 3.4.8 Implement tabbed sections (Basic / Runtime / Advanced)
|
|
||||||
|
|
||||||
### 3.5 Config Folder Manager
|
|
||||||
- [ ] 3.5.1 Create `components/ConfigFolderManager.tsx`
|
|
||||||
- [ ] 3.5.2 Implement folder list view
|
|
||||||
- [ ] 3.5.3 Create folder editor (name, description, mount_path)
|
|
||||||
- [ ] 3.5.4 Create file manager (add/edit/delete files with path and content)
|
|
||||||
- [ ] 3.5.5 Implement file content editor (textarea with syntax highlighting)
|
|
||||||
- [ ] 3.5.6 Create project override manager
|
|
||||||
- [ ] 3.5.7 Add active/inactive toggle
|
|
||||||
- [ ] 3.5.8 Show folder size indicator
|
|
||||||
|
|
||||||
### 3.6 Navigation Updates
|
|
||||||
- [ ] 3.6.1 Update header/navigation to link to `/tool-workshop`
|
|
||||||
- [ ] 3.6.2 Remove old `/tool-configs` and `/tool-types` routes (or redirect)
|
|
||||||
- [ ] 3.6.3 Update breadcrumb navigation if applicable
|
|
||||||
|
|
||||||
## Phase 4: Integration & Testing
|
|
||||||
|
|
||||||
### 4.1 Backend Testing
|
|
||||||
- [ ] 4.1.1 Test config folder CRUD operations
|
|
||||||
- [ ] 4.1.2 Test config folder project overrides
|
|
||||||
- [ ] 4.1.3 Test tool type creation with dockerfile
|
|
||||||
- [ ] 4.1.4 Test tool type creation with compose
|
|
||||||
- [ ] 4.1.5 Test readiness probe execution (success case)
|
|
||||||
- [ ] 4.1.6 Test readiness probe execution (timeout case)
|
|
||||||
- [ ] 4.1.7 Test instance creation with config folders mounted
|
|
||||||
- [ ] 4.1.8 Test instance creation with port override
|
|
||||||
- [ ] 4.1.9 Test instance creation with volumes
|
|
||||||
- [ ] 4.1.10 Test 10MB size limit enforcement
|
|
||||||
|
|
||||||
### 4.2 Frontend Testing
|
|
||||||
- [ ] 4.2.1 Test Tool Workshop page load
|
|
||||||
- [ ] 4.2.2 Test tool type creation flow
|
|
||||||
- [ ] 4.2.3 Test config folder creation and file management
|
|
||||||
- [ ] 4.2.4 Test config editor with all new fields
|
|
||||||
- [ ] 4.2.5 Test responsive layout on mobile
|
|
||||||
- [ ] 4.2.6 Test form validation (port range, JSON structure)
|
|
||||||
|
|
||||||
### 4.3 End-to-End Testing
|
|
||||||
- [ ] 4.3.1 Create a new tool type with dockerfile, start instance
|
|
||||||
- [ ] 4.3.2 Create a new tool type with compose, start instance
|
|
||||||
- [ ] 4.3.3 Create config folder, mount into instance, verify files present
|
|
||||||
- [ ] 4.3.4 Add project override, verify different files in different projects
|
|
||||||
- [ ] 4.3.5 Test readiness probe with failing command (should mark failed)
|
|
||||||
- [ ] 4.3.6 Test readiness probe with succeeding command (should mark running)
|
|
||||||
|
|
||||||
### 4.4 Quality Gates
|
|
||||||
- [ ] 4.4.1 Run backend linting (ruff)
|
|
||||||
- [ ] 4.4.2 Run backend type checking (mypy)
|
|
||||||
- [ ] 4.4.3 Run frontend type checking (tsc)
|
|
||||||
- [ ] 4.4.4 Run frontend linting (eslint)
|
|
||||||
- [ ] 4.4.5 Build frontend and verify no errors
|
|
||||||
- [ ] 4.4.6 Run existing tests to ensure no regressions
|
|
||||||
- [ ] 4.4.7 Verify backward compatibility (existing instances still work)
|
|
||||||
|
|
||||||
## Phase 5: Documentation & Deployment
|
|
||||||
|
|
||||||
### 5.1 Documentation
|
|
||||||
- [ ] 5.1.1 Update API documentation (OpenAPI/Swagger annotations)
|
|
||||||
- [ ] 5.1.2 Add tool workshop user guide
|
|
||||||
- [ ] 5.1.3 Document config folder usage
|
|
||||||
- [ ] 5.1.4 Document readiness probe configuration
|
|
||||||
- [ ] 5.1.5 Add example dockerfile and compose templates
|
|
||||||
|
|
||||||
### 5.2 Migration & Deployment
|
|
||||||
- [ ] 5.2.1 Verify database migrations run cleanly on existing data
|
|
||||||
- [ ] 5.2.2 Update seed data for built-in tool types (add definition_type)
|
|
||||||
- [ ] 5.2.3 Test fresh install (no existing data)
|
|
||||||
- [ ] 5.2.4 Commit all changes with conventional commit messages
|
|
||||||
- [ ] 5.2.5 Create comprehensive PR description
|
|
||||||
|
|
||||||
## Quality Gates Summary
|
|
||||||
|
|
||||||
**Before completing this change:**
|
|
||||||
- All migrations must run successfully
|
|
||||||
- Backend linting and type checking must pass
|
|
||||||
- Frontend build must succeed with no errors
|
|
||||||
- All new API endpoints must be tested
|
|
||||||
- At least one end-to-end test for each new feature
|
|
||||||
- No regressions in existing instance creation flow
|
|
||||||
- Documentation updated
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-22
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# UI Redesign - Design
|
||||||
|
|
||||||
|
## Information Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
App
|
||||||
|
├── Home
|
||||||
|
│ ├── Hero / status
|
||||||
|
│ ├── Open sessions
|
||||||
|
│ ├── Available projects
|
||||||
|
│ └── Session creation
|
||||||
|
├── Projects
|
||||||
|
├── Settings
|
||||||
|
│ ├── General
|
||||||
|
│ ├── SSH Keys
|
||||||
|
│ ├── Tool Types
|
||||||
|
│ └── Tool Configs
|
||||||
|
└── Legacy routes
|
||||||
|
└── Redirect to new locations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Home Page
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provide a fast, glanceable overview of the user's active work.
|
||||||
|
|
||||||
|
### Sections
|
||||||
|
|
||||||
|
1. **Hero**
|
||||||
|
- Greeting
|
||||||
|
- Short status line
|
||||||
|
- Primary actions: New Project, Open Session, Settings
|
||||||
|
|
||||||
|
2. **Summary strip**
|
||||||
|
- Small count cards for sessions, projects, and tooling state
|
||||||
|
|
||||||
|
3. **Open Sessions**
|
||||||
|
- Primary section
|
||||||
|
- Session cards with project, repository, tool type, status, and actions
|
||||||
|
|
||||||
|
4. **Available Projects**
|
||||||
|
- Secondary section
|
||||||
|
- Project cards with quick entry into the project workspace
|
||||||
|
|
||||||
|
5. **Session composer**
|
||||||
|
- Optional compact create flow if it fits the page cleanly
|
||||||
|
|
||||||
|
## Settings Page
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
Tabbed shell with one content area and four tabs:
|
||||||
|
|
||||||
|
- General
|
||||||
|
- SSH Keys
|
||||||
|
- Tool Types
|
||||||
|
- Tool Configs
|
||||||
|
|
||||||
|
### Tab Responsibilities
|
||||||
|
|
||||||
|
**General**
|
||||||
|
- Theme
|
||||||
|
- Git identity
|
||||||
|
- Default editor
|
||||||
|
|
||||||
|
**SSH Keys**
|
||||||
|
- List keys
|
||||||
|
- Create key
|
||||||
|
- Copy public key
|
||||||
|
- Delete key
|
||||||
|
|
||||||
|
**Tool Types**
|
||||||
|
- Browse tool catalog
|
||||||
|
- Edit custom tool types
|
||||||
|
- Delete custom tool types
|
||||||
|
|
||||||
|
**Tool Configs**
|
||||||
|
- Browse per-tool configurations
|
||||||
|
- Add/edit/delete configs
|
||||||
|
- Keep the existing config model and API behavior
|
||||||
|
|
||||||
|
## Visual Direction
|
||||||
|
|
||||||
|
- Font: Inter for UI text
|
||||||
|
- Code font: monospace only for technical fields
|
||||||
|
- Palette: warm light surfaces, forest green primary, muted utility accents
|
||||||
|
- Dark mode: charcoal surfaces with softened accents
|
||||||
|
- Styling: editorial, structured, high-contrast hierarchy, minimal chrome
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
- `/` -> Home
|
||||||
|
- `/sessions` -> redirect to `/`
|
||||||
|
- `/settings` -> General tab
|
||||||
|
- `/settings/ssh-keys` -> SSH Keys tab
|
||||||
|
- `/settings/tool-types` -> Tool Types tab
|
||||||
|
- `/settings/tool-configs` -> Tool Configs tab
|
||||||
|
- legacy `/ssh-keys`, `/tool-types`, `/tool-configs` -> redirect to settings tabs
|
||||||
|
|
||||||
|
## Component Strategy
|
||||||
|
|
||||||
|
- Reuse shell and existing APIs
|
||||||
|
- Replace the dashboard page with the new home overview
|
||||||
|
- Convert the settings layout into a shared tab shell
|
||||||
|
- Keep changes focused to the frontend layer
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# UI Redesign: Home + Settings
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current authenticated UI is functional but fragmented. Sessions, tool setup, and settings are spread across top-level pages, and the home screen does not yet provide a strong overview of open sessions and available projects.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Redesign the authenticated frontend around two primary surfaces:
|
||||||
|
|
||||||
|
1. **Home**: an overview of open sessions and available projects
|
||||||
|
2. **Settings**: a tabbed settings hub with General, SSH Keys, Tool Types, and Tool Configs
|
||||||
|
|
||||||
|
Keep existing functionality and the Project -> Repository -> Session hierarchy intact. Reuse the current APIs and workflows.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Redesign the main landing page into an operational overview
|
||||||
|
- Fold the Sessions page into the home experience
|
||||||
|
- Convert SSH Keys, Tool Types, and Tool Configs into settings tabs
|
||||||
|
- Update navigation and routes to match the new IA
|
||||||
|
- Refresh visual design, typography, and spacing
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No backend behavior changes
|
||||||
|
- No new session or project APIs
|
||||||
|
- No changes to the project/repository/session data model
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- Home shows open sessions and available projects clearly
|
||||||
|
- Settings contains tabs for General, SSH Keys, Tool Types, Tool Configs
|
||||||
|
- Old top-level settings-related routes redirect to the new structure
|
||||||
|
- Visual system uses Inter and a refined warm palette
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# UI Redesign - Tasks
|
||||||
|
|
||||||
|
## 1. Visual System
|
||||||
|
|
||||||
|
- [ ] Update global typography to Inter
|
||||||
|
- [ ] Refine color tokens for the new warm editorial palette
|
||||||
|
- [ ] Add styling for new home sections and settings tabs
|
||||||
|
|
||||||
|
## 2. Navigation and Routing
|
||||||
|
|
||||||
|
- [ ] Remove Sessions from top-level navigation
|
||||||
|
- [ ] Keep SSH Keys, Tool Types, and Tool Configs accessible from Settings tabs
|
||||||
|
- [ ] Add redirects for legacy top-level config routes
|
||||||
|
- [ ] Redirect `/sessions` to `/`
|
||||||
|
|
||||||
|
## 3. Home Page
|
||||||
|
|
||||||
|
- [ ] Redesign the home page as an overview of open sessions and projects
|
||||||
|
- [ ] Add summary cards and hero actions
|
||||||
|
- [ ] Reuse existing session and project data
|
||||||
|
- [ ] Keep create/open session actions available
|
||||||
|
|
||||||
|
## 4. Settings Hub
|
||||||
|
|
||||||
|
- [ ] Turn Settings into a tabbed hub
|
||||||
|
- [ ] Build General, SSH Keys, Tool Types, and Tool Configs tabs
|
||||||
|
- [ ] Reuse existing APIs and forms
|
||||||
|
- [ ] Keep the Project settings page separate
|
||||||
|
|
||||||
|
## 5. Cleanup and Verification
|
||||||
|
|
||||||
|
- [ ] Remove obsolete top-level pages from navigation flow
|
||||||
|
- [ ] Update tests for the new landing page and redirects
|
||||||
|
- [ ] Run typecheck, lint, and build
|
||||||
Reference in New Issue
Block a user