From 862373503d8d4fba3d53b4fd021356f038bc309a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 18:11:04 +0000 Subject: [PATCH 1/3] Initial plan From 684278acc202b21c5aa8f167052611992bfb99cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 18:15:12 +0000 Subject: [PATCH 2/3] Add dashboard history and case APIs with frontend loading Agent-Logs-Url: https://github.com/Anuj-verse/fundGuard/sessions/3c83a29c-4361-461d-9aec-07c2636f4e3d Co-authored-by: Anuj-verse <182001728+Anuj-verse@users.noreply.github.com> --- services/dashboard-api/app/main.py | 97 +++++++++++++++++++++- services/dashboard-api/tests/test_api.py | 48 ++++++++++- services/dashboard/src/pages/Cases.tsx | 47 +++++++++-- services/dashboard/src/pages/Dashboard.tsx | 19 +++++ 4 files changed, 196 insertions(+), 15 deletions(-) diff --git a/services/dashboard-api/app/main.py b/services/dashboard-api/app/main.py index cdc0db0..3e4e380 100644 --- a/services/dashboard-api/app/main.py +++ b/services/dashboard-api/app/main.py @@ -1,7 +1,9 @@ import os import json import asyncio -from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from collections import deque +from datetime import datetime +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi.responses import Response from fastapi.middleware.cors import CORSMiddleware from aiokafka import AIOKafkaConsumer @@ -23,6 +25,50 @@ ) clients = set() +risk_history = deque(maxlen=1000) +cases_store = {} +txn_to_case = {} + + +def _to_iso(value): + if isinstance(value, str): + return value + if isinstance(value, datetime): + return value.isoformat() + return datetime.utcnow().isoformat() + + +def _parse_iso(value): + if not value: + return datetime.utcnow() + if value.endswith("Z"): + value = value.replace("Z", "+00:00") + return datetime.fromisoformat(value) + + +def ingest_risk_event(payload: dict): + event = dict(payload) + event["received_at"] = _to_iso(event.get("received_at")) + risk_history.append(event) + + decision = str(event.get("decision", "APPROVE")).upper() + if decision in {"REJECT", "REVIEW"}: + txn_id = event.get("transaction_id", "unknown") + case_id = txn_to_case.get(txn_id) + if not case_id: + case_id = f"CASE-{len(cases_store) + 1:04d}" + txn_to_case[txn_id] = case_id + cases_store[case_id] = { + "id": case_id, + "transactionId": txn_id, + "accountId": event.get("account_id", "unknown"), + "riskScore": round(float(event.get("unified_score", 0.0)) * 100, 2), + "status": "Open", + "created": event["received_at"], + } + else: + cases_store[case_id]["riskScore"] = round(float(event.get("unified_score", 0.0)) * 100, 2) + return event @app.on_event("startup") async def startup_event(): @@ -40,7 +86,7 @@ async def consume_risk_scores(): await consumer.start() try: async for msg in consumer: - payload = msg.value + payload = ingest_risk_event(msg.value) for ws in list(clients): try: await ws.send_json(payload) @@ -63,6 +109,53 @@ async def websocket_endpoint(websocket: WebSocket): except WebSocketDisconnect: clients.remove(websocket) +@app.get("/api/history/recent-alerts") +async def get_recent_alerts(limit: int = 10): + capped_limit = max(1, min(limit, 100)) + alerts = [e for e in reversed(risk_history) if str(e.get("decision", "")).upper() != "APPROVE"] + return alerts[:capped_limit] + +@app.get("/api/history/dashboard-stats") +async def get_dashboard_stats(): + events = list(risk_history) + live_events = len(events) + rejected_events = sum(1 for e in events if str(e.get("decision", "")).upper() == "REJECT") + active_alerts = sum(1 for e in events if str(e.get("decision", "")).upper() != "APPROVE") + high_risk = rejected_events + + fraud_rate = "0.00%" + trans_min = "0.0" + if live_events: + newest = _parse_iso(events[-1].get("received_at")) + oldest = _parse_iso(events[0].get("received_at")) + elapsed_minutes = max((newest - oldest).total_seconds() / 60, 1 / 60) + trans_min = f"{(live_events / elapsed_minutes):.1f}" + fraud_rate = f"{((rejected_events / live_events) * 100):.2f}%" + + return { + "fraudRate": fraud_rate, + "activeAlerts": active_alerts, + "transMin": trans_min, + "highRisk": high_risk, + "liveEvents": live_events, + "rejectedEvents": rejected_events, + } + +@app.get("/api/cases") +async def list_cases(): + return sorted(cases_store.values(), key=lambda c: c["created"], reverse=True) + +@app.patch("/api/cases/{case_id}") +async def update_case(case_id: str, payload: dict): + case = cases_store.get(case_id) + if not case: + raise HTTPException(status_code=404, detail="Case not found") + status = payload.get("status") + if not status: + raise HTTPException(status_code=400, detail="Missing status") + case["status"] = status + return case + @app.post("/api/explain") async def explain_transaction(payload: dict): # Proxy to llm-service diff --git a/services/dashboard-api/tests/test_api.py b/services/dashboard-api/tests/test_api.py index 3c28735..d13b3e9 100644 --- a/services/dashboard-api/tests/test_api.py +++ b/services/dashboard-api/tests/test_api.py @@ -1,8 +1,48 @@ from fastapi.testclient import TestClient -from app.main import app + +from app.main import app, cases_store, ingest_risk_event, risk_history, txn_to_case client = TestClient(app) -def test_explain_proxy(): - # Will fail without llm-service, so we just test the API structure - pass + +def reset_state(): + risk_history.clear() + cases_store.clear() + txn_to_case.clear() + + +def test_history_endpoints_return_recent_alerts_and_stats(): + reset_state() + ingest_risk_event({"transaction_id": "txn-1", "decision": "APPROVE", "unified_score": 0.2, "received_at": "2026-05-10T10:00:00"}) + ingest_risk_event({"transaction_id": "txn-2", "decision": "REVIEW", "unified_score": 0.65, "received_at": "2026-05-10T10:01:00"}) + ingest_risk_event({"transaction_id": "txn-3", "decision": "REJECT", "unified_score": 0.92, "received_at": "2026-05-10T10:02:00"}) + + alerts = client.get("/api/history/recent-alerts?limit=5") + assert alerts.status_code == 200 + alert_payload = alerts.json() + assert len(alert_payload) == 2 + assert alert_payload[0]["transaction_id"] == "txn-3" + assert alert_payload[1]["transaction_id"] == "txn-2" + + stats = client.get("/api/history/dashboard-stats") + assert stats.status_code == 200 + stats_payload = stats.json() + assert stats_payload["liveEvents"] == 3 + assert stats_payload["activeAlerts"] == 2 + assert stats_payload["rejectedEvents"] == 1 + assert stats_payload["highRisk"] == 1 + + +def test_cases_endpoint_supports_status_updates(): + reset_state() + ingest_risk_event({"transaction_id": "txn-case", "decision": "REJECT", "unified_score": 0.99, "received_at": "2026-05-10T11:00:00"}) + + cases = client.get("/api/cases") + assert cases.status_code == 200 + payload = cases.json() + assert len(payload) == 1 + assert payload[0]["status"] == "Open" + + updated = client.patch(f"/api/cases/{payload[0]['id']}", json={"status": "Closed"}) + assert updated.status_code == 200 + assert updated.json()["status"] == "Closed" diff --git a/services/dashboard/src/pages/Cases.tsx b/services/dashboard/src/pages/Cases.tsx index a38be3a..0d9c45e 100644 --- a/services/dashboard/src/pages/Cases.tsx +++ b/services/dashboard/src/pages/Cases.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useReactTable, getCoreRowModel, @@ -11,6 +11,7 @@ import { type CaseData = { id: string; accountId: string; + transactionId: string; riskScore: number; status: string; created: string; @@ -27,6 +28,10 @@ const columns = [ header: 'Account ID', cell: info => {info.getValue()}, }), + columnHelper.accessor('transactionId', { + header: 'Transaction ID', + cell: info => {info.getValue()}, + }), columnHelper.accessor('riskScore', { header: 'Risk Score', cell: info => { @@ -54,12 +59,31 @@ const columns = [ ] export default function Cases() { - const data = useMemo(() => [ - { id: 'CASE-1001', accountId: 'ACC-09923', riskScore: 92, status: 'Open', created: '2026-05-10T10:15:00' }, - { id: 'CASE-1002', accountId: 'ACC-01044', riskScore: 85, status: 'Open', created: '2026-05-10T09:42:00' }, - { id: 'CASE-1003', accountId: 'ACC-54421', riskScore: 65, status: 'Investigating', created: '2026-05-09T16:20:00' }, - { id: 'CASE-1004', accountId: 'ACC-99812', riskScore: 40, status: 'Closed', created: '2026-05-08T11:05:00' }, - ], []) + const [data, setData] = useState([]) + + const loadCases = useCallback(async () => { + try { + const response = await fetch('http://localhost:8005/api/cases') + if (!response.ok) return + const items = (await response.json()) as CaseData[] + setData(items) + } catch { + setData([]) + } + }, []) + + useEffect(() => { + loadCases() + }, [loadCases]) + + const updateStatus = useCallback(async (id: string, status: string) => { + await fetch(`http://localhost:8005/api/cases/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }) + loadCases() + }, [loadCases]) const table = useReactTable({ data, @@ -75,7 +99,7 @@ export default function Cases() {

Active Investigations

- +
@@ -92,6 +116,7 @@ export default function Cases() { )} ))} + ))} @@ -103,6 +128,10 @@ export default function Cases() { {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} + ))} @@ -111,4 +140,4 @@ export default function Cases() { ); -} \ No newline at end of file +} diff --git a/services/dashboard/src/pages/Dashboard.tsx b/services/dashboard/src/pages/Dashboard.tsx index 9fe4e3c..c4cf02d 100644 --- a/services/dashboard/src/pages/Dashboard.tsx +++ b/services/dashboard/src/pages/Dashboard.tsx @@ -11,6 +11,8 @@ type RiskEvent = { }; }; +const API_BASE_URL = "http://localhost:8005"; + export default function Dashboard() { const [alerts, setAlerts] = useState([]); const [latestEvent, setLatestEvent] = useState(null); @@ -26,6 +28,22 @@ export default function Dashboard() { }); useEffect(() => { + let cancelled = false; + + fetch(`${API_BASE_URL}/api/history/dashboard-stats`) + .then((res) => res.json()) + .then((data) => { + if (!cancelled) setStats(data); + }) + .catch(() => undefined); + + fetch(`${API_BASE_URL}/api/history/recent-alerts?limit=10`) + .then((res) => res.json()) + .then((data: RiskEvent[]) => { + if (!cancelled) setAlerts(data); + }) + .catch(() => undefined); + const ws = new WebSocket("ws://localhost:8005/ws"); ws.onopen = () => { @@ -63,6 +81,7 @@ export default function Dashboard() { ws.onclose = () => setWsConnected(false); return () => { + cancelled = true; setWsConnected(false); ws.close(); }; From e9a4c1f1ca5ca22490a600b72901d0d3fb47a352 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 18:18:15 +0000 Subject: [PATCH 3/3] Use configurable dashboard API URLs and improve case status update handling Agent-Logs-Url: https://github.com/Anuj-verse/fundGuard/sessions/3c83a29c-4361-461d-9aec-07c2636f4e3d Co-authored-by: Anuj-verse <182001728+Anuj-verse@users.noreply.github.com> --- services/dashboard/src/pages/Cases.tsx | 22 +++++++++++++++------- services/dashboard/src/pages/Dashboard.tsx | 5 +++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/services/dashboard/src/pages/Cases.tsx b/services/dashboard/src/pages/Cases.tsx index 0d9c45e..64ae936 100644 --- a/services/dashboard/src/pages/Cases.tsx +++ b/services/dashboard/src/pages/Cases.tsx @@ -8,6 +8,8 @@ import { createColumnHelper } from '@tanstack/react-table'; +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8005'; + type CaseData = { id: string; accountId: string; @@ -63,7 +65,7 @@ export default function Cases() { const loadCases = useCallback(async () => { try { - const response = await fetch('http://localhost:8005/api/cases') + const response = await fetch(`${API_BASE_URL}/api/cases`) if (!response.ok) return const items = (await response.json()) as CaseData[] setData(items) @@ -77,12 +79,18 @@ export default function Cases() { }, [loadCases]) const updateStatus = useCallback(async (id: string, status: string) => { - await fetch(`http://localhost:8005/api/cases/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status }), - }) - loadCases() + try { + const response = await fetch(`${API_BASE_URL}/api/cases/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }) + if (response.ok) { + loadCases() + } + } catch { + return + } }, [loadCases]) const table = useReactTable({ diff --git a/services/dashboard/src/pages/Dashboard.tsx b/services/dashboard/src/pages/Dashboard.tsx index c4cf02d..7ddc8f8 100644 --- a/services/dashboard/src/pages/Dashboard.tsx +++ b/services/dashboard/src/pages/Dashboard.tsx @@ -11,7 +11,8 @@ type RiskEvent = { }; }; -const API_BASE_URL = "http://localhost:8005"; +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8005"; +const WS_URL = import.meta.env.VITE_WS_URL ?? API_BASE_URL.replace("http", "ws") + "/ws"; export default function Dashboard() { const [alerts, setAlerts] = useState([]); @@ -44,7 +45,7 @@ export default function Dashboard() { }) .catch(() => undefined); - const ws = new WebSocket("ws://localhost:8005/ws"); + const ws = new WebSocket(WS_URL); ws.onopen = () => { streamStartedAt.current = Date.now();
Actions
+ + +