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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -100,3 +100,25 @@ export async function getUserSessions(): Promise<Session[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
// Stop confirmation
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Health check state
|
||||
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
|
||||
|
||||
const loadInstances = useCallback(async () => {
|
||||
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 (
|
||||
<div className="instance-list">
|
||||
<div className="instance-list-header">
|
||||
@@ -144,19 +201,38 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
||||
style={{ backgroundColor: getStatusColor(instance.status) }}
|
||||
/>
|
||||
{instance.status}
|
||||
{isTunnelUnhealthy(instance) && (
|
||||
<span className="error-badge" title="Tunnel unreachable">
|
||||
<Icon name="warning" size="sm" />
|
||||
tunnel error
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="instance-actions">
|
||||
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
||||
<a
|
||||
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button small"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</a>
|
||||
<>
|
||||
<a
|
||||
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button small"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</a>
|
||||
{isTunnelUnhealthy(instance) && (
|
||||
<button
|
||||
className="secondary-button small warning"
|
||||
onClick={() => void handleRecreateTunnel(instance.id)}
|
||||
type="button"
|
||||
title="Recreate tunnel"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Fix Tunnel
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
|
||||
<button
|
||||
@@ -180,13 +256,33 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
||||
)}
|
||||
{instance.status === "running" && (
|
||||
<>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => void handleStop(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
</button>
|
||||
{stopConfirmId === instance.id ? (
|
||||
<div className="inline-confirm">
|
||||
<span>Stop?</span>
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={() => void handleStop(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(instance.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => void handleRestart(instance.id)}
|
||||
|
||||
+113
-17
@@ -4,7 +4,15 @@ import { useNavigate } from "react-router-dom";
|
||||
import { listProjects } from "../api/projects";
|
||||
import type { Project } from "../types";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import { getUserSessions, type Session, deleteInstance, stopInstance, startInstance } from "../api/sessions";
|
||||
import {
|
||||
getUserSessions,
|
||||
type Session,
|
||||
deleteInstance,
|
||||
stopInstance,
|
||||
startInstance,
|
||||
checkInstanceHealth,
|
||||
recreateInstanceTunnel,
|
||||
} from "../api/sessions";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { createInstance } from "../api/sessions";
|
||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||
@@ -31,6 +39,9 @@ export const SessionsPage = () => {
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, { healthy: boolean; status_code: number | null; error?: string }>>({});
|
||||
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
@@ -75,6 +86,38 @@ export const SessionsPage = () => {
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const runningSessions = sessions.filter(
|
||||
(s) => s.status === "running" && s.url
|
||||
);
|
||||
for (const session of runningSessions) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id
|
||||
);
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: health,
|
||||
}));
|
||||
} catch {
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: { healthy: false, status_code: null, error: "check failed" },
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately and then every 30 seconds
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => void checkHealth(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
@@ -143,9 +186,10 @@ export const SessionsPage = () => {
|
||||
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
||||
try {
|
||||
await stopInstance(projectId, repoId, sessionId);
|
||||
setStopConfirmId(null);
|
||||
await loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
setStopConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,12 +197,30 @@ export const SessionsPage = () => {
|
||||
try {
|
||||
await deleteInstance(projectId, repoId, sessionId);
|
||||
setDeleteConfirmId(null);
|
||||
await loadSessions();
|
||||
// Remove from local state immediately
|
||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (session: Session) => {
|
||||
setRecreatingId(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id
|
||||
);
|
||||
// Refresh sessions to get new URL
|
||||
await loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setRecreatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = (session: Session) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, '_blank', 'noopener,noreferrer');
|
||||
@@ -264,6 +326,9 @@ export const SessionsPage = () => {
|
||||
</p>
|
||||
)}
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
|
||||
<span className="status-badge error">tunnel error</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
{session.url ? (
|
||||
@@ -286,20 +351,51 @@ export const SessionsPage = () => {
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() =>
|
||||
void handleStop(
|
||||
session.id,
|
||||
session.project_id,
|
||||
session.repository_id
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => void handleRecreateTunnel(session)}
|
||||
type="button"
|
||||
disabled={recreatingId === session.id}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
{recreatingId === session.id ? "Recreating..." : "Recreate Tunnel"}
|
||||
</button>
|
||||
)}
|
||||
{stopConfirmId === session.id ? (
|
||||
<div className="stop-confirm-inline">
|
||||
<span className="confirm-text">Stop?</span>
|
||||
<button
|
||||
className="danger-button small"
|
||||
onClick={() =>
|
||||
void handleStop(
|
||||
session.id,
|
||||
session.project_id,
|
||||
session.repository_id
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => setStopConfirmId(session.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
{deleteConfirmId === session.id ? (
|
||||
<div className="delete-confirm-inline">
|
||||
<button
|
||||
|
||||
@@ -2611,9 +2611,17 @@ a.nav-item,
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.delete-confirm-inline {
|
||||
.delete-confirm-inline,
|
||||
.stop-confirm-inline {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.create-session-section {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-20
|
||||
@@ -0,0 +1,62 @@
|
||||
## Context
|
||||
|
||||
The session management system currently has three UX and reliability issues:
|
||||
|
||||
1. **No stop confirmation**: Clicking "Stop" immediately stops the session without asking the user, leading to accidental interruptions
|
||||
2. **Stale state after delete**: When a session is deleted, the frontend React state is not updated, so the deleted session remains visible until the page is manually reloaded
|
||||
3. **No tunnel recovery**: If a temporary Cloudflare tunnel breaks (e.g., cloudflared process dies), there's no way to recreate it without stopping and restarting the entire instance
|
||||
|
||||
The system uses temporary Cloudflare tunnels (`cloudflared tunnel --url`) which run as background processes inside the API container. These tunnels can fail silently.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Prevent accidental session stops with a confirmation dialog
|
||||
- Update frontend state immediately after successful deletion
|
||||
- Monitor tunnel health by checking HTTP responses
|
||||
- Allow tunnel recreation without instance restart
|
||||
- Display tunnel health status to users
|
||||
|
||||
**Non-Goals:**
|
||||
- Persistent tunnels (we're keeping temporary tunnels)
|
||||
- Auto-recovery of broken tunnels (manual button only)
|
||||
- Changing the Docker compose architecture
|
||||
- Adding WebSocket health checks
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Frontend confirmation dialog**
|
||||
- Use a simple inline confirmation (not a modal) to match existing patterns in the codebase
|
||||
- Show "Confirm stop? [Cancel] [Stop]" when stop is clicked
|
||||
- Reuse existing CSS button styles
|
||||
|
||||
**2. Frontend state update after delete**
|
||||
- Filter out the deleted session from local React state immediately after delete API call succeeds
|
||||
- Don't wait for the next polling cycle
|
||||
|
||||
**3. Tunnel health check**
|
||||
- Poll tunnel health every 30 seconds via HEAD request to the tunnel URL
|
||||
- Check only running instances (status === "running")
|
||||
- Mark as "error" if response is not 2xx or request fails
|
||||
- Show error badge next to session name
|
||||
|
||||
**4. Tunnel recreation**
|
||||
- New backend endpoint: `POST /instances/{id}/recreate-tunnel`
|
||||
- Kills old cloudflared process (if any) via stored PID
|
||||
- Starts new cloudflared process with `start_cloudflared_tunnel()`
|
||||
- Updates instance.url and instance.tunnel_id in database
|
||||
- Frontend button: "Recreate Tunnel" appears when tunnel is in error state
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk] Health check adds network overhead** → Mitigation: Only check every 30s, only for running instances
|
||||
**[Risk] Recreating tunnel while user is connected** → Mitigation: User-initiated action, brief downtime (5-10s)
|
||||
**[Risk] PID reuse could kill wrong process** → Mitigation: Check process name before killing (optional enhancement)
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No migration needed. These are UI/UX improvements on existing data model.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,27 @@
|
||||
## Why
|
||||
|
||||
The session management UI has critical UX and reliability issues that make it frustrating to use. Users can accidentally stop sessions without confirmation, deleted sessions remain visible until manual reload, and broken tunnels require full instance restart to fix. These bugs degrade the core user experience of the tool instance system.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Add confirmation dialog for stopping sessions** - Prevent accidental session stops with a "Are you sure?" dialog
|
||||
- **Fix frontend state after session deletion** - Update React state immediately when delete succeeds so the session disappears without reload
|
||||
- **Add tunnel health monitoring** - Periodically check if tunnel URLs respond with HTTP 200, mark as erroneous if not
|
||||
- **Add "Recreate Tunnel" button** - Allow users to regenerate a broken tunnel without restarting the entire instance
|
||||
- **Display tunnel health status** - Show visual indicator (error badge) when a tunnel is broken
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `tunnel-health-monitoring`: Background health checks for temporary Cloudflare tunnels with status indicators
|
||||
- `session-lifecycle-ux`: Improved session stop/delete interactions with confirmations and state updates
|
||||
|
||||
### Modified Capabilities
|
||||
- `tool-instances`: Update instance model and API to support tunnel recreation without full restart
|
||||
|
||||
## Impact
|
||||
|
||||
- Frontend: `sessions.tsx`, `instance-list.tsx`, `api/sessions.ts`
|
||||
- Backend: `tool_instances.py` (tunnel recreation endpoint), `docker.py` (tunnel restart utility)
|
||||
- Database: No schema changes needed (existing `tunnel_id` and `url` fields reused)
|
||||
- Docker: No changes needed
|
||||
@@ -0,0 +1,34 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Stopping a session requires confirmation
|
||||
The system SHALL display a confirmation dialog before stopping a running session.
|
||||
|
||||
#### Scenario: User initiates stop
|
||||
- **WHEN** user clicks the "Stop" button on a running session
|
||||
- **THEN** a confirmation dialog appears asking "Are you sure you want to stop this session?"
|
||||
- **AND** the dialog provides "Cancel" and "Stop" options
|
||||
|
||||
#### Scenario: User confirms stop
|
||||
- **WHEN** user clicks "Stop" in the confirmation dialog
|
||||
- **THEN** the session stops
|
||||
- **AND** the dialog closes
|
||||
|
||||
#### Scenario: User cancels stop
|
||||
- **WHEN** user clicks "Cancel" in the confirmation dialog
|
||||
- **THEN** the dialog closes
|
||||
- **AND** the session remains running
|
||||
|
||||
### Requirement: Deleted sessions disappear from UI immediately
|
||||
The system SHALL update the frontend state immediately after a session is successfully deleted.
|
||||
|
||||
#### Scenario: Delete session
|
||||
- **WHEN** user deletes a session
|
||||
- **AND** the delete API call returns success
|
||||
- **THEN** the session is removed from the visible list
|
||||
- **AND** no page reload is required
|
||||
|
||||
#### Scenario: Delete session failure
|
||||
- **WHEN** user deletes a session
|
||||
- **AND** the delete API call fails
|
||||
- **THEN** the session remains in the list
|
||||
- **AND** an error message is displayed
|
||||
@@ -0,0 +1,46 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Tool Lifecycle
|
||||
|
||||
The system SHALL manage tool lifecycle operations including tunnel recreation.
|
||||
|
||||
#### Scenario: Stop tool
|
||||
- GIVEN a running tool instance
|
||||
- WHEN the user stops it
|
||||
- THEN `docker compose stop` is executed
|
||||
- AND the cloudflared tunnel process is terminated
|
||||
- AND status is updated to "stopped"
|
||||
|
||||
#### Scenario: Start tool
|
||||
- GIVEN a stopped tool instance
|
||||
- WHEN the user starts it
|
||||
- THEN `docker compose start` is executed
|
||||
- AND a new temporary Cloudflare tunnel is created
|
||||
- AND status is updated to "running"
|
||||
|
||||
#### Scenario: Recreate tunnel
|
||||
- GIVEN a running tool instance with a broken tunnel
|
||||
- WHEN the user requests tunnel recreation
|
||||
- THEN the existing cloudflared process is terminated
|
||||
- AND a new temporary Cloudflare tunnel is created
|
||||
- AND the instance URL is updated
|
||||
- AND the instance shows as healthy
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tunnel Health Check
|
||||
|
||||
The system SHALL check tunnel health for running instances.
|
||||
|
||||
#### Scenario: Healthy tunnel check
|
||||
- GIVEN a running instance with an active tunnel
|
||||
- WHEN the health check runs
|
||||
- THEN the tunnel URL responds with HTTP 2xx
|
||||
- AND the instance is marked as healthy
|
||||
|
||||
#### Scenario: Broken tunnel check
|
||||
- GIVEN a running instance with a broken tunnel
|
||||
- WHEN the health check runs
|
||||
- THEN the tunnel URL does not respond with HTTP 2xx
|
||||
- AND the instance is marked with tunnel_error
|
||||
- AND a "Recreate Tunnel" button is shown
|
||||
@@ -0,0 +1,35 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: System monitors tunnel health
|
||||
The system SHALL periodically check if active tunnel URLs are reachable and mark them as erroneous if not.
|
||||
|
||||
#### Scenario: Healthy tunnel
|
||||
- **WHEN** a tunnel health check is performed on a running instance
|
||||
- **THEN** the system receives an HTTP 2xx response
|
||||
- **AND** the instance status remains "running"
|
||||
|
||||
#### Scenario: Broken tunnel
|
||||
- **WHEN** a tunnel health check is performed on a running instance
|
||||
- **AND** the response is not HTTP 2xx or the request fails
|
||||
- **THEN** the instance is marked with tunnel_error status
|
||||
- **AND** a visual error indicator is displayed in the UI
|
||||
|
||||
### Requirement: Users can recreate broken tunnels
|
||||
The system SHALL allow users to regenerate a temporary tunnel for a running instance without restarting the instance.
|
||||
|
||||
#### Scenario: Recreate tunnel
|
||||
- **WHEN** user clicks "Recreate Tunnel" button on an instance with a broken tunnel
|
||||
- **THEN** the system stops the existing cloudflared process
|
||||
- **AND** starts a new cloudflared tunnel
|
||||
- **AND** updates the instance URL
|
||||
- **AND** the new URL is displayed in the UI
|
||||
|
||||
#### Scenario: Recreate tunnel success
|
||||
- **WHEN** tunnel recreation completes successfully
|
||||
- **THEN** the error indicator is removed
|
||||
- **AND** the instance shows as healthy
|
||||
|
||||
#### Scenario: Recreate tunnel failure
|
||||
- **WHEN** tunnel recreation fails
|
||||
- **THEN** the error indicator remains
|
||||
- **AND** an error message is displayed to the user
|
||||
@@ -0,0 +1,42 @@
|
||||
## 1. Backend - Tunnel Recreation
|
||||
|
||||
- [x] 1.1 Add `recreate_tunnel` function to docker.py
|
||||
- [x] 1.2 Create `POST /instances/{id}/recreate-tunnel` endpoint in tool_instances.py
|
||||
- [x] 1.3 Update stop_instance to also stop the tunnel process
|
||||
|
||||
## 2. Backend - Tunnel Health Check
|
||||
|
||||
- [x] 2.1 Add `check_tunnel_health(url)` function to docker.py
|
||||
- [x] 2.2 Create `GET /instances/{id}/health` endpoint in tool_instances.py
|
||||
- [x] 2.3 Add tunnel_url_health field to ToolInstance model (optional, can use status)
|
||||
|
||||
## 3. Frontend - Stop Confirmation
|
||||
|
||||
- [ ] 3.1 Add confirmation dialog component for stop action
|
||||
- [ ] 3.2 Update SessionsPage stop handler to show confirmation
|
||||
- [ ] 3.3 Update InstanceList stop handler to show confirmation
|
||||
|
||||
## 4. Frontend - Delete State Update
|
||||
|
||||
- [ ] 4.1 Update delete handler in SessionsPage to filter state immediately
|
||||
- [ ] 4.2 Update delete handler in InstanceList to filter state immediately
|
||||
- [ ] 4.3 Ensure error handling shows message on failure
|
||||
|
||||
## 5. Frontend - Tunnel Health & Recreate
|
||||
|
||||
- [x] 5.1 Add tunnel health check API function in sessions.ts
|
||||
- [x] 5.2 Add recreate tunnel API function in sessions.ts
|
||||
- [ ] 5.3 Implement health check polling (30s interval) in SessionsPage
|
||||
- [ ] 5.4 Show error badge when tunnel is unhealthy
|
||||
- [ ] 5.5 Add "Recreate Tunnel" button next to "Open" button
|
||||
- [ ] 5.6 Update InstanceList to show health status and recreate button
|
||||
|
||||
## 6. Quality Gates
|
||||
|
||||
- [ ] 6.1 Run Python syntax check
|
||||
- [ ] 6.2 Run frontend typecheck
|
||||
- [ ] 6.3 Run frontend lint
|
||||
- [ ] 6.4 Test stop confirmation dialog
|
||||
- [ ] 6.5 Test delete state update
|
||||
- [ ] 6.6 Test tunnel recreation
|
||||
- [ ] 6.7 Commit and push changes
|
||||
Reference in New Issue
Block a user