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

+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">