From ea9ba065d07d8c83056349f6537395e0db4b00c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 18:08:08 +0000 Subject: [PATCH 1/5] Initial plan From 0d4550786bbd5660c28d3e90f5669460cb0726a3 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:56 +0000 Subject: [PATCH 2/5] feat: persist risk stream for dashboard history and cases Agent-Logs-Url: https://github.com/Anuj-verse/fundGuard/sessions/e7b7920d-4e69-4da3-a327-4e328e8d134e Co-authored-by: Anuj-verse <182001728+Anuj-verse@users.noreply.github.com> --- services/dashboard-api/app/main.py | 149 +++++++++++++++++++++ services/dashboard-api/tests/test_api.py | 42 +++++- services/dashboard/src/pages/Alerts.tsx | 26 +++- services/dashboard/src/pages/Cases.tsx | 40 ++++-- services/dashboard/src/pages/Dashboard.tsx | 22 +++ 5 files changed, 262 insertions(+), 17 deletions(-) diff --git a/services/dashboard-api/app/main.py b/services/dashboard-api/app/main.py index cdc0db0..b65edc9 100644 --- a/services/dashboard-api/app/main.py +++ b/services/dashboard-api/app/main.py @@ -1,6 +1,9 @@ import os import json import asyncio +import sqlite3 +import threading +from datetime import datetime, timezone from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.responses import Response from fastapi.middleware.cors import CORSMiddleware @@ -11,6 +14,7 @@ KAFKA_BROKERS = os.getenv("KAFKA_BROKERS", "localhost:9092") LLM_SERVICE_URL = os.getenv("LLM_SERVICE_URL", "http://localhost:8004") GRAPH_SERVICE_URL = os.getenv("GRAPH_SERVICE_URL", "http://localhost:8002") +DB_PATH = os.getenv("DASHBOARD_DB_PATH", "/tmp/fundguard_dashboard.db") app = FastAPI(title="Dashboard API") @@ -23,9 +27,86 @@ ) clients = set() +db_lock = threading.Lock() + + +def _db_connection(): + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + with db_lock: + conn = _db_connection() + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS risk_scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + transaction_id TEXT NOT NULL, + unified_score REAL NOT NULL, + decision TEXT NOT NULL, + edge_score REAL NOT NULL DEFAULT 0.0, + graph_score REAL NOT NULL DEFAULT 0.0, + rule_score REAL NOT NULL DEFAULT 0.0, + created_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS cases ( + id TEXT PRIMARY KEY, + transaction_id TEXT NOT NULL, + risk_score REAL NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + conn.commit() + finally: + conn.close() + + +def persist_risk_score(payload: dict): + txn_id = payload.get("transaction_id") or f"TXN-{int(datetime.now(tz=timezone.utc).timestamp() * 1000)}" + decision = str(payload.get("decision") or "APPROVE") + unified_score = float(payload.get("unified_score") or 0.0) + components = payload.get("components") or {} + edge_score = float(components.get("edge_score") or 0.0) + graph_score = float(components.get("graph_score") or 0.0) + rule_score = float(components.get("rule_score") or 0.0) + created_at = datetime.now(tz=timezone.utc).isoformat() + + with db_lock: + conn = _db_connection() + try: + conn.execute( + """ + INSERT INTO risk_scores ( + transaction_id, unified_score, decision, edge_score, graph_score, rule_score, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (txn_id, unified_score, decision, edge_score, graph_score, rule_score, created_at), + ) + if decision != "APPROVE": + case_id = f"CASE-{txn_id}" + conn.execute( + """ + INSERT OR IGNORE INTO cases (id, transaction_id, risk_score, status, created_at) + VALUES (?, ?, ?, 'Open', ?) + """, + (case_id, txn_id, unified_score * 100, created_at), + ) + conn.commit() + finally: + conn.close() @app.on_event("startup") async def startup_event(): + init_db() asyncio.create_task(consume_risk_scores()) async def consume_risk_scores(): @@ -41,6 +122,7 @@ async def consume_risk_scores(): try: async for msg in consumer: payload = msg.value + persist_risk_score(payload) for ws in list(clients): try: await ws.send_json(payload) @@ -63,6 +145,73 @@ async def websocket_endpoint(websocket: WebSocket): except WebSocketDisconnect: clients.remove(websocket) + +@app.get("/api/recent-alerts") +async def recent_alerts(limit: int = 20): + with db_lock: + conn = _db_connection() + try: + rows = conn.execute( + """ + SELECT transaction_id, unified_score, decision, created_at + FROM risk_scores + WHERE decision != 'APPROVE' + ORDER BY created_at DESC + LIMIT ? + """, + (max(1, min(limit, 200)),), + ).fetchall() + finally: + conn.close() + return [dict(row) for row in rows] + + +@app.get("/api/stats") +async def dashboard_stats(): + with db_lock: + conn = _db_connection() + try: + total = conn.execute("SELECT COUNT(*) FROM risk_scores").fetchone()[0] + rejected = conn.execute("SELECT COUNT(*) FROM risk_scores WHERE decision = 'REJECT'").fetchone()[0] + alerts = conn.execute("SELECT COUNT(*) FROM risk_scores WHERE decision != 'APPROVE'").fetchone()[0] + first_ts = conn.execute("SELECT created_at FROM risk_scores ORDER BY created_at ASC LIMIT 1").fetchone() + finally: + conn.close() + + if first_ts: + started = datetime.fromisoformat(first_ts[0]) + elapsed_minutes = max((datetime.now(tz=timezone.utc) - started).total_seconds() / 60.0, 1 / 60) + else: + elapsed_minutes = 1 / 60 + + return { + "fraudRate": f"{((rejected / total) * 100 if total else 0.0):.2f}%", + "activeAlerts": alerts, + "transMin": f"{(total / elapsed_minutes if total else 0.0):.1f}", + "highRisk": rejected, + "liveEvents": total, + "rejectedEvents": rejected, + } + + +@app.get("/api/cases") +async def list_cases(limit: int = 100): + with db_lock: + conn = _db_connection() + try: + rows = conn.execute( + """ + SELECT id, transaction_id, risk_score, status, created_at + FROM cases + ORDER BY created_at DESC + LIMIT ? + """, + (max(1, min(limit, 500)),), + ).fetchall() + finally: + conn.close() + return [dict(row) for row in rows] + @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..24bcfa5 100644 --- a/services/dashboard-api/tests/test_api.py +++ b/services/dashboard-api/tests/test_api.py @@ -1,8 +1,40 @@ +from pathlib import Path from fastapi.testclient import TestClient -from app.main import app +import app.main as dashboard_main -client = TestClient(app) +client = TestClient(dashboard_main.app) -def test_explain_proxy(): - # Will fail without llm-service, so we just test the API structure - pass + +def _seed(db_path: Path): + dashboard_main.DB_PATH = str(db_path) + dashboard_main.init_db() + dashboard_main.persist_risk_score({"transaction_id": "TXN-1", "unified_score": 0.20, "decision": "APPROVE"}) + dashboard_main.persist_risk_score({"transaction_id": "TXN-2", "unified_score": 0.62, "decision": "REVIEW"}) + dashboard_main.persist_risk_score({"transaction_id": "TXN-3", "unified_score": 0.91, "decision": "REJECT"}) + + +def test_recent_alerts_and_stats(tmp_path): + _seed(tmp_path / "dashboard.db") + + alerts_resp = client.get("/api/recent-alerts") + assert alerts_resp.status_code == 200 + alerts = alerts_resp.json() + assert len(alerts) == 2 + assert alerts[0]["decision"] in {"REJECT", "REVIEW"} + + stats_resp = client.get("/api/stats") + assert stats_resp.status_code == 200 + stats = stats_resp.json() + assert stats["liveEvents"] == 3 + assert stats["activeAlerts"] == 2 + assert stats["highRisk"] == 1 + + +def test_cases_list(tmp_path): + _seed(tmp_path / "cases.db") + + resp = client.get("/api/cases") + assert resp.status_code == 200 + cases = resp.json() + assert len(cases) == 2 + assert cases[0]["id"].startswith("CASE-") diff --git a/services/dashboard/src/pages/Alerts.tsx b/services/dashboard/src/pages/Alerts.tsx index 96111b7..8576d58 100644 --- a/services/dashboard/src/pages/Alerts.tsx +++ b/services/dashboard/src/pages/Alerts.tsx @@ -21,6 +21,30 @@ export default function Alerts() { }, [alerts]); useEffect(() => { + void (async () => { + try { + const resp = await fetch("http://localhost:8005/api/recent-alerts?limit=100"); + if (!resp.ok) { + return; + } + const data = await resp.json(); + setAlerts( + data + .slice() + .reverse() + .map((row: { transaction_id?: string; unified_score?: number; decision?: string; created_at?: string }) => ({ + time: row.created_at ? new Date(row.created_at).toLocaleTimeString() : new Date().toLocaleTimeString(), + transaction_id: row.transaction_id || `TXN-${Math.floor(Math.random() * 1000000)}`, + details: "Historical anomaly from risk stream", + score: row.unified_score || 0.0, + decision: row.decision || "REVIEW", + })) + ); + } catch { + // fallback to live websocket-only mode + } + })(); + const ws = new WebSocket("ws://localhost:8005/ws"); ws.onmessage = (event) => { @@ -80,4 +104,4 @@ export default function Alerts() { ); -} \ No newline at end of file +} diff --git a/services/dashboard/src/pages/Cases.tsx b/services/dashboard/src/pages/Cases.tsx index a38be3a..36e2aad 100644 --- a/services/dashboard/src/pages/Cases.tsx +++ b/services/dashboard/src/pages/Cases.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useState } from 'react'; import { useReactTable, getCoreRowModel, @@ -10,7 +10,7 @@ import { type CaseData = { id: string; - accountId: string; + transaction_id: string; riskScore: number; status: string; created: string; @@ -23,8 +23,8 @@ const columns = [ header: 'Case ID', cell: info => {info.getValue()}, }), - columnHelper.accessor('accountId', { - header: 'Account ID', + columnHelper.accessor('transaction_id', { + header: 'Transaction ID', cell: info => {info.getValue()}, }), columnHelper.accessor('riskScore', { @@ -54,12 +54,30 @@ 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([]) + + useEffect(() => { + void (async () => { + try { + const resp = await fetch("http://localhost:8005/api/cases?limit=200"); + if (!resp.ok) { + return; + } + const rows = await resp.json(); + setData( + rows.map((row: { id: string; transaction_id: string; risk_score: number; status: string; created_at: string }) => ({ + id: row.id, + transaction_id: row.transaction_id, + riskScore: row.risk_score, + status: row.status, + created: row.created_at, + })) + ); + } catch { + // fallback to empty state + } + })(); + }, []) const table = useReactTable({ data, @@ -111,4 +129,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..71383fd 100644 --- a/services/dashboard/src/pages/Dashboard.tsx +++ b/services/dashboard/src/pages/Dashboard.tsx @@ -26,6 +26,28 @@ export default function Dashboard() { }); useEffect(() => { + void (async () => { + try { + const [alertsResp, statsResp] = await Promise.all([ + fetch("http://localhost:8005/api/recent-alerts?limit=10"), + fetch("http://localhost:8005/api/stats"), + ]); + if (alertsResp.ok) { + const initialAlerts = (await alertsResp.json()) as RiskEvent[]; + setAlerts(initialAlerts); + if (initialAlerts.length > 0) { + setLatestEvent(initialAlerts[0]); + } + } + if (statsResp.ok) { + const initialStats = await statsResp.json(); + setStats((prev) => ({ ...prev, ...initialStats })); + } + } catch { + // fallback to live websocket-only mode + } + })(); + const ws = new WebSocket("ws://localhost:8005/ws"); ws.onopen = () => { From 4566d696da003c3291a954ad48c8b0c8b0df2e06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 18:13:39 +0000 Subject: [PATCH 3/5] refactor: parameterize dashboard api url and tighten persistence tests Agent-Logs-Url: https://github.com/Anuj-verse/fundGuard/sessions/e7b7920d-4e69-4da3-a327-4e328e8d134e Co-authored-by: Anuj-verse <182001728+Anuj-verse@users.noreply.github.com> --- services/dashboard-api/app/main.py | 7 ++++--- services/dashboard-api/tests/test_api.py | 8 ++++++++ services/dashboard/src/pages/Alerts.tsx | 5 +++-- services/dashboard/src/pages/Cases.tsx | 3 ++- services/dashboard/src/pages/Dashboard.tsx | 5 +++-- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/services/dashboard-api/app/main.py b/services/dashboard-api/app/main.py index b65edc9..d05ae48 100644 --- a/services/dashboard-api/app/main.py +++ b/services/dashboard-api/app/main.py @@ -15,6 +15,7 @@ LLM_SERVICE_URL = os.getenv("LLM_SERVICE_URL", "http://localhost:8004") GRAPH_SERVICE_URL = os.getenv("GRAPH_SERVICE_URL", "http://localhost:8002") DB_PATH = os.getenv("DASHBOARD_DB_PATH", "/tmp/fundguard_dashboard.db") +MIN_ELAPSED_MINUTES = 1.0 / 60.0 app = FastAPI(title="Dashboard API") @@ -31,7 +32,7 @@ def _db_connection(): - conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn @@ -180,9 +181,9 @@ async def dashboard_stats(): if first_ts: started = datetime.fromisoformat(first_ts[0]) - elapsed_minutes = max((datetime.now(tz=timezone.utc) - started).total_seconds() / 60.0, 1 / 60) + elapsed_minutes = max((datetime.now(tz=timezone.utc) - started).total_seconds() / 60.0, MIN_ELAPSED_MINUTES) else: - elapsed_minutes = 1 / 60 + elapsed_minutes = MIN_ELAPSED_MINUTES return { "fraudRate": f"{((rejected / total) * 100 if total else 0.0):.2f}%", diff --git a/services/dashboard-api/tests/test_api.py b/services/dashboard-api/tests/test_api.py index 24bcfa5..7db020e 100644 --- a/services/dashboard-api/tests/test_api.py +++ b/services/dashboard-api/tests/test_api.py @@ -1,10 +1,18 @@ from pathlib import Path from fastapi.testclient import TestClient import app.main as dashboard_main +import pytest client = TestClient(dashboard_main.app) +@pytest.fixture(autouse=True) +def reset_db_path(): + original_db_path = dashboard_main.DB_PATH + yield + dashboard_main.DB_PATH = original_db_path + + def _seed(db_path: Path): dashboard_main.DB_PATH = str(db_path) dashboard_main.init_db() diff --git a/services/dashboard/src/pages/Alerts.tsx b/services/dashboard/src/pages/Alerts.tsx index 8576d58..710a444 100644 --- a/services/dashboard/src/pages/Alerts.tsx +++ b/services/dashboard/src/pages/Alerts.tsx @@ -8,6 +8,7 @@ type AlertRow = { score: number; decision: string; }; +const API_BASE_URL = import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "http://localhost:8005"; export default function Alerts() { const [alerts, setAlerts] = useState([]); @@ -23,7 +24,7 @@ export default function Alerts() { useEffect(() => { void (async () => { try { - const resp = await fetch("http://localhost:8005/api/recent-alerts?limit=100"); + const resp = await fetch(`${API_BASE_URL}/api/recent-alerts?limit=100`); if (!resp.ok) { return; } @@ -34,7 +35,7 @@ export default function Alerts() { .reverse() .map((row: { transaction_id?: string; unified_score?: number; decision?: string; created_at?: string }) => ({ time: row.created_at ? new Date(row.created_at).toLocaleTimeString() : new Date().toLocaleTimeString(), - transaction_id: row.transaction_id || `TXN-${Math.floor(Math.random() * 1000000)}`, + transaction_id: row.transaction_id || "UNKNOWN_TXN", details: "Historical anomaly from risk stream", score: row.unified_score || 0.0, decision: row.decision || "REVIEW", diff --git a/services/dashboard/src/pages/Cases.tsx b/services/dashboard/src/pages/Cases.tsx index 36e2aad..13cf335 100644 --- a/services/dashboard/src/pages/Cases.tsx +++ b/services/dashboard/src/pages/Cases.tsx @@ -15,6 +15,7 @@ type CaseData = { status: string; created: string; } +const API_BASE_URL = import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "http://localhost:8005"; const columnHelper = createColumnHelper() @@ -59,7 +60,7 @@ export default function Cases() { useEffect(() => { void (async () => { try { - const resp = await fetch("http://localhost:8005/api/cases?limit=200"); + const resp = await fetch(`${API_BASE_URL}/api/cases?limit=200`); if (!resp.ok) { return; } diff --git a/services/dashboard/src/pages/Dashboard.tsx b/services/dashboard/src/pages/Dashboard.tsx index 71383fd..ddca791 100644 --- a/services/dashboard/src/pages/Dashboard.tsx +++ b/services/dashboard/src/pages/Dashboard.tsx @@ -10,6 +10,7 @@ type RiskEvent = { rule_score?: number; }; }; +const API_BASE_URL = import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "http://localhost:8005"; export default function Dashboard() { const [alerts, setAlerts] = useState([]); @@ -29,8 +30,8 @@ export default function Dashboard() { void (async () => { try { const [alertsResp, statsResp] = await Promise.all([ - fetch("http://localhost:8005/api/recent-alerts?limit=10"), - fetch("http://localhost:8005/api/stats"), + fetch(`${API_BASE_URL}/api/recent-alerts?limit=10`), + fetch(`${API_BASE_URL}/api/stats`), ]); if (alertsResp.ok) { const initialAlerts = (await alertsResp.json()) as RiskEvent[]; From 61ff498004e63f1d3d3aa94d3d3088cf9a5f1351 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 18:14:54 +0000 Subject: [PATCH 4/5] fix: handle naive timestamps in dashboard stats calculation Agent-Logs-Url: https://github.com/Anuj-verse/fundGuard/sessions/e7b7920d-4e69-4da3-a327-4e328e8d134e Co-authored-by: Anuj-verse <182001728+Anuj-verse@users.noreply.github.com> --- services/dashboard-api/app/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/dashboard-api/app/main.py b/services/dashboard-api/app/main.py index d05ae48..490ebda 100644 --- a/services/dashboard-api/app/main.py +++ b/services/dashboard-api/app/main.py @@ -181,6 +181,8 @@ async def dashboard_stats(): if first_ts: started = datetime.fromisoformat(first_ts[0]) + if started.tzinfo is None: + started = started.replace(tzinfo=timezone.utc) elapsed_minutes = max((datetime.now(tz=timezone.utc) - started).total_seconds() / 60.0, MIN_ELAPSED_MINUTES) else: elapsed_minutes = MIN_ELAPSED_MINUTES From 2d4cf3697009313842b7f0200876b7ea8e0ec4c9 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:42 +0000 Subject: [PATCH 5/5] test: assert approved events excluded from alert history Agent-Logs-Url: https://github.com/Anuj-verse/fundGuard/sessions/e7b7920d-4e69-4da3-a327-4e328e8d134e Co-authored-by: Anuj-verse <182001728+Anuj-verse@users.noreply.github.com> --- services/dashboard-api/tests/test_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/dashboard-api/tests/test_api.py b/services/dashboard-api/tests/test_api.py index 7db020e..ef59f0d 100644 --- a/services/dashboard-api/tests/test_api.py +++ b/services/dashboard-api/tests/test_api.py @@ -28,6 +28,7 @@ def test_recent_alerts_and_stats(tmp_path): assert alerts_resp.status_code == 200 alerts = alerts_resp.json() assert len(alerts) == 2 + assert all(alert["decision"] != "APPROVE" for alert in alerts) assert alerts[0]["decision"] in {"REJECT", "REVIEW"} stats_resp = client.get("/api/stats")