From e0931daece1229ff1f7506eff93187c524d9f611 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 5 Apr 2026 23:06:00 +0200 Subject: [PATCH] feat: wire frontend pages to live APIs and standardize response contracts (P1) - 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 --- backend/app/replay/api.py | 15 +- backend/tests/e2e/conftest.py | 3 + backend/tests/e2e/test_replay_analytics.py | 13 +- backend/tests/unit/replay/test_api.py | 55 +++-- frontend/src/api.ts | 6 +- frontend/src/pages/DashboardPage.tsx | 136 ++++++------ frontend/src/pages/ReplayListPage.tsx | 229 +++++++++++---------- frontend/src/pages/ReplayPage.tsx | 103 +++++---- 8 files changed, 327 insertions(+), 233 deletions(-) diff --git a/backend/app/replay/api.py b/backend/app/replay/api.py index bb1a8d4..4681b69 100644 --- a/backend/app/replay/api.py +++ b/backend/app/replay/api.py @@ -14,6 +14,10 @@ if TYPE_CHECKING: router = APIRouter(prefix="/api", tags=["replay"]) +_COUNT_CONVERSATIONS_SQL = """ +SELECT COUNT(*) FROM conversations +""" + _LIST_CONVERSATIONS_SQL = """ SELECT thread_id, created_at, last_activity, status, total_tokens, total_cost_usd FROM conversations @@ -48,13 +52,22 @@ async def list_conversations( pool = await get_pool(request) offset = (page - 1) * per_page async with pool.connection() as conn: + count_cursor = await conn.execute(_COUNT_CONVERSATIONS_SQL) + count_row = await count_cursor.fetchone() + total = count_row[0] if count_row else 0 + cursor = await conn.execute( _LIST_CONVERSATIONS_SQL, {"limit": per_page, "offset": offset}, ) rows = await cursor.fetchall() - return _envelope([dict(row) for row in rows]) + return _envelope({ + "conversations": [dict(row) for row in rows], + "total": total, + "page": page, + "per_page": per_page, + }) @router.get("/replay/{thread_id}") diff --git a/backend/tests/e2e/conftest.py b/backend/tests/e2e/conftest.py index 421fd08..98ebdb6 100644 --- a/backend/tests/e2e/conftest.py +++ b/backend/tests/e2e/conftest.py @@ -107,6 +107,9 @@ class FakeCursor: async def fetchall(self) -> list[dict]: return self._rows + async def fetchone(self) -> tuple | dict | None: + return self._rows[0] if self._rows else None + class FakeConnection: """Fake async connection that returns a FakeCursor.""" diff --git a/backend/tests/e2e/test_replay_analytics.py b/backend/tests/e2e/test_replay_analytics.py index 919e041..26d7724 100644 --- a/backend/tests/e2e/test_replay_analytics.py +++ b/backend/tests/e2e/test_replay_analytics.py @@ -44,6 +44,8 @@ class ReplayPool(FakePool): async def execute(self, query: str, params=None): from tests.e2e.conftest import FakeCursor + if "COUNT" in query and "conversations" in query: + return FakeCursor([(len(self._convos),)]) if "conversations" in query and "SELECT" in query: return FakeCursor(self._convos) if "checkpoints" in query: @@ -94,9 +96,11 @@ class TestFlow6ReplayConversation: assert resp.status_code == 200 body = resp.json() assert body["success"] is True - assert len(body["data"]) == 2 - assert body["data"][0]["thread_id"] == "conv-001" - assert body["data"][1]["thread_id"] == "conv-002" + data = body["data"] + assert len(data["conversations"]) == 2 + assert data["conversations"][0]["thread_id"] == "conv-001" + assert data["conversations"][1]["thread_id"] == "conv-002" + assert data["total"] == 2 def test_list_conversations_pagination(self) -> None: conversations = [ @@ -206,7 +210,8 @@ class TestFullUserJourney: body = resp.json() assert body["success"] is True assert any( - c["thread_id"] == "e2e-journey-1" for c in body["data"] + c["thread_id"] == "e2e-journey-1" + for c in body["data"]["conversations"] ) # Step 3: Health check still works diff --git a/backend/tests/unit/replay/test_api.py b/backend/tests/unit/replay/test_api.py index 412938e..c2e179b 100644 --- a/backend/tests/unit/replay/test_api.py +++ b/backend/tests/unit/replay/test_api.py @@ -19,13 +19,35 @@ def _build_app() -> FastAPI: return app -def _make_mock_pool(fetchall_result: list[dict]) -> MagicMock: - """Build a mock pool that returns the given rows from fetchall.""" - mock_cursor = AsyncMock() - mock_cursor.fetchall = AsyncMock(return_value=fetchall_result) +def _make_mock_pool( + fetchall_result: list[dict], + *, + count: int | None = None, +) -> MagicMock: + """Build a mock pool that returns the given rows from fetchall. - mock_conn = AsyncMock() - mock_conn.execute = AsyncMock(return_value=mock_cursor) + When *count* is provided, the first execute() call returns a cursor + whose fetchone() yields ``(count,)`` (for the COUNT query) and the + second call returns the rows via fetchall(). When *count* is None + (the default), a single cursor backed by *fetchall_result* is used + for all calls. + """ + if count is not None: + count_cursor = AsyncMock() + count_cursor.fetchone = AsyncMock(return_value=(count,)) + + rows_cursor = AsyncMock() + rows_cursor.fetchall = AsyncMock(return_value=fetchall_result) + + mock_conn = AsyncMock() + mock_conn.execute = AsyncMock(side_effect=[count_cursor, rows_cursor]) + else: + mock_cursor = AsyncMock() + mock_cursor.fetchall = AsyncMock(return_value=fetchall_result) + mock_cursor.fetchone = AsyncMock(return_value=None) + + mock_conn = AsyncMock() + mock_conn.execute = AsyncMock(return_value=mock_cursor) mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_conn) @@ -39,14 +61,17 @@ def _make_mock_pool(fetchall_result: list[dict]) -> MagicMock: class TestListConversations: def test_returns_200_with_empty_list(self) -> None: app = _build_app() - app.state.pool = _make_mock_pool([]) + app.state.pool = _make_mock_pool([], count=0) with TestClient(app) as client: resp = client.get("/api/conversations") assert resp.status_code == 200 body = resp.json() assert body["success"] is True - assert isinstance(body["data"], list) + data = body["data"] + assert isinstance(data["conversations"], list) + assert data["total"] == 0 + assert data["page"] == 1 assert body["error"] is None def test_returns_conversations_list(self) -> None: @@ -61,18 +86,20 @@ class TestListConversations: "total_cost_usd": 0.01, } ] - app.state.pool = _make_mock_pool(mock_rows) + app.state.pool = _make_mock_pool(mock_rows, count=1) with TestClient(app) as client: resp = client.get("/api/conversations") body = resp.json() assert resp.status_code == 200 - assert len(body["data"]) == 1 - assert body["data"][0]["thread_id"] == "t1" + data = body["data"] + assert len(data["conversations"]) == 1 + assert data["conversations"][0]["thread_id"] == "t1" + assert data["total"] == 1 def test_pagination_defaults(self) -> None: app = _build_app() - app.state.pool = _make_mock_pool([]) + app.state.pool = _make_mock_pool([], count=0) with TestClient(app) as client: resp = client.get("/api/conversations") @@ -80,7 +107,7 @@ class TestListConversations: def test_pagination_custom_params(self) -> None: app = _build_app() - app.state.pool = _make_mock_pool([]) + app.state.pool = _make_mock_pool([], count=0) with TestClient(app) as client: resp = client.get("/api/conversations?page=2&per_page=10") @@ -88,7 +115,7 @@ class TestListConversations: def test_per_page_max_capped_at_100(self) -> None: app = _build_app() - app.state.pool = _make_mock_pool([]) + app.state.pool = _make_mock_pool([], count=0) with TestClient(app) as client: resp = client.get("/api/conversations?per_page=200") diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c94b667..4a146fa 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -10,13 +10,11 @@ export interface ApiResponse { export interface ConversationSummary { thread_id: string; - started_at: string; + created_at: string; last_activity: string; - turn_count: number; - agents_used: string[]; + status: string | null; total_tokens: number; total_cost_usd: number; - resolution_type: string | null; } export interface ConversationsPage { diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 9628f84..39d1f2d 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { fetchAnalytics, AnalyticsData } from "../api"; const RANGE_OPTIONS = [ { value: "7d", label: "7 days" }, @@ -6,36 +7,19 @@ const RANGE_OPTIONS = [ { value: "30d", label: "30 days" }, ]; -// Mock Data -const MOCK_DATA = { - total_conversations: 4208, - resolution_rate: 0.724, - escalation_rate: 0.276, - avg_turns_per_conversation: 3.4, - total_tokens: 1450200, - total_cost_usd: 12.45, - agent_usage: [ - { agent_name: "Order Specialist", message_count: 8540, total_tokens: 854000, total_cost_usd: 7.20 }, - { agent_name: "Billing Assistant", message_count: 3120, total_tokens: 412000, total_cost_usd: 3.50 }, - { agent_name: "Router & Orchestrator", message_count: 4208, total_tokens: 184200, total_cost_usd: 1.75 }, - ], - interrupt_stats: { - total: 412, - approved: 380, - rejected: 28, - expired: 4, - } -}; - export function DashboardPage() { const [range, setRange] = useState("30d"); const [isLoading, setIsLoading] = useState(true); - const data = MOCK_DATA; + const [data, setData] = useState(null); + const [error, setError] = useState(null); useEffect(() => { setIsLoading(true); - const timer = setTimeout(() => setIsLoading(false), 1200); - return () => clearTimeout(timer); + setError(null); + fetchAnalytics(range) + .then((result) => setData(result)) + .catch((err: Error) => setError(err.message)) + .finally(() => setIsLoading(false)); }, [range]); function pct(value: number): string { @@ -80,7 +64,7 @@ export function DashboardPage() { {isLoading ? ( <>
- {[1, 2, 3, 4, 5].map(i => ( + {[1, 2, 3, 4].map(i => (
@@ -93,38 +77,52 @@ export function DashboardPage() {
+ ) : error ? ( +
+

Failed to load analytics

+

{error}

+ +
+ ) : !data ? ( +
+

No analytics data available

+

Start some conversations to see metrics here.

+
) : ( <>
- - - - - + + = 0.7} /> + +
{/* Agent Workload Table */}

Agent Workload Distribution

- - - - - - - - - - {data.agent_usage.map((a) => ( - e.currentTarget.style.backgroundColor = 'var(--bg-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}> - - - + {data.agent_usage.length === 0 ? ( +

No agent activity recorded yet.

+ ) : ( +
Agent NameActions HandledCost Footprint
{a.agent_name}{a.message_count.toLocaleString()}{formatCost(a.total_cost_usd)}
+ + + + + - ))} - -
Agent NameMessage CountShare
+ + + {data.agent_usage.map((a) => ( + e.currentTarget.style.backgroundColor = 'var(--bg-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}> + {a.agent} + {a.count.toLocaleString()} + {pct(a.percentage)} + + ))} + + + )}
{/* Human in the loop card */} @@ -136,24 +134,28 @@ export function DashboardPage() {

Breakdown of supervisor responses to High-Risk Action Cards dynamically requested by Agents.

- -
-
- Action Approved - {data.interrupt_stats.approved} -
-
-
-
-
- Action Rejected (Escalated) - {data.interrupt_stats.rejected} + {data.interrupt_stats.total === 0 ? ( +

No interrupt events recorded yet.

+ ) : ( +
+
+ Action Approved + {data.interrupt_stats.approved} +
+
+
+
+ +
+ Action Rejected (Escalated) + {data.interrupt_stats.rejected} +
+
+
+
-
-
-
-
+ )}
@@ -165,10 +167,10 @@ export function DashboardPage() { function MetricBox({ label, value, trend, positive }: { label: string, value: string | number, trend: string, positive?: boolean }) { return ( -
([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 (
@@ -22,104 +44,103 @@ export function ReplayListPage() {

Conversation Replay

Review autonomous agent sessions and audit MCP action execution trails.

-
- -
-
- - - - - - - - - - - - {MOCK_CONVERSATIONS.map((c, i) => ( - navigate(`/replay/${c.thread_id}`)} - style={{ - borderBottom: i === MOCK_CONVERSATIONS.length - 1 ? "none" : "1px solid var(--border-light)", - cursor: "pointer", - transition: "background-color 0.2s" - }} - className="replay-row-hover" - > - - - - - + {error ? ( +
+

Failed to load conversations

+

{error}

+ +
+ ) : isLoading ? ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+
+
+ ))} +
+ ) : conversations.length === 0 ? ( +
+

No conversations yet

+

Start a chat session to see conversations here.

+
+ ) : ( +
+
ThreadDetected IntentAgents InvokedOutcomePerformance
-
{c.user}
-
{c.thread_id}
-
-
{c.intent}
-
{c.date}
-
-
- {c.agents.map(a => ( - - {a} - - ))} -
-
- - {c.status} - - {c.hitl && 🔒} - - {c.turns} turns • {c.cost} -
+ + + + + + + - ))} - -
ThreadCreatedLast ActivityStatusCost
- -
- Showing 1-5 of 120 sessions -
- - + + + {conversations.map((c, i) => ( + 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" + > + +
{c.thread_id}
+ + + {formatDate(c.created_at)} + + + {formatDate(c.last_activity)} + + + + {c.status ?? "active"} + + + + {c.total_tokens.toLocaleString()} tokens / {formatCost(c.total_cost_usd)} + + + ))} + + + +
+ + Showing {(page - 1) * perPage + 1}-{Math.min(page * perPage, total)} of {total} sessions + +
+ + +
-
+ )}