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:
1 parent
d4751b9061
commit
0387900954
5 files changed
+515
-7
No files matched your search
+98
-1
@@ -13,7 +13,7 @@ hands off responsibility to the message queue. This separation of concerns is
|
|||||||
fundamental to scalable microservices architecture.
|
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.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -23,6 +23,8 @@ import json
|
|||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
import redis
|
||||||
|
import threading
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
# CONFIGURATION
|
# CONFIGURATION
|
||||||
@@ -85,6 +87,66 @@ def get_kafka_producer():
|
|||||||
raise
|
raise
|
||||||
return kafka_producer
|
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
|
# AUTHENTICATION
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -211,6 +273,34 @@ async def get_demo_token(user_id: str):
|
|||||||
logger.info(f"[JWT GENERATION] Generated token for user={user_id}")
|
logger.info(f"[JWT GENERATION] Generated token for user={user_id}")
|
||||||
return {"access_token": token, "token_type": "bearer"}
|
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
|
# STARTUP / SHUTDOWN
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -224,6 +314,13 @@ async def startup_event():
|
|||||||
logger.info(f"Topic: {KAFKA_TOPIC}")
|
logger.info(f"Topic: {KAFKA_TOPIC}")
|
||||||
logger.info("Listening for authenticated transaction requests...")
|
logger.info("Listening for authenticated transaction requests...")
|
||||||
logger.info("="*80)
|
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")
|
@app.on_event("shutdown")
|
||||||
async def shutdown_event():
|
async def shutdown_event():
|
||||||
|
|||||||
+28
-2
@@ -122,8 +122,12 @@ class DecisionEngine:
|
|||||||
return decision
|
return decision
|
||||||
|
|
||||||
def store_result(self, transaction_id: str, decision: str,
|
def store_result(self, transaction_id: str, decision: str,
|
||||||
layer1_score: float, layer2_score: float = None) -> None:
|
layer1_score: float, layer2_score: float = None,
|
||||||
"""Store decision result in Redis for retrieval"""
|
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}"
|
key = f"result:{transaction_id}"
|
||||||
value = {
|
value = {
|
||||||
"decision": decision,
|
"decision": decision,
|
||||||
@@ -136,3 +140,25 @@ class DecisionEngine:
|
|||||||
self.redis_client.setex(key, 3600, str(value))
|
self.redis_client.setex(key, 3600, str(value))
|
||||||
|
|
||||||
logger.info(f"[RESULT STORAGE] Stored decision in Redis: {key} = {decision}")
|
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)}")
|
||||||
@@ -193,7 +193,8 @@ def process_transaction(transaction, components):
|
|||||||
transaction_id=transaction_id,
|
transaction_id=transaction_id,
|
||||||
decision=decision,
|
decision=decision,
|
||||||
layer1_score=layer1_score,
|
layer1_score=layer1_score,
|
||||||
layer2_score=layer2_score
|
layer2_score=layer2_score,
|
||||||
|
transaction_data=transaction # Pass full transaction for pub/sub message
|
||||||
)
|
)
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -345,3 +345,170 @@
|
|||||||
font-size: 0.8rem;
|
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
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { AlertCircle, CheckCircle, Clock, Lock, Send } from 'lucide-react';
|
import { AlertCircle, CheckCircle, Clock, Lock, Send, Wifi, WifiOff, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -28,6 +28,15 @@ interface Result {
|
|||||||
timestamp: string;
|
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
|
// Use localhost when browser accesses from host machine
|
||||||
const API_BASE_URL = 'http://localhost:8000';
|
const API_BASE_URL = 'http://localhost:8000';
|
||||||
|
|
||||||
@@ -46,9 +55,72 @@ const App: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [results, setResults] = useState<Result[]>([]);
|
const [results, setResults] = useState<Result[]>([]);
|
||||||
|
const [consumerStatus, setConsumerStatus] = useState<FraudResult | null>(null);
|
||||||
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = 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
|
// Generate JWT token
|
||||||
const generateToken = async () => {
|
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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<div className="header">
|
<div className="header">
|
||||||
@@ -181,6 +266,88 @@ const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container">
|
<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 */}
|
{/* Authentication Section */}
|
||||||
<section className="card auth-card">
|
<section className="card auth-card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
@@ -308,8 +475,58 @@ const App: React.FC = () => {
|
|||||||
<Clock size={20} />
|
<Clock size={20} />
|
||||||
<h2>Transaction Results</h2>
|
<h2>Transaction Results</h2>
|
||||||
</div>
|
</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">
|
<div className="results-list">
|
||||||
{results.map((result, idx) => (
|
{paginatedResults.map((result, idx) => (
|
||||||
<div key={idx} className="result-item">
|
<div key={idx} className="result-item">
|
||||||
<div className="result-content">
|
<div className="result-content">
|
||||||
<p className="result-id">
|
<p className="result-id">
|
||||||
|
|||||||
Reference in new issue
Block a user