Files
headquarter/apps/web/src/pages/dashboard.tsx
T
Alex Blank ab79080f0b refactor: consolidate loading/error states and extract instance actions hook
Frontend:
- Create reusable DataStates components (LoadingState, ErrorState, EmptyState)
- Refactor 12 pages to use shared state components instead of inline JSX
- Extract useInstanceActions hook to eliminate session action duplication
- Update dashboard and sessions pages to use shared hook

OpenSpec:
- Archive completed mobile-app-usability change (44/44 tasks)
- Archive completed add-config-profiles change (15/15 tasks)

Quality: TypeScript check passes, production build succeeds
2026-05-25 22:50:18 +02:00

268 lines
9.3 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { getUserSessions, 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 { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions";
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 [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]);
const {
loadingSessionId: actionBusy,
handleOpen,
handleStart,
handleStop,
handleDelete,
handleRecreateTunnel,
} = useInstanceActions({ onRefresh: 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 handleCreateSuccess = async (instance: { id: string }) => {
await updateUserConfig({ last_session_id: instance.id });
setSelectedProject("");
await loadHome();
};
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" && <LoadingState message="Loading overview..." />}
{status === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
{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>{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}</h2>
</div>
</div>
<SessionList
sessions={safeSessions}
onOpen={handleOpen}
onStop={handleStop}
onDelete={handleDelete}
onRecreateTunnel={handleRecreateTunnel}
actionBusyId={actionBusy}
tunnelHealth={tunnelHealth}
showGrouping={false}
emptyMessage="No active sessions right now."
/>
</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 ? (
<EmptyState message="No projects yet." />
) : (
<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>
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Recent sessions</p>
<h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2>
</div>
</div>
<SessionList
sessions={safeSessions}
onOpen={handleOpen}
onStart={handleStart}
onDelete={handleDelete}
actionBusyId={actionBusy}
showGrouping={false}
maxRecent={5}
emptyMessage="No recent sessions."
/>
</section>
)}
</>
)}
</section>
);
};
export { HomePage as DashboardPage };