ce1a73abce
- Add CSS styles for SessionCard and SessionList components - Add responsive styles for mobile viewport - Fix TypeScript errors (remove unused port property) - Fix ESLint errors (remove unused imports and variables) Refs: session-list-overhaul tasks 5-6
352 lines
11 KiB
TypeScript
352 lines
11 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,
|
|
checkInstanceHealth,
|
|
recreateInstanceTunnel,
|
|
} from "../api/sessions";
|
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
|
import { Icon } from "../components/icon";
|
|
import { CreateSessionForm } from "../components/create-session-form";
|
|
import { SessionList } from "../components/session-list";
|
|
import { SessionCard } from "../components/session-card";
|
|
import type { InstanceHealth } from "../api/sessions";
|
|
|
|
type SessionsStatus = "loading" | "ready" | "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 [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
|
|
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
|
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
|
const [loadingAction, setLoadingAction] = useState<string>("");
|
|
|
|
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 web-enabled instances
|
|
useEffect(() => {
|
|
const checkHealth = async () => {
|
|
const activeSessions = sessions.filter(
|
|
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
|
|
&& s.tool_type_interfaces?.includes("web")
|
|
);
|
|
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",
|
|
} as InstanceHealth,
|
|
}));
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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 lastSession = useMemo(
|
|
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
|
[sessions, lastSessionId]
|
|
);
|
|
|
|
const handleCreateSuccess = async (instance: { id: string }) => {
|
|
await updateUserConfig({ last_session_id: instance.id });
|
|
setSelectedProject("");
|
|
await loadSessions();
|
|
};
|
|
|
|
const handleStop = async (session: Session) => {
|
|
setLoadingSessionId(session.id);
|
|
setLoadingAction("Stopping...");
|
|
try {
|
|
await stopInstance(session.project_id, session.repository_id, session.id);
|
|
await loadSessions();
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setLoadingSessionId(null);
|
|
setLoadingAction("");
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (session: Session) => {
|
|
setLoadingSessionId(session.id);
|
|
setLoadingAction("Deleting...");
|
|
try {
|
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
|
setDirtyDeleteSession(null);
|
|
setDirtyDeleteFiles([]);
|
|
// Remove from local state immediately
|
|
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
|
} catch (error) {
|
|
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
|
if (axiosError.response?.status === 409) {
|
|
const detail = axiosError.response.data?.detail;
|
|
if (detail?.changed_files) {
|
|
setDirtyDeleteSession(session);
|
|
setDirtyDeleteFiles(detail.changed_files);
|
|
return;
|
|
}
|
|
}
|
|
} finally {
|
|
setLoadingSessionId(null);
|
|
setLoadingAction("");
|
|
}
|
|
};
|
|
|
|
const handleForceDelete = async (session: Session) => {
|
|
setLoadingSessionId(session.id);
|
|
setLoadingAction("Force deleting...");
|
|
try {
|
|
await deleteInstance(session.project_id, session.repository_id, session.id, true);
|
|
setDirtyDeleteSession(null);
|
|
setDirtyDeleteFiles([]);
|
|
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setLoadingSessionId(null);
|
|
setLoadingAction("");
|
|
}
|
|
};
|
|
|
|
const handleRecreateTunnel = async (session: Session) => {
|
|
setLoadingSessionId(session.id);
|
|
setLoadingAction("Recreating tunnel...");
|
|
try {
|
|
await recreateInstanceTunnel(
|
|
session.project_id,
|
|
session.repository_id,
|
|
session.id
|
|
);
|
|
// Refresh sessions to get new URL
|
|
await loadSessions();
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setLoadingSessionId(null);
|
|
setLoadingAction("");
|
|
}
|
|
};
|
|
|
|
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}`);
|
|
}
|
|
};
|
|
|
|
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>
|
|
<SessionCard
|
|
session={lastSession}
|
|
onOpen={handleOpen}
|
|
onDelete={handleDelete}
|
|
isBusy={loadingSessionId === lastSession.id}
|
|
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Session List */}
|
|
<div className={`sessions-list-wrapper ${loadingSessionId ? "dimmed" : ""}`}>
|
|
{loadingSessionId && (
|
|
<div className="loading-overlay">
|
|
<div className="loading-content">
|
|
<Icon name="loading" size="lg" />
|
|
<p>{loadingAction}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<SessionList
|
|
sessions={sessions}
|
|
onOpen={handleOpen}
|
|
onStop={handleStop}
|
|
onDelete={handleDelete}
|
|
onRecreateTunnel={handleRecreateTunnel}
|
|
actionBusyId={loadingSessionId}
|
|
tunnelHealth={tunnelHealth}
|
|
/>
|
|
</div>
|
|
|
|
{/* Create Session */}
|
|
<div className="create-session-section">
|
|
<h2>Create New Session</h2>
|
|
<CreateSessionForm
|
|
projects={projects}
|
|
repositories={repositories}
|
|
toolTypes={toolTypes}
|
|
onProjectChange={(projectId) => {
|
|
setSelectedProject(projectId);
|
|
}}
|
|
onSuccess={handleCreateSuccess}
|
|
/>
|
|
</div>
|
|
|
|
{/* Dirty Delete Confirmation Modal */}
|
|
{dirtyDeleteSession && (
|
|
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
|
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
|
<h3>Uncommitted Changes</h3>
|
|
<p>
|
|
The repository <strong>{dirtyDeleteSession.repository_name}</strong> has
|
|
uncommitted changes. Deleting this session will permanently lose these
|
|
changes.
|
|
</p>
|
|
<div className="changed-files-list">
|
|
<h4>Changed files:</h4>
|
|
<ul>
|
|
{dirtyDeleteFiles.map((file, idx) => (
|
|
<li key={idx}>{file}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div className="modal-actions">
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => setDirtyDeleteSession(null)}
|
|
type="button"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="danger-button"
|
|
onClick={() => void handleForceDelete(dirtyDeleteSession)}
|
|
type="button"
|
|
>
|
|
Force Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|