Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 95 additions & 2 deletions services/dashboard-api/app/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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():
Expand All @@ -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)
Expand All @@ -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
Expand Down
48 changes: 44 additions & 4 deletions services/dashboard-api/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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"
55 changes: 46 additions & 9 deletions services/dashboard/src/pages/Cases.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
useReactTable,
getCoreRowModel,
Expand All @@ -8,9 +8,12 @@ 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;
transactionId: string;
riskScore: number;
status: string;
created: string;
Expand All @@ -27,6 +30,10 @@ const columns = [
header: 'Account ID',
cell: info => <span className="font-mono text-gray-400">{info.getValue()}</span>,
}),
columnHelper.accessor('transactionId', {
header: 'Transaction ID',
cell: info => <span className="font-mono text-gray-400">{info.getValue()}</span>,
}),
columnHelper.accessor('riskScore', {
header: 'Risk Score',
cell: info => {
Expand Down Expand Up @@ -54,12 +61,37 @@ const columns = [
]

export default function Cases() {
const data = useMemo<CaseData[]>(() => [
{ 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<CaseData[]>([])

const loadCases = useCallback(async () => {
try {
const response = await fetch(`${API_BASE_URL}/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) => {
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({
data,
Expand All @@ -75,7 +107,7 @@ export default function Cases() {
<div className="bg-dark-surface border border-gray-800 rounded-xl overflow-hidden">
<div className="p-6 border-b border-gray-800 flex justify-between items-center">
<h3 className="font-semibold text-lg">Active Investigations</h3>
<button className="bg-gray-800 text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-700 transition">Filter</button>
<button onClick={loadCases} className="bg-gray-800 text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-700 transition">Refresh</button>
</div>
<div className="p-6 overflow-x-auto">
<table className="w-full text-left border-collapse">
Expand All @@ -92,6 +124,7 @@ export default function Cases() {
)}
</th>
))}
<th className="p-4 text-sm font-semibold text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
))}
</thead>
Expand All @@ -103,6 +136,10 @@ export default function Cases() {
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
<td className="p-4 flex gap-2">
<button onClick={() => updateStatus(row.original.id, 'Investigating')} className="px-2 py-1 rounded bg-gray-800 text-gray-300 text-xs">Investigate</button>
<button onClick={() => updateStatus(row.original.id, 'Closed')} className="px-2 py-1 rounded bg-gray-800 text-gray-300 text-xs">Close</button>
</td>
</tr>
))}
</tbody>
Expand All @@ -111,4 +148,4 @@ export default function Cases() {
</div>
</div>
);
}
}
22 changes: 21 additions & 1 deletion services/dashboard/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ type RiskEvent = {
};
};

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<RiskEvent[]>([]);
const [latestEvent, setLatestEvent] = useState<RiskEvent | null>(null);
Expand All @@ -26,7 +29,23 @@ export default function Dashboard() {
});

useEffect(() => {
const ws = new WebSocket("ws://localhost:8005/ws");
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_URL);

ws.onopen = () => {
streamStartedAt.current = Date.now();
Expand Down Expand Up @@ -63,6 +82,7 @@ export default function Dashboard() {
ws.onclose = () => setWsConnected(false);

return () => {
cancelled = true;
setWsConnected(false);
ws.close();
};
Expand Down