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
+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