Add real-time consumer panel and paginated transaction results

- Implement WebSocket endpoint (/ws/fraud-results) in API for real-time fraud detection updates
- Add Redis pub/sub listener in API to broadcast consumer results to connected WebSocket clients
- Update consumer to publish fraud detection results to Redis 'fraud_results' channel with transaction details
- Add WebSocket connection logic in frontend with auto-reconnect (exponential backoff)
- Display real-time consumer status panel with fraud score color coding and decision status
- Implement client-side transaction results pagination (10/25/50 items per page)
- Add pagination controls: Previous/Next buttons, page indicator, page size selector
- Newest-first transaction sorting (DESC by timestamp)
- Add comprehensive styling for consumer panel and pagination controls
- All frontend components tested and builds successfully

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Pat TeruelandCopilot committed 2026-05-01 19:29:31 +08:00
1 parent d4751b9061
commit 0387900954
5 files changed
+515 -7

No files matched your search

+98 -1
View File
@@ -13,7 +13,7 @@ hands off responsibility to the message queue. This separation of concerns is
fundamental to scalable microservices architecture.
"""
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi import FastAPI, Depends, HTTPException, status, WebSocket, WebSocketDisconnect
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
@@ -23,6 +23,8 @@ import json
import uuid
import time
import logging
import redis
import threading
# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
@@ -85,6 +87,66 @@ def get_kafka_producer():
raise
return kafka_producer
# ══════════════════════════════════════════════════════════════════════════════
# REDIS & WEBSOCKET PUBSUB
# ══════════════════════════════════════════════════════════════════════════════
redis_client = None
connected_clients = set()
pubsub_thread = None
def get_redis_client():
"""Get or create Redis client"""
global redis_client
if redis_client is None:
try:
redis_client = redis.Redis(
host="redis",
port=6379,
decode_responses=True
)
redis_client.ping()
logger.info("[REDIS] Connected to Redis")
except Exception as e:
logger.warning(f"[REDIS] Connection failed: {e}")
redis_client = None
raise
return redis_client
def start_pubsub_listener():
"""Start background thread to listen for fraud results on Redis pub/sub"""
global pubsub_thread
def listen_and_broadcast():
try:
redis_conn = get_redis_client()
pubsub = redis_conn.pubsub()
pubsub.subscribe("fraud_results")
logger.info("[PUBSUB] Listening to 'fraud_results' channel")
for message in pubsub.listen():
if message["type"] == "message":
try:
data = json.loads(message["data"])
# Broadcast to all connected WebSocket clients
for client in list(connected_clients):
try:
if not client.client_state.value == 0: # Check if still connected
client.send_json(data)
except Exception as e:
logger.warning(f"[PUBSUB] Failed to send to client: {e}")
try:
connected_clients.discard(client)
except:
pass
except Exception as e:
logger.error(f"[PUBSUB] Failed to parse message: {e}")
except Exception as e:
logger.error(f"[PUBSUB] Listener error: {e}")
pubsub_thread = threading.Thread(target=listen_and_broadcast, daemon=True)
pubsub_thread.start()
# ══════════════════════════════════════════════════════════════════════════════
# AUTHENTICATION
# ══════════════════════════════════════════════════════════════════════════════
@@ -211,6 +273,34 @@ async def get_demo_token(user_id: str):
logger.info(f"[JWT GENERATION] Generated token for user={user_id}")
return {"access_token": token, "token_type": "bearer"}
@app.websocket("/ws/fraud-results")
async def websocket_fraud_results(websocket: WebSocket):
"""
WebSocket endpoint for real-time fraud detection results.
Clients connect and receive live updates when fraud detection completes.
Results are streamed from Redis pub/sub channel via background listener.
"""
await websocket.accept()
connected_clients.add(websocket)
logger.info(f"[WEBSOCKET] Client connected. Total clients: {len(connected_clients)}")
try:
# Keep connection alive while client is connected
while True:
# Wait for any client message (optional heartbeat/ping)
data = await websocket.receive_text()
logger.debug(f"[WEBSOCKET] Received from client: {data}")
except WebSocketDisconnect:
connected_clients.discard(websocket)
logger.info(f"[WEBSOCKET] Client disconnected. Total clients: {len(connected_clients)}")
except Exception as e:
logger.error(f"[WEBSOCKET] Error: {e}")
try:
connected_clients.discard(websocket)
except:
pass
# ══════════════════════════════════════════════════════════════════════════════
# STARTUP / SHUTDOWN
# ══════════════════════════════════════════════════════════════════════════════
@@ -224,6 +314,13 @@ async def startup_event():
logger.info(f"Topic: {KAFKA_TOPIC}")
logger.info("Listening for authenticated transaction requests...")
logger.info("="*80)
# Start Redis pub/sub listener for WebSocket broadcasts
try:
get_redis_client()
start_pubsub_listener()
except Exception as e:
logger.warning(f"[STARTUP] Redis pub/sub not available: {e}")
@app.on_event("shutdown")
async def shutdown_event():
+28 -2
View File
@@ -122,8 +122,12 @@ class DecisionEngine:
return decision
def store_result(self, transaction_id: str, decision: str,
layer1_score: float, layer2_score: float = None) -> None:
"""Store decision result in Redis for retrieval"""
layer1_score: float, layer2_score: float = None,
transaction_data: dict = None) -> None:
"""Store decision result in Redis and publish to pub/sub channel"""
import json
from datetime import datetime
key = f"result:{transaction_id}"
value = {
"decision": decision,
@@ -136,3 +140,25 @@ class DecisionEngine:
self.redis_client.setex(key, 3600, str(value))
logger.info(f"[RESULT STORAGE] Stored decision in Redis: {key} = {decision}")
# Publish to pub/sub channel for real-time WebSocket updates
try:
# Build message with transaction details for frontend
message = {
"transaction_id": transaction_id,
"decision": decision,
"fraud_score": round(layer2_score, 4) if layer2_score is not None else round(layer1_score, 4),
"layer1_score": round(layer1_score, 4),
"timestamp": datetime.utcnow().isoformat() + "Z"
}
# Add transaction data if provided (amount, merchant)
if transaction_data:
message["amount"] = transaction_data.get("amount")
message["merchant"] = transaction_data.get("merchant")
# Publish to Redis pub/sub channel
self.redis_client.publish("fraud_results", json.dumps(message))
logger.info(f"[RESULT STORAGE] Published result to pub/sub channel: {transaction_id[:8]}... → {decision}")
except Exception as e:
logger.error(f"[RESULT STORAGE] Failed to publish to pub/sub: {str(e)}")
+2 -1
View File
@@ -193,7 +193,8 @@ def process_transaction(transaction, components):
transaction_id=transaction_id,
decision=decision,
layer1_score=layer1_score,
layer2_score=layer2_score
layer2_score=layer2_score,
transaction_data=transaction # Pass full transaction for pub/sub message
)
# ─────────────────────────────────────────────────────────────────────────
+167
View File
@@ -345,3 +345,170 @@
font-size: 0.8rem;
}
}
/* Consumer Status Card */
.consumer-card {
border-top: 4px solid var(--primary);
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
}
.consumer-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.connection-status {
display: flex;
justify-content: center;
}
.connection-status .status-badge {
font-size: 0.875rem;
padding: 0.5rem 1rem;
}
.connection-status .status-badge.connected {
background: var(--success);
color: white;
}
.connection-status .status-badge.disconnected {
background: var(--error);
color: white;
}
.empty-state {
text-align: center;
padding: 2rem 1rem;
color: var(--text-light);
}
.empty-state p {
margin: 0.5rem 0;
}
.consumer-status-details {
display: flex;
flex-direction: column;
gap: 0.75rem;
background: white;
padding: 1rem;
border-radius: 0.375rem;
}
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
.status-row:last-child {
border-bottom: none;
}
.status-row label {
font-weight: 600;
color: var(--text);
font-size: 0.875rem;
min-width: 120px;
}
.status-row span {
color: var(--text-light);
font-size: 0.875rem;
text-align: right;
flex: 1;
margin-left: 1rem;
}
/* Pagination Controls */
.pagination-controls {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: var(--bg);
border-radius: 0.375rem;
margin-bottom: 1rem;
}
.pagination-info {
text-align: center;
font-size: 0.875rem;
color: var(--text-light);
font-weight: 500;
}
.pagination-buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.btn-pagination {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: 100px;
justify-content: center;
}
.btn-pagination:hover:not(:disabled) {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-pagination:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-indicator {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
min-width: 120px;
text-align: center;
}
.page-size-selector {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.page-size-selector label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.select-input {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
background: white;
}
.select-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
+220 -3
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { AlertCircle, CheckCircle, Clock, Lock, Send } from 'lucide-react';
import React, { useState, useEffect, useRef } from 'react';
import { AlertCircle, CheckCircle, Clock, Lock, Send, Wifi, WifiOff, ChevronLeft, ChevronRight } from 'lucide-react';
import axios from 'axios';
import './App.css';
@@ -28,6 +28,15 @@ interface Result {
timestamp: string;
}
interface FraudResult {
transaction_id: string;
decision: string;
fraud_score: number;
amount?: number;
merchant?: string;
timestamp: string;
}
// Use localhost when browser accesses from host machine
const API_BASE_URL = 'http://localhost:8000';
@@ -46,9 +55,72 @@ const App: React.FC = () => {
});
const [results, setResults] = useState<Result[]>([]);
const [consumerStatus, setConsumerStatus] = useState<FraudResult | null>(null);
const [wsConnected, setWsConnected] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// WebSocket connection management
useEffect(() => {
const connectWebSocket = () => {
try {
const wsUrl = 'ws://localhost:8000/ws/fraud-results';
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[WEBSOCKET] Connected to fraud results stream');
setWsConnected(true);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as FraudResult;
console.log('[WEBSOCKET] Received fraud result:', data);
setConsumerStatus(data);
} catch (e) {
console.error('[WEBSOCKET] Failed to parse message:', e);
}
};
ws.onerror = (error) => {
console.error('[WEBSOCKET] Error:', error);
setWsConnected(false);
};
ws.onclose = () => {
console.log('[WEBSOCKET] Disconnected, attempting reconnect...');
setWsConnected(false);
// Exponential backoff reconnection: 1s → 2s → 4s → 8s
const delay = Math.min(1000 * Math.pow(2, 3), 8000);
reconnectTimeoutRef.current = setTimeout(connectWebSocket, delay);
};
wsRef.current = ws;
} catch (e) {
console.error('[WEBSOCKET] Connection failed:', e);
setWsConnected(false);
}
};
connectWebSocket();
return () => {
if (wsRef.current) {
wsRef.current.close();
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, []);
// Generate JWT token
const generateToken = async () => {
@@ -173,6 +245,19 @@ const App: React.FC = () => {
});
};
// Pagination helpers
const totalPages = Math.ceil(results.length / pageSize);
const paginatedResults = results.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
);
const getFraudScoreColor = (score: number): string => {
if (score < 0.3) return '#10b981'; // Green - low risk
if (score < 0.7) return '#f59e0b'; // Yellow - medium risk
return '#ef4444'; // Red - high risk
};
return (
<div className="app">
<div className="header">
@@ -181,6 +266,88 @@ const App: React.FC = () => {
</div>
<div className="container">
{/* Consumer Status Panel */}
<section className="card consumer-card">
<div className="card-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{wsConnected ? (
<Wifi size={20} style={{ color: '#10b981' }} />
) : (
<WifiOff size={20} style={{ color: '#ef4444' }} />
)}
<h2>Real-Time Consumer Pipeline</h2>
</div>
</div>
<div className="consumer-content">
<div className="connection-status">
<span className={`status-badge ${wsConnected ? 'connected' : 'disconnected'}`}>
{wsConnected ? '● Connected' : '● Disconnected'}
</span>
</div>
{consumerStatus ? (
<div className="consumer-status-details">
<div className="status-row">
<label>Transaction ID:</label>
<span>{consumerStatus.transaction_id.substring(0, 12)}...</span>
</div>
{consumerStatus.amount && (
<div className="status-row">
<label>Amount:</label>
<span>${consumerStatus.amount.toFixed(2)}</span>
</div>
)}
{consumerStatus.merchant && (
<div className="status-row">
<label>Merchant:</label>
<span>{consumerStatus.merchant}</span>
</div>
)}
<div className="status-row">
<label>Fraud Score:</label>
<div
style={{
padding: '4px 8px',
backgroundColor: getFraudScoreColor(consumerStatus.fraud_score),
color: 'white',
borderRadius: '4px',
fontWeight: 'bold',
}}
>
{(consumerStatus.fraud_score * 100).toFixed(1)}%
</div>
</div>
<div className="status-row">
<label>Decision:</label>
<div
style={{
padding: '4px 8px',
backgroundColor: consumerStatus.decision === 'blocked' ? '#ef4444' : '#10b981',
color: 'white',
borderRadius: '4px',
fontWeight: 'bold',
textTransform: 'uppercase',
}}
>
{consumerStatus.decision}
</div>
</div>
<div className="status-row">
<label>Processed:</label>
<span>{new Date(consumerStatus.timestamp).toLocaleString()}</span>
</div>
</div>
) : (
<div className="empty-state">
<p>Waiting for fraud detection results...</p>
<p style={{ fontSize: '12px', color: '#6b7280' }}>
Submit transactions to see results
</p>
</div>
)}
</div>
</section>
{/* Authentication Section */}
<section className="card auth-card">
<div className="card-header">
@@ -308,8 +475,58 @@ const App: React.FC = () => {
<Clock size={20} />
<h2>Transaction Results</h2>
</div>
{/* Pagination Controls */}
<div className="pagination-controls">
<div className="pagination-info">
<span>
Showing {paginatedResults.length > 0 ? (currentPage - 1) * pageSize + 1 : 0}–
{Math.min(currentPage * pageSize, results.length)} of {results.length} transactions
</span>
</div>
<div className="pagination-buttons">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="btn btn-pagination"
>
<ChevronLeft size={16} /> Previous
</button>
<span className="page-indicator">
Page {currentPage} of {totalPages || 1}
</span>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="btn btn-pagination"
>
Next <ChevronRight size={16} />
</button>
</div>
<div className="page-size-selector">
<label>Items per page:</label>
<select
value={pageSize}
onChange={(e) => {
setPageSize(parseInt(e.target.value));
setCurrentPage(1); // Reset to first page
}}
className="select-input"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
</select>
</div>
</div>
{/* Results List */}
<div className="results-list">
{results.map((result, idx) => (
{paginatedResults.map((result, idx) => (
<div key={idx} className="result-item">
<div className="result-content">
<p className="result-id">