aee3987c24
- Extract FileBrowser from inline definition in repo-workspace.tsx - Create components/features/git/FileBrowser.tsx with module CSS - Create reusable UI primitives: LoadingState, ErrorState, StatusBadge - Create barrel exports for components/ui/ and components/features/git/ - Replace inline loading/error patterns in dashboard, sessions, repo-workspace Quality gates: tsc (pass), eslint (pass) Refs: repo-restructure Task 1.2
668 lines
18 KiB
TypeScript
668 lines
18 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import { listProjects } from "../api/projects";
|
|
import { listRepositories } from "../api/git_repositories";
|
|
import {
|
|
getUserSessions,
|
|
deleteInstance,
|
|
stopInstance,
|
|
startInstance,
|
|
checkInstanceHealth,
|
|
recreateInstanceTunnel,
|
|
createInstance,
|
|
} from "../api/sessions";
|
|
import { listToolTypes } from "../api/tool_types";
|
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
|
import type { Project } from "../types/project";
|
|
import type { Session } from "../types/session";
|
|
import type { GitRepository } from "../types/git-repository";
|
|
import type { ToolType } from "../types/tool-type";
|
|
import { Icon } from "../components/icon";
|
|
import { LoadingState } from "../components/ui";
|
|
import { ErrorState } from "../components/ui";
|
|
|
|
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; status_code: number | null; error?: string }
|
|
>
|
|
>({});
|
|
const [recreatingId, setRecreatingId] = 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 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([]);
|
|
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"].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" && (
|
|
<LoadingState message="Loading sessions..." />
|
|
)}
|
|
|
|
{status === "error" && (
|
|
<ErrorState
|
|
message="Failed to load sessions"
|
|
onRetry={() => void loadSessions()}
|
|
/>
|
|
)}
|
|
|
|
{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 ||
|
|
lastSession.tool_type_name ||
|
|
"Unnamed Session"}
|
|
</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 ||
|
|
session.tool_type_name ||
|
|
"Unnamed Session"}
|
|
</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>
|
|
{tunnelHealth[session.id] &&
|
|
!tunnelHealth[session.id].healthy && (
|
|
<span className="status-badge error">
|
|
tunnel error
|
|
</span>
|
|
)}
|
|
</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].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
|
|
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 ||
|
|
session.tool_type_name ||
|
|
"Unnamed Session"}
|
|
</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>
|
|
);
|
|
};
|