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:
Fusion
2026-05-20 16:49:31 +02:00
parent e985f0122e
commit 6ec35988cc
13 changed files with 669 additions and 35 deletions
+110
View File
@@ -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",
+54
View File
@@ -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),
}
+22
View File
@@ -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;
}
+113 -17
View File
@@ -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
View File
@@ -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
+9 -1
View File
@@ -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 {