Backend: - ConversationTracker: Protocol + PostgresConversationTracker for lifecycle tracking - Error handler: ErrorCategory enum, classify_error(), with_retry() exponential backoff - Wire PostgresAnalyticsRecorder + ConversationTracker into ws_handler - Rate limiting (10 msg/10s per thread), edge case hardening - Health endpoint GET /api/health, version 0.5.0 - Demo seed data script + sample OpenAPI spec Frontend (all new): - React Router with NavBar (Chat / Replay / Dashboard / Review) - ReplayListPage + ReplayPage with ReplayTimeline component - DashboardPage with MetricCard, range selector, zero-state - ReviewPage for OpenAPI classification review - ErrorBanner for WebSocket disconnect handling - API client (api.ts) with typed fetch wrappers Infrastructure: - Frontend Dockerfile (multi-stage node -> nginx) - nginx.conf with SPA routing + API/WS proxy - docker-compose.yml with frontend service + healthchecks - .env.example files (root + backend) Documentation: - README.md with quick start and architecture - Agent configuration guide - OpenAPI import guide - Deployment guide - Demo script 48 new tests, 449 total passing, 92.87% coverage
90 lines
2.7 KiB
TypeScript
90 lines
2.7 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { fetchReplay } from "../api";
|
|
import type { ReplayStep } from "../api";
|
|
import { ReplayTimeline } from "../components/ReplayTimeline";
|
|
|
|
export function ReplayPage() {
|
|
const { threadId } = useParams<{ threadId: string }>();
|
|
const [steps, setSteps] = useState<ReplayStep[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const perPage = 20;
|
|
|
|
useEffect(() => {
|
|
if (!threadId) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
fetchReplay(threadId, page, perPage)
|
|
.then((data) => {
|
|
setSteps(data.steps);
|
|
setTotal(data.total);
|
|
})
|
|
.catch((err: Error) => setError(err.message))
|
|
.finally(() => setLoading(false));
|
|
}, [threadId, page]);
|
|
|
|
if (!threadId) {
|
|
return <div style={styles.error}>No thread ID provided.</div>;
|
|
}
|
|
|
|
const totalPages = Math.ceil(total / perPage);
|
|
|
|
return (
|
|
<div style={styles.container}>
|
|
<h2 style={styles.heading}>
|
|
Replay:{" "}
|
|
<span style={styles.threadId}>{threadId}</span>
|
|
</h2>
|
|
{loading && <div style={styles.center}>Loading replay...</div>}
|
|
{error && <div style={styles.error}>Error: {error}</div>}
|
|
{!loading && !error && <ReplayTimeline steps={steps} />}
|
|
{!loading && totalPages > 1 && (
|
|
<div style={styles.pagination}>
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
style={styles.pageBtn}
|
|
>
|
|
Previous
|
|
</button>
|
|
<span style={{ fontSize: "13px", color: "#555" }}>
|
|
Page {page} of {totalPages} ({total} steps)
|
|
</span>
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page >= totalPages}
|
|
style={styles.pageBtn}
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const styles: Record<string, React.CSSProperties> = {
|
|
container: { padding: "24px", maxWidth: "800px", margin: "0 auto" },
|
|
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "20px" },
|
|
threadId: { fontFamily: "monospace", fontSize: "16px", color: "#1976d2" },
|
|
center: { padding: "48px", textAlign: "center", color: "#888" },
|
|
error: { padding: "24px", color: "#c62828" },
|
|
pagination: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "12px",
|
|
marginTop: "20px",
|
|
},
|
|
pageBtn: {
|
|
padding: "6px 14px",
|
|
border: "1px solid #e0e0e0",
|
|
borderRadius: "4px",
|
|
background: "#fff",
|
|
cursor: "pointer",
|
|
fontSize: "13px",
|
|
},
|
|
};
|