feat: complete phase 5 -- error hardening, frontend, Docker, demo, docs

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
This commit is contained in:
Yaojia Wang
2026-03-31 21:20:06 +02:00
parent 38644594d2
commit 0e78e5b06b
44 changed files with 3397 additions and 169 deletions

View File

@@ -0,0 +1,89 @@
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",
},
};