From 6ec35988cc217cd05ef3906f288574568cf2b093 Mon Sep 17 00:00:00 2001 From: Fusion Date: Wed, 20 May 2026 16:49:31 +0200 Subject: [PATCH] feat(sessions): add stop confirmation, health checks, and tunnel recreation - Add inline confirmation dialog before stopping instances - Delete instances from state immediately without page reload - Add health check polling every 30s for running instances - Show tunnel error badge when tunnel is unreachable - Add 'Fix Tunnel' button to recreate broken tunnels - Update API client with health check and tunnel recreation endpoints --- apps/api/src/api/tool_instances.py | 110 +++++++++++++++ apps/api/src/services/docker.py | 54 ++++++++ apps/web/src/api/sessions.ts | 22 +++ apps/web/src/components/instance-list.tsx | 130 +++++++++++++++--- apps/web/src/pages/sessions.tsx | 130 +++++++++++++++--- apps/web/src/styles.css | 10 +- .../session-management-fixes/.openspec.yaml | 2 + .../session-management-fixes/design.md | 62 +++++++++ .../session-management-fixes/proposal.md | 27 ++++ .../specs/session-lifecycle-ux/spec.md | 34 +++++ .../specs/tool-instances/spec.md | 46 +++++++ .../specs/tunnel-health-monitoring/spec.md | 35 +++++ .../changes/session-management-fixes/tasks.md | 42 ++++++ 13 files changed, 669 insertions(+), 35 deletions(-) create mode 100644 openspec/changes/session-management-fixes/.openspec.yaml create mode 100644 openspec/changes/session-management-fixes/design.md create mode 100644 openspec/changes/session-management-fixes/proposal.md create mode 100644 openspec/changes/session-management-fixes/specs/session-lifecycle-ux/spec.md create mode 100644 openspec/changes/session-management-fixes/specs/tool-instances/spec.md create mode 100644 openspec/changes/session-management-fixes/specs/tunnel-health-monitoring/spec.md create mode 100644 openspec/changes/session-management-fixes/tasks.md diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 94f0bd9..e751764 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -23,10 +23,12 @@ from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType from src.models.user import User from src.services.docker import ( + check_tunnel_health, ensure_instance_directory, execute_compose_command, find_free_port, get_container_id, + recreate_tunnel, start_cloudflared_tunnel, stop_cloudflared_tunnel, get_container_name, @@ -691,6 +693,114 @@ async def get_instance_logs( return {"logs": logs} +@router.post( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel", + summary="Recreate tunnel", + description="Recreate the temporary Cloudflare tunnel for a running instance.", +) +async def recreate_tunnel_endpoint( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Recreate the temporary tunnel for an instance. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the instance. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary with new URL and status. + """ + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + ) + + if instance.status != "running": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="instance must be running to recreate tunnel", + ) + + # Get tool type for default port + tool_type = await session.get(ToolType, instance.tool_type_id) + instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080 + + try: + tunnel_info = recreate_tunnel( + container_name=instance.container_name or instance.name, + port=instance_port, + old_pid=instance.tunnel_id, + ) + instance.tunnel_id = tunnel_info["pid"] + instance.public_url = tunnel_info["url"] + instance.url = tunnel_info["url"] + await session.commit() + logger.info( + "Recreated tunnel for instance %s: pid=%s, url=%s", + instance.id, + tunnel_info["pid"], + tunnel_info["url"], + ) + return {"status": "healthy", "url": instance.url} + except Exception as exc: + logger.exception("Failed to recreate tunnel for instance %s", instance.id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to recreate tunnel: {str(exc)}", + ) + + +@router.get( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/health", + summary="Check tunnel health", + description="Check if the temporary Cloudflare tunnel for an instance is healthy.", +) +async def check_instance_tunnel_health( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Check tunnel health for an instance. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the instance. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary with health status. + """ + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + ) + + if not instance.url or instance.status != "running": + return {"healthy": False, "status_code": None, "error": "instance not running"} + + health = check_tunnel_health(instance.url) + return health + + @router.get( "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", summary="Proxy to instance", diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index 9ccaaef..ca58298 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -329,3 +329,57 @@ def stop_cloudflared_tunnel(pid: str) -> None: os.kill(int(pid), signal.SIGTERM) except ProcessLookupError: pass # Already stopped + + +def recreate_tunnel( + container_name: str, port: int, old_pid: str | None = None +) -> dict[str, str]: + """Recreate a temporary Cloudflare tunnel. + + Stops the old tunnel (if pid provided) and starts a new one. + + Args: + container_name: Name of the Docker container to tunnel to + port: Port number the container listens on + old_pid: Optional PID of the old tunnel process to stop + + Returns: + Dict with 'url' and 'pid' for the new tunnel + """ + if old_pid: + stop_cloudflared_tunnel(old_pid) + + return start_cloudflared_tunnel(container_name, port) + + +def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, any]: + """Check if a tunnel URL is healthy. + + Args: + url: The tunnel URL to check + timeout: Request timeout in seconds + + Returns: + Dict with 'healthy' (bool) and 'status_code' (int or None) + """ + import subprocess + + try: + result = subprocess.run( + ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", + "--max-time", str(timeout), url], + capture_output=True, + text=True, + timeout=timeout + 5, + ) + status_code = int(result.stdout.strip()) + return { + "healthy": 200 <= status_code < 400, + "status_code": status_code, + } + except (ValueError, subprocess.TimeoutExpired, Exception) as e: + return { + "healthy": False, + "status_code": None, + "error": str(e), + } diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index 2f7071e..a404760 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -100,3 +100,25 @@ export async function getUserSessions(): Promise { const response = await apiClient.get("/users/me/sessions"); return response.data.sessions; } + +export async function checkInstanceHealth( + projectId: string, + repoId: string, + instanceId: string +): Promise<{ healthy: boolean; status_code: number | null; error?: string }> { + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health` + ); + return response.data; +} + +export async function recreateInstanceTunnel( + projectId: string, + repoId: string, + instanceId: string +): Promise<{ status: string; url?: string }> { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel` + ); + return response.data; +} diff --git a/apps/web/src/components/instance-list.tsx b/apps/web/src/components/instance-list.tsx index 3b7d43e..d9ec05a 100644 --- a/apps/web/src/components/instance-list.tsx +++ b/apps/web/src/components/instance-list.tsx @@ -3,9 +3,11 @@ import { useNavigate } from "react-router-dom"; import { Icon } from "./icon"; import type { ToolInstance } from "../api/sessions"; import { + checkInstanceHealth, createInstance, deleteInstance, listInstances, + recreateInstanceTunnel, restartInstance, startInstance, stopInstance, @@ -28,6 +30,12 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps const [selectedToolType, setSelectedToolType] = useState(""); const [displayName, setDisplayName] = useState(""); const [error, setError] = useState(null); + + // Stop confirmation + const [stopConfirmId, setStopConfirmId] = useState(null); + + // Health check state + const [healthStatus, setHealthStatus] = useState>({}); const loadInstances = useCallback(async () => { setLoading(true); @@ -45,6 +53,36 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps void loadInstances(); }, [loadInstances]); + // Health check polling + useEffect(() => { + const runningInstances = instances.filter(i => i.status === "running" && i.url?.startsWith("http")); + if (runningInstances.length === 0) return; + + const checkHealth = async () => { + for (const instance of runningInstances) { + try { + const health = await checkInstanceHealth(projectId, repoId, instance.id); + setHealthStatus(prev => ({ + ...prev, + [instance.id]: { healthy: health.healthy, lastCheck: Date.now() } + })); + } catch { + setHealthStatus(prev => ({ + ...prev, + [instance.id]: { healthy: false, lastCheck: Date.now() } + })); + } + } + }; + + // Check immediately + void checkHealth(); + + // Then every 30 seconds + const interval = setInterval(() => void checkHealth(), 30000); + return () => clearInterval(interval); + }, [instances, projectId, repoId]); + const handleCreate = async () => { if (!selectedToolType) return; setError(null); @@ -71,6 +109,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps const handleStop = async (instanceId: string) => { try { await stopInstance(projectId, repoId, instanceId); + setStopConfirmId(null); await loadInstances(); } catch { setError("Failed to stop instance"); @@ -90,12 +129,22 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps if (!confirm("Are you sure you want to delete this instance?")) return; try { await deleteInstance(projectId, repoId, instanceId); - await loadInstances(); + // Update state immediately instead of reloading + setInstances(prev => prev.filter(i => i.id !== instanceId)); } catch { setError("Failed to delete instance"); } }; + const handleRecreateTunnel = async (instanceId: string) => { + try { + await recreateInstanceTunnel(projectId, repoId, instanceId); + await loadInstances(); + } catch { + setError("Failed to recreate tunnel"); + } + }; + const getStatusColor = (status: string) => { switch (status) { case "running": @@ -110,6 +159,14 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps } }; + const isTunnelUnhealthy = (instance: ToolInstance) => { + if (instance.status !== "running") return false; + if (!instance.url?.startsWith("http")) return false; + const health = healthStatus[instance.id]; + if (!health) return false; + return !health.healthy; + }; + return (
@@ -144,19 +201,38 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps style={{ backgroundColor: getStatusColor(instance.status) }} /> {instance.status} + {isTunnelUnhealthy(instance) && ( + + + tunnel error + + )}
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && ( - - - Open - + <> + + + Open + + {isTunnelUnhealthy(instance) && ( + + )} + )} {instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && ( + {stopConfirmId === instance.id ? ( +
+ Stop? + + +
+ ) : ( + + )}
{session.url ? ( @@ -286,20 +351,51 @@ export const SessionsPage = () => { Open )} - + {tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && ( + + )} + {stopConfirmId === session.id ? ( +
+ Stop? + + +
+ ) : ( + + )} {deleteConfirmId === session.id ? (