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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user