- Backend: Add COUNT query and paginated response shape to conversations endpoint
Returns { conversations: [...], total, page, per_page } instead of flat array
- Frontend: Replace mock data in DashboardPage with fetchAnalytics() API calls
- Frontend: Replace mock data in ReplayListPage with fetchConversations() API calls
- Frontend: Replace mock data in ReplayPage with fetchReplay() API calls
- Add proper loading, empty, and error states to all three pages
- Align ConversationSummary type with actual DB columns (created_at, status)
- Update unit and E2E tests for new paginated conversation response shape
- Add fetchone() to FakeCursor for COUNT query support in E2E tests
152 lines
7.2 KiB
TypeScript
152 lines
7.2 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { fetchConversations, ConversationSummary } from "../api";
|
|
|
|
export function ReplayListPage() {
|
|
const navigate = useNavigate();
|
|
const [page, setPage] = useState(1);
|
|
const [perPage] = useState(20);
|
|
const [total, setTotal] = useState(0);
|
|
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
fetchConversations(page, perPage)
|
|
.then((result) => {
|
|
setConversations(result.conversations);
|
|
setTotal(result.total);
|
|
})
|
|
.catch((err: Error) => setError(err.message))
|
|
.finally(() => setIsLoading(false));
|
|
}, [page, perPage]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
|
|
|
function formatDate(iso: string): string {
|
|
try {
|
|
return new Date(iso).toLocaleString();
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function formatCost(usd: number): string {
|
|
return `$${usd.toFixed(2)}`;
|
|
}
|
|
|
|
return (
|
|
<div className="page-container">
|
|
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}>
|
|
<div>
|
|
<h2>Conversation Replay</h2>
|
|
<p>Review autonomous agent sessions and audit MCP action execution trails.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{error ? (
|
|
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}>
|
|
<p style={{ fontSize: "1.125rem", fontWeight: 600, color: "var(--brand-accent)" }}>Failed to load conversations</p>
|
|
<p style={{ marginTop: "0.5rem" }}>{error}</p>
|
|
<button onClick={() => setPage(1)} className="btn btn-secondary" style={{ marginTop: "1rem" }}>Retry</button>
|
|
</div>
|
|
) : isLoading ? (
|
|
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", overflow: "hidden", border: "1px solid var(--border-light)", padding: "2rem" }}>
|
|
{[1, 2, 3, 4, 5].map(i => (
|
|
<div key={i} className="skeleton-box" style={{ height: "60px", marginBottom: "1rem", borderRadius: "8px" }}>
|
|
<div className="skeleton-text" style={{ width: "30%", height: "14px", margin: "12px 16px" }}></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : conversations.length === 0 ? (
|
|
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}>
|
|
<p style={{ fontSize: "1.125rem", fontWeight: 600 }}>No conversations yet</p>
|
|
<p style={{ marginTop: "0.5rem" }}>Start a chat session to see conversations here.</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", overflow: "hidden", border: "1px solid var(--border-light)" }}>
|
|
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}>
|
|
<thead>
|
|
<tr style={{ backgroundColor: "var(--bg-surface-inner)", borderBottom: "1px solid var(--border-light)" }}>
|
|
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Thread</th>
|
|
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Created</th>
|
|
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Last Activity</th>
|
|
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Status</th>
|
|
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Cost</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{conversations.map((c, i) => (
|
|
<tr
|
|
key={c.thread_id}
|
|
onClick={() => navigate(`/replay/${c.thread_id}`)}
|
|
style={{
|
|
borderBottom: i === conversations.length - 1 ? "none" : "1px solid var(--border-light)",
|
|
cursor: "pointer",
|
|
transition: "background-color 0.2s"
|
|
}}
|
|
className="replay-row-hover"
|
|
>
|
|
<td style={{ padding: "1.25rem 1.5rem" }}>
|
|
<div style={{ fontWeight: 600, color: "var(--text-primary)", fontSize: "0.9375rem", fontFamily: "monospace" }}>{c.thread_id}</div>
|
|
</td>
|
|
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}>
|
|
{formatDate(c.created_at)}
|
|
</td>
|
|
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}>
|
|
{formatDate(c.last_activity)}
|
|
</td>
|
|
<td style={{ padding: "1.25rem 1.5rem" }}>
|
|
<span style={{
|
|
fontSize: "0.75rem",
|
|
padding: "4px 10px",
|
|
borderRadius: "6px",
|
|
fontWeight: 600,
|
|
backgroundColor: c.status === "resolved" ? "#DEF7EC" : c.status === "escalated" ? "#FDE8E8" : "var(--bg-hover)",
|
|
color: c.status === "resolved" ? "#03543F" : c.status === "escalated" ? "#9B1C1C" : "var(--text-secondary)",
|
|
}}>
|
|
{c.status ?? "active"}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}>
|
|
{c.total_tokens.toLocaleString()} tokens / {formatCost(c.total_cost_usd)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div style={{ padding: "1.25rem 1.5rem", borderTop: "1px solid var(--border-light)", display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "var(--bg-surface-inner)" }}>
|
|
<span style={{ fontSize: "0.875rem", color: "var(--text-secondary)" }}>
|
|
Showing {(page - 1) * perPage + 1}-{Math.min(page * perPage, total)} of {total} sessions
|
|
</span>
|
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setPage(p => Math.max(1, p - 1)) }}
|
|
disabled={page === 1}
|
|
className="btn btn-secondary"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setPage(p => Math.min(totalPages, p + 1)) }}
|
|
disabled={page >= totalPages}
|
|
className="btn btn-secondary"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<style>{`
|
|
.replay-row-hover:hover {
|
|
background-color: var(--bg-hover) !important;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|