381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
|
import { 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";
|
|
import { updateUserConfig } from "../api/settings";
|
|
import type { Project } from "../types";
|
|
import { Icon } from "../components/icon";
|
|
import { CreateSessionForm } from "../components/create-session-form";
|
|
|
|
type HomeStatus = "loading" | "ready" | "error";
|
|
|
|
const summaryCards = [
|
|
{ label: "Open sessions", key: "openSessions" },
|
|
{ label: "Projects", key: "projects" },
|
|
{ label: "Repositories", key: "repositories" },
|
|
] as const;
|
|
|
|
type SessionView = SessionApi;
|
|
|
|
export const HomePage = () => {
|
|
const navigate = useNavigate();
|
|
const [status, setStatus] = useState<HomeStatus>("loading");
|
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
|
const [sessions, setSessions] = useState<SessionView[]>([]);
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
|
const [selectedProject, setSelectedProject] = useState("");
|
|
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 () => {
|
|
setStatus("loading");
|
|
try {
|
|
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
|
|
getDashboardSummary(),
|
|
getUserSessions(),
|
|
listProjects(),
|
|
listToolTypes(),
|
|
]);
|
|
setSummary(dashboard);
|
|
setSessions(sessionData as SessionView[]);
|
|
setProjects(projectData);
|
|
setToolTypes(toolTypeData);
|
|
setStatus("ready");
|
|
} catch {
|
|
setStatus("error");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
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([]);
|
|
return;
|
|
}
|
|
|
|
const loadRepos = async () => {
|
|
try {
|
|
const data = await listRepositories(selectedProject);
|
|
setRepositories(data);
|
|
} catch {
|
|
setRepositories([]);
|
|
}
|
|
};
|
|
|
|
void loadRepos();
|
|
}, [selectedProject]);
|
|
|
|
const activeSessions = useMemo(
|
|
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
|
|
[safeSessions]
|
|
);
|
|
|
|
const recentSessions = useMemo(
|
|
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
|
|
[safeSessions]
|
|
);
|
|
|
|
const handleCreateSuccess = async (instance: { id: string }) => {
|
|
await updateUserConfig({ last_session_id: instance.id });
|
|
setSelectedProject("");
|
|
await loadHome();
|
|
};
|
|
|
|
const handleOpen = (session: SessionView) => {
|
|
if (session.url) {
|
|
window.open(session.url, "_blank", "noopener,noreferrer");
|
|
return;
|
|
}
|
|
if (session.tool_type_interfaces.includes("terminal")) {
|
|
navigate(`/instances/${session.id}/terminal`);
|
|
return;
|
|
}
|
|
navigate(`/projects/${session.project_id}`);
|
|
};
|
|
|
|
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();
|
|
} finally {
|
|
setActionBusy(null);
|
|
}
|
|
};
|
|
|
|
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);
|
|
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
|
} catch {
|
|
// error - session remains in state
|
|
} finally {
|
|
setActionBusy(null);
|
|
}
|
|
};
|
|
|
|
const handleRecreateTunnel = async (session: SessionView) => {
|
|
setActionBusy(session.id);
|
|
try {
|
|
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
|
await loadHome();
|
|
} finally {
|
|
setActionBusy(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section className="stack home-page">
|
|
<header className="home-hero card">
|
|
<div className="stack-sm">
|
|
<p className="eyebrow">Workspace overview</p>
|
|
<h1>Home</h1>
|
|
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
|
</div>
|
|
<div className="home-hero-actions">
|
|
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
|
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
|
</div>
|
|
</header>
|
|
|
|
{status === "loading" && <p className="muted">Loading overview...</p>}
|
|
|
|
{status === "error" && (
|
|
<div className="card stack">
|
|
<p>Unable to load your workspace overview.</p>
|
|
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
|
<Icon name="refresh" size="sm" />
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{status === "ready" && summary && (
|
|
<>
|
|
<div className="home-summary-grid">
|
|
{summaryCards.map((card) => (
|
|
<article className="card home-summary-card" key={card.label}>
|
|
<p className="card-label">{card.label}</p>
|
|
<p className="card-value">
|
|
{card.key === "openSessions"
|
|
? activeSessions.length
|
|
: card.key === "projects"
|
|
? summary.projects
|
|
: summary.repositories}
|
|
</p>
|
|
</article>
|
|
))}
|
|
</div>
|
|
|
|
<section className="card stack home-section">
|
|
<div className="page-header">
|
|
<div>
|
|
<p className="eyebrow">Open sessions</p>
|
|
<h2>{activeSessions.length}</h2>
|
|
</div>
|
|
</div>
|
|
{activeSessions.length === 0 ? (
|
|
<p className="muted">No active sessions right now.</p>
|
|
) : (
|
|
<div className="home-session-grid">
|
|
{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>
|
|
<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>
|
|
|
|
<section className="card stack home-section">
|
|
<div className="page-header">
|
|
<div>
|
|
<p className="eyebrow">Available projects</p>
|
|
<h2>{projects.length}</h2>
|
|
</div>
|
|
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
|
</div>
|
|
{projects.length === 0 ? (
|
|
<p className="muted">No projects yet.</p>
|
|
) : (
|
|
<div className="home-project-grid">
|
|
{projects.map((project) => (
|
|
<article className="card project-card home-project-card" key={project.id}>
|
|
<div className="stack-sm">
|
|
<h3>{project.name}</h3>
|
|
{project.description && <p className="muted">{project.description}</p>}
|
|
</div>
|
|
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
|
|
Open Workspace
|
|
</button>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="card stack home-section">
|
|
<div className="page-header">
|
|
<div>
|
|
<p className="eyebrow">Quick create</p>
|
|
<h2>Start a session</h2>
|
|
</div>
|
|
</div>
|
|
<CreateSessionForm
|
|
projects={projects}
|
|
repositories={repositories}
|
|
toolTypes={toolTypes}
|
|
onProjectChange={(projectId) => setSelectedProject(projectId)}
|
|
onSuccess={handleCreateSuccess}
|
|
/>
|
|
</section>
|
|
|
|
{recentSessions.length > 0 && (
|
|
<section className="card stack home-section">
|
|
<div className="page-header">
|
|
<div>
|
|
<p className="eyebrow">Recent sessions</p>
|
|
<h2>{recentSessions.length}</h2>
|
|
</div>
|
|
</div>
|
|
<div className="recent-sessions-list">
|
|
{recentSessions.map((session) => (
|
|
<article 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.project_name} · {session.tool_type_name}</span>
|
|
</div>
|
|
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { HomePage as DashboardPage };
|