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:
133
frontend/src/pages/ReplayListPage.tsx
Normal file
133
frontend/src/pages/ReplayListPage.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchConversations } from "../api";
|
||||
import type { ConversationSummary } from "../api";
|
||||
|
||||
export function ReplayListPage() {
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const perPage = 20;
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchConversations(page, perPage)
|
||||
.then((data) => {
|
||||
setConversations(data.conversations);
|
||||
setTotal(data.total);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page]);
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.center}>Loading conversations...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div style={styles.error}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.heading}>Conversations</h2>
|
||||
{conversations.length === 0 ? (
|
||||
<div style={styles.empty}>No conversations yet.</div>
|
||||
) : (
|
||||
<>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Thread ID</th>
|
||||
<th style={styles.th}>Started</th>
|
||||
<th style={styles.th}>Turns</th>
|
||||
<th style={styles.th}>Agents</th>
|
||||
<th style={styles.th}>Resolution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conversations.map((c) => (
|
||||
<tr
|
||||
key={c.thread_id}
|
||||
onClick={() => navigate(`/replay/${c.thread_id}`)}
|
||||
style={styles.row}
|
||||
>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.threadId}>{c.thread_id}</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
{new Date(c.started_at).toLocaleString()}
|
||||
</td>
|
||||
<td style={styles.td}>{c.turn_count}</td>
|
||||
<td style={styles.td}>{c.agents_used.join(", ") || "—"}</td>
|
||||
<td style={styles.td}>{c.resolution_type ?? "open"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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}
|
||||
</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: "1000px", margin: "0 auto" },
|
||||
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "16px" },
|
||||
center: { padding: "48px", textAlign: "center", color: "#888" },
|
||||
error: { padding: "24px", color: "#c62828" },
|
||||
empty: { color: "#888", fontSize: "14px" },
|
||||
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px" },
|
||||
th: {
|
||||
textAlign: "left",
|
||||
padding: "8px 12px",
|
||||
borderBottom: "2px solid #e0e0e0",
|
||||
color: "#555",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
fontSize: "11px",
|
||||
letterSpacing: "0.5px",
|
||||
},
|
||||
td: { padding: "10px 12px", borderBottom: "1px solid #f0f0f0" },
|
||||
row: { cursor: "pointer", transition: "background 0.1s" },
|
||||
threadId: { fontFamily: "monospace", fontSize: "12px", color: "#1976d2" },
|
||||
pagination: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
marginTop: "16px",
|
||||
},
|
||||
pageBtn: {
|
||||
padding: "6px 14px",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: "4px",
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user