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
@@ -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
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user