feat: session management fixes and sessions hub
- Add confirmation dialogs for stop/delete on dashboard - Filter deleted sessions immediately without reload - Add tunnel health polling with error badges - Add Sessions nav item with active count badge - Route /sessions to SessionsPage component Quality gates: 43/43 tests pass, typecheck pass, lint pass Refs: openspec/changes/session-management-fixes Refs: openspec/changes/sessions-hub
This commit is contained in:
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
|
||||
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
@@ -34,6 +34,9 @@ export const HomePage = () => {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
const loadHome = useCallback(async () => {
|
||||
@@ -59,6 +62,49 @@ export const HomePage = () => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const runningSessions = safeSessions.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,
|
||||
container_status: "unknown",
|
||||
container_health: null,
|
||||
container_exit_code: null,
|
||||
tunnel_status: "error",
|
||||
tunnel_status_code: null,
|
||||
probe_status: "error",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => {
|
||||
void checkHealth();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [safeSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
@@ -120,7 +166,12 @@ export const HomePage = () => {
|
||||
};
|
||||
|
||||
const handleStop = async (session: SessionView) => {
|
||||
if (stopConfirmId !== session.id) {
|
||||
setStopConfirmId(session.id);
|
||||
return;
|
||||
}
|
||||
setActionBusy(session.id);
|
||||
setStopConfirmId(null);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
@@ -130,10 +181,17 @@ export const HomePage = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async (session: SessionView) => {
|
||||
if (deleteConfirmId !== session.id) {
|
||||
setDeleteConfirmId(session.id);
|
||||
return;
|
||||
}
|
||||
setActionBusy(session.id);
|
||||
setDeleteConfirmId(null);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||
} catch {
|
||||
// error - session remains in state
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
@@ -203,36 +261,65 @@ export const HomePage = () => {
|
||||
<p className="muted">No active sessions right now.</p>
|
||||
) : (
|
||||
<div className="home-session-grid">
|
||||
{activeSessions.map((session) => (
|
||||
<article className="card session-card" key={session.id}>
|
||||
<div className="stack-sm">
|
||||
<div className="row row-tight">
|
||||
<h3>{session.display_name}</h3>
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
{activeSessions.map((session) => {
|
||||
const health = tunnelHealth[session.id];
|
||||
const isUnhealthy = health && !health.healthy;
|
||||
return (
|
||||
<article className="card session-card" key={session.id}>
|
||||
<div className="stack-sm">
|
||||
<div className="row row-tight">
|
||||
<h3>{session.display_name}</h3>
|
||||
<div className="row row-tight">
|
||||
{isUnhealthy && (
|
||||
<span className="status-badge error" title={health.error || "unhealthy"}>!</span>
|
||||
)}
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
||||
<p className="muted">{session.tool_type_name}</p>
|
||||
</div>
|
||||
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
||||
<p className="muted">{session.tool_type_name}</p>
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Tunnel
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<div className="session-actions">
|
||||
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Tunnel
|
||||
</button>
|
||||
{stopConfirmId === session.id ? (
|
||||
<div className="stop-confirm-inline">
|
||||
<span className="confirm-text">Stop?</span>
|
||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="stop" size="sm" /> Stop
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => setStopConfirmId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
{deleteConfirmId === session.id ? (
|
||||
<div className="delete-confirm-inline">
|
||||
<span className="confirm-text">Delete?</span>
|
||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => setDeleteConfirmId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user