Files
headquarter/apps/web/src/pages/sessions.tsx
T
Fusion d13e16f5e1 feat(health-monitoring): complete instance health monitoring implementation
Backend:
- Container startup verification with docker inspect polling
- Readiness probe integration with ToolType configuration
- Enhanced health endpoint checking container + tunnel status
- Smart tunnel recovery distinguishing connection errors vs HTTP errors
- New status states: starting, probing, unhealthy

Frontend:
- Updated status badges for new states (starting, probing, unhealthy)
- Show tunnel error only when tunnel_status is unreachable
- Show app error badge with status code for error_response
- Add collapsible probe output section for diagnostics
- Only show Recreate Tunnel button for unreachable tunnels

Quality Gates:
- Frontend type checking: PASSED
- Frontend build: PASSED
- Backend unit tests: 56 passed

Addresses instance-health-monitoring OpenSpec change
2026-05-22 21:28:45 +02:00

649 lines
23 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
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,
checkInstanceHealth,
recreateInstanceTunnel,
} from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { createInstance } from "../api/sessions";
import { getUserConfig, updateUserConfig } from "../api/settings";
import { Icon } from "../components/icon";
type SessionsStatus = "loading" | "ready" | "error";
type CreateStatus = "idle" | "creating" | "error";
export const SessionsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<SessionsStatus>("loading");
const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState<string>("");
const [selectedRepo, setSelectedRepo] = useState<string>("");
const [selectedToolType, setSelectedToolType] = useState<string>("");
const [displayName, setDisplayName] = useState("");
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
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;
container_status: string;
container_health: string | null;
tunnel_status: string;
tunnel_status_code: number | null;
probe_status: string;
last_probe_output: string | null;
error: string | null;
}>>({});
const [recreatingId, setRecreatingId] = useState<string | null>(null);
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setStatus("loading");
try {
const [sessionsData, config] = await Promise.all([
getUserSessions(),
getUserConfig(),
]);
setSessions(sessionsData);
setLastSessionId(config.last_session_id ?? null);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadSessions();
}, [loadSessions]);
useEffect(() => {
const loadProjects = async () => {
try {
const data = await listProjects();
setProjects(data);
} catch {
// ignore
}
};
void loadProjects();
}, []);
useEffect(() => {
const loadToolTypes = async () => {
try {
const data = await listToolTypes();
setToolTypes(data);
} catch {
// ignore
}
};
void loadToolTypes();
}, []);
// Poll health every 30 seconds for active instances
useEffect(() => {
const checkHealth = async () => {
const activeSessions = sessions.filter(
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
);
for (const session of activeSessions) {
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,
container_status: "unknown",
container_health: null,
tunnel_status: "unreachable",
tunnel_status_code: null,
probe_status: "unknown",
last_probe_output: 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([]);
return;
}
const loadRepos = async () => {
try {
const data = await listRepositories(selectedProject);
setRepositories(data);
} catch {
setRepositories([]);
}
};
void loadRepos();
}, [selectedProject]);
const activeSessions = useMemo(
() => sessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)),
[sessions]
);
const recentSessions = useMemo(
() => sessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5),
[sessions]
);
const lastSession = useMemo(
() => sessions.find((s) => s.id === lastSessionId) ?? null,
[sessions, lastSessionId]
);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setCreateError(null);
if (!selectedProject || !selectedRepo || !selectedToolType) {
setCreateError("Project, repository, and tool type are required");
return;
}
setCreateStatus("creating");
try {
const instance = await createInstance(
selectedProject,
selectedRepo,
selectedToolType,
displayName || undefined
);
// Auto-start the instance
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setCreateStatus("idle");
setSelectedProject("");
setSelectedRepo("");
setSelectedToolType("");
setDisplayName("");
await loadSessions();
} catch {
setCreateStatus("error");
setCreateError("Failed to create session");
}
};
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
try {
await stopInstance(projectId, repoId, sessionId);
setStopConfirmId(null);
await loadSessions();
} catch {
setStopConfirmId(null);
}
};
const handleDelete = async (sessionId: string, projectId: string, repoId: string) => {
try {
await deleteInstance(projectId, repoId, sessionId);
setDeleteConfirmId(null);
// 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');
} else if (session.tool_type_interfaces?.includes("terminal")) {
navigate(`/instances/${session.id}/terminal`);
} else {
navigate(`/projects/${session.project_id}`);
}
};
const handleResumeLast = async () => {
if (!lastSession) return;
// Find the project and repo IDs
const project = projects.find((p) => p.name === lastSession.project_name);
if (project) {
navigate(`/projects/${project.id}`);
}
};
return (
<section className="stack sessions-page">
<div className="page-header">
<h1>Sessions</h1>
</div>
{status === "loading" && <p className="muted">Loading sessions...</p>}
{status === "error" && (
<div className="card stack">
<p>Failed to load sessions</p>
<button className="secondary-button" onClick={() => void loadSessions()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{status === "ready" && (
<>
{/* Last Session */}
{lastSession && (
<div className="last-session-section">
<h2>Last Session</h2>
<div className="card last-session-card">
<div className="last-session-info">
<h3>{lastSession.display_name}</h3>
<p className="muted">
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
</p>
{lastSession.url && (
<p className="session-url">
<a href={lastSession.url} target="_blank" rel="noopener noreferrer">
{lastSession.url}
</a>
</p>
)}
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
</div>
<div className="last-session-actions">
{lastSession.url ? (
<a
href={lastSession.url}
target="_blank"
rel="noopener noreferrer"
className="primary-button"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button className="primary-button" onClick={handleResumeLast} type="button">
<Icon name="play" size="sm" />
Resume
</button>
)}
</div>
</div>
</div>
)}
{/* Active Sessions */}
<div className="active-sessions-section">
<h2>
Active Sessions
{activeSessions.length > 0 && (
<span className="badge">{activeSessions.length}</span>
)}
</h2>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions</p>
) : (
<div className="sessions-grid">
{activeSessions.map((session) => (
<div className="card session-card" key={session.id}>
<div className="session-info">
<h4>{session.display_name}</h4>
<p className="muted">
{session.tool_type_name} · {session.project_name}
</p>
{session.url && (
<p className="session-url">
<a href={session.url} target="_blank" rel="noopener noreferrer">
{session.url}
</a>
</p>
)}
<span className={`status-badge ${session.status}`}>{session.status}</span>
{session.status === "starting" && (
<span className="status-badge starting">starting...</span>
)}
{session.status === "probing" && (
<span className="status-badge probing">checking...</span>
)}
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
<span className="status-badge error">tunnel error</span>
)}
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
)}
{tunnelHealth[session.id]?.last_probe_output && (
<div className="probe-output-section">
<button
className="probe-toggle"
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
type="button"
>
<Icon name="info" size="sm" />
{expandedProbeId === session.id ? "Hide probe output" : "Show probe output"}
</button>
{expandedProbeId === session.id && (
<pre className="probe-output">
{tunnelHealth[session.id].last_probe_output}
</pre>
)}
</div>
)}
</div>
<div className="session-actions">
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button
className="secondary-button small"
onClick={() => handleOpen(session)}
type="button"
>
<Icon name="external" size="sm" />
Open
</button>
)}
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
<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
className="danger-button small"
onClick={() =>
void handleDelete(
session.id,
session.project_id,
session.repository_id
)
}
type="button"
>
Delete
</button>
<button
className="ghost-button small"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={() => setDeleteConfirmId(session.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Recent Sessions */}
{recentSessions.length > 0 && (
<div className="recent-sessions-section">
<h2>Recent Sessions</h2>
<div className="recent-sessions-list">
{recentSessions.map((session) => (
<div className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">{session.display_name}</span>
<span className="muted">
{session.tool_type_name} · {session.project_name}
</span>
</div>
<div className="recent-session-actions">
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="ghost-button small"
>
Open
</a>
) : (
<button
className="ghost-button small"
onClick={() => handleOpen(session)}
type="button"
>
Open
</button>
)}
{deleteConfirmId === session.id ? (
<div className="delete-confirm-inline">
<button
className="danger-button small"
onClick={() =>
void handleDelete(
session.id,
session.project_id,
session.repository_id
)
}
type="button"
>
Delete
</button>
<button
className="ghost-button small"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={() => setDeleteConfirmId(session.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Create Session */}
<div className="create-session-section">
<h2>Create New Session</h2>
<form onSubmit={handleCreate} className="card stack create-session-form">
<div className="form-row">
<label className="form-field">
Project
<select
value={selectedProject}
onChange={(e) => {
setSelectedProject(e.target.value);
setSelectedRepo("");
}}
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="form-field">
Repository
<select
value={selectedRepo}
onChange={(e) => setSelectedRepo(e.target.value)}
disabled={!selectedProject}
>
<option value="">Select repository...</option>
{repositories.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</label>
<label className="form-field">
Tool Type
<select
value={selectedToolType}
onChange={(e) => setSelectedToolType(e.target.value)}
>
<option value="">Select tool...</option>
{toolTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.display_name}
</option>
))}
</select>
</label>
</div>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
{createError && <p className="error-text">{createError}</p>}
<div className="form-actions">
<button
className="primary-button"
type="submit"
disabled={createStatus === "creating"}
>
{createStatus === "creating" ? (
<>
<Icon name="loading" size="sm" />
Creating...
</>
) : (
<>
<Icon name="add" size="sm" />
Create Session
</>
)}
</button>
</div>
</form>
</div>
</>
)}
</section>
);
};