feat(test-api)_: Requests Replay util page

This commit is contained in:
Andrey Bocharnikov
2025-08-05 13:36:11 +04:00
parent 66512c14f7
commit ac31eb7529
9 changed files with 1275 additions and 230 deletions
+2 -2
View File
@@ -70,14 +70,14 @@ add_cors_config() {
print " add_header '\''Access-Control-Allow-Origin'\'' '\''" origin "'\'' always;"
print " add_header '\''Access-Control-Allow-Methods'\'' '\''GET, POST, OPTIONS'\'' always;"
print " add_header '\''Access-Control-Allow-Headers'\'' '\''DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,If-None-Match,Accept-Encoding'\'' always;"
print " add_header '\''Access-Control-Expose-Headers'\'' '\''Content-Length,Content-Range,X-Proxy-Cache,X-Response-Size,ETag,Content-Encoding,Vary'\'' always;"
print " add_header '\''Access-Control-Expose-Headers'\'' '\''Content-Length,Content-Range,X-Proxy-Cache,X-Response-Size,ETag,Content-Encoding,Vary,Cache-Status'\'' always;"
print ""
print " # Handle OPTIONS method"
print " if (\$request_method = '\''OPTIONS'\'') {"
print " add_header '\''Access-Control-Allow-Origin'\'' '\''" origin "'\'' always;"
print " add_header '\''Access-Control-Allow-Methods'\'' '\''GET, POST, OPTIONS'\'' always;"
print " add_header '\''Access-Control-Allow-Headers'\'' '\''DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,If-None-Match,Accept-Encoding'\'' always;"
print " add_header '\''Access-Control-Expose-Headers'\'' '\''Content-Length,Content-Range,X-Proxy-Cache,X-Response-Size,ETag,Content-Encoding,Vary'\'' always;"
print " add_header '\''Access-Control-Expose-Headers'\'' '\''Content-Length,Content-Range,X-Proxy-Cache,X-Response-Size,ETag,Content-Encoding,Vary,Cache-Status'\'' always;"
print " add_header '\''Access-Control-Max-Age'\'' 1728000;"
print " add_header '\''Content-Type'\'' '\''text/plain; charset=utf-8'\'';"
print " return 204;"
File diff suppressed because one or more lines are too long
+18 -211
View File
@@ -1,227 +1,34 @@
import React, { useState } from 'react';
import ErrorBoundary from './components/ErrorBoundary';
import CryptoDataTable from './components/CryptoDataTable';
import Layout from './components/Layout';
import Tabs from './components/Tabs';
import Stats from './components/Stats';
import TokenDetails from './components/TokenDetails';
import { Loading, Error } from './components/LoadingAndErrors';
import useCoinGeckoData from './hooks/useCoinGeckoData';
import useCoinGeckoPriceData from './hooks/useCoinGeckoPriceData';
import MainPage from './components/MainPage';
import Leaderboard from './components/Leaderboard';
import RequestReplay from './components/RequestReplay';
function App() {
// Main tab state
const [activeMainTab, setActiveMainTab] = useState('CoinGecko');
const [currentPage, setCurrentPage] = useState('main');
// Sub tab state
const [activeTab, setActiveTab] = useState('All');
// Endpoint state for prices
const [priceEndpoint, setPriceEndpoint] = useState('prices');
// Endpoint state for token data
const [tokenEndpoint, setTokenEndpoint] = useState('leaderboard');
// Selected token state
const [selectedToken, setSelectedToken] = useState(null);
// CoinGecko data with token endpoint parameter
const {
coinGeckoData,
isLoading: isLoadingCoinGecko,
error: coinGeckoError,
stats: coinGeckoStats
} = useCoinGeckoData(tokenEndpoint);
const {
coinGeckoPriceData,
// isLoading: isLoadingCoinGeckoPrices, // Not used currently
error: coinGeckoPriceError,
stats: coinGeckoPriceStats
} = useCoinGeckoPriceData(priceEndpoint);
// Main tabs
const mainTabs = ['CoinGecko'];
// Filter tabs for each data source
const tabs = ['All', '🔥 Trending', 'New', 'Gainers', 'Losers', 'Meme', 'AI', 'Gaming', '⭐ Watchlist'];
const isLoading = (activeMainTab === 'CoinGecko' && isLoadingCoinGecko && coinGeckoData.length === 0);
const error = (activeMainTab === 'CoinGecko' && (coinGeckoError || coinGeckoPriceError));
// Handle token selection
const handleTokenClick = (token) => {
setSelectedToken(token);
const handleNavigate = (page) => {
setCurrentPage(page);
};
const handleCloseTokenDetails = () => {
setSelectedToken(null);
const handleBack = () => {
setCurrentPage('main');
};
// Get the current active data and price data based on active tab
const getCurrentData = () => {
if (activeMainTab === 'CoinGecko') {
return {
data: coinGeckoData,
priceData: coinGeckoPriceData,
source: 'CoinGecko'
};
} else {
return {};
const renderCurrentPage = () => {
switch (currentPage) {
case 'leaderboard':
return <Leaderboard onBack={handleBack} />;
case 'requests-replay':
return <RequestReplay onBack={handleBack} />;
default:
return <MainPage onNavigate={handleNavigate} />;
}
};
if (isLoading) {
return (
<Layout title="Crypto Dashboard">
<Loading />
</Layout>
);
}
if (error) {
return (
<Layout title="Crypto Dashboard">
<Error message={error} />
</Layout>
);
}
const { data, priceData: activePrice, source } = getCurrentData();
return (
<Layout title="Crypto Dashboard">
{/* Token data source switcher */}
<div style={{
marginBottom: '20px',
padding: '15px',
background: '#f5f5f5',
borderRadius: '8px',
border: '1px solid #ddd'
}}>
<h3 style={{ margin: '0 0 10px 0', fontSize: '16px', color: '#333' }}>Token Data Source:</h3>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={() => setTokenEndpoint('leaderboard')}
style={{
padding: '8px 16px',
border: '1px solid #28a745',
borderRadius: '4px',
backgroundColor: tokenEndpoint === 'leaderboard' ? '#28a745' : '#fff',
color: tokenEndpoint === 'leaderboard' ? '#fff' : '#28a745',
cursor: 'pointer',
fontSize: '14px'
}}
>
Optimized Leaderboard
</button>
<button
onClick={() => setTokenEndpoint('coins')}
style={{
padding: '8px 16px',
border: '1px solid #28a745',
borderRadius: '4px',
backgroundColor: tokenEndpoint === 'coins' ? '#28a745' : '#fff',
color: tokenEndpoint === 'coins' ? '#fff' : '#28a745',
cursor: 'pointer',
fontSize: '14px'
}}
>
Coins/Markets (250)
</button>
</div>
<p style={{ margin: '10px 0 0 0', fontSize: '12px', color: '#666' }}>
{tokenEndpoint === 'leaderboard'
? 'Using /v1/leaderboard/markets - optimized endpoint with curated token data'
: 'Using /v1/coins/markets?per_page=250 - first 250 tokens from standard coins endpoint'
}
</p>
</div>
{/* Price endpoint switcher */}
<div style={{
marginBottom: '20px',
padding: '15px',
background: '#f5f5f5',
borderRadius: '8px',
border: '1px solid #ddd'
}}>
<h3 style={{ margin: '0 0 10px 0', fontSize: '16px', color: '#333' }}>Price Data Source:</h3>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={() => setPriceEndpoint('prices')}
style={{
padding: '8px 16px',
border: '1px solid #007bff',
borderRadius: '4px',
backgroundColor: priceEndpoint === 'prices' ? '#007bff' : '#fff',
color: priceEndpoint === 'prices' ? '#fff' : '#007bff',
cursor: 'pointer',
fontSize: '14px'
}}
>
By Symbol (Binance Format)
</button>
<button
onClick={() => setPriceEndpoint('simpleprices')}
style={{
padding: '8px 16px',
border: '1px solid #007bff',
borderRadius: '4px',
backgroundColor: priceEndpoint === 'simpleprices' ? '#007bff' : '#fff',
color: priceEndpoint === 'simpleprices' ? '#fff' : '#007bff',
cursor: 'pointer',
fontSize: '14px'
}}
>
By Token ID (CoinGecko Format)
</button>
</div>
<p style={{ margin: '10px 0 0 0', fontSize: '12px', color: '#666' }}>
{priceEndpoint === 'prices'
? 'Using /v1/leaderboard/prices - returns prices by symbol (BTC, ETH, etc.)'
: 'Using /v1/leaderboard/simpleprices - returns prices by token ID (bitcoin, ethereum, etc.)'
}
</p>
</div>
{/* Show appropriate stats based on active main tab */}
{(
<>
<Stats stats={coinGeckoStats} title={`CoinGecko Data Stats (${tokenEndpoint})`} />
<Stats stats={coinGeckoPriceStats} title={`CoinGecko Price Data Stats (${priceEndpoint})`} />
</>
)}
{/* Tabs navigation */}
<Tabs
mainTabs={mainTabs}
activeMainTab={activeMainTab}
onMainTabChange={setActiveMainTab}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Use shared table component for both data sources */}
<ErrorBoundary>
<CryptoDataTable
data={data}
priceData={activePrice}
source={source}
priceEndpoint={priceEndpoint}
onTokenClick={handleTokenClick}
/>
</ErrorBoundary>
{/* Token details modal */}
{selectedToken && (
<TokenDetails
token={selectedToken}
onClose={handleCloseTokenDetails}
/>
)}
<Layout>
{renderCurrentPage()}
</Layout>
);
}
+2 -8
View File
@@ -2,20 +2,14 @@ import React from 'react';
import styled from 'styled-components';
const Container = styled.div`
padding: 20px;
background-color: #fff;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
min-height: 100vh;
`;
const Header = styled.h1`
font-size: 24px;
margin-bottom: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
`;
function Layout({ children, title }) {
return (
<Container>
<Header>{title}</Header>
{children}
</Container>
);
+275
View File
@@ -0,0 +1,275 @@
import React, { useState } from 'react';
import ErrorBoundary from './ErrorBoundary';
import CryptoDataTable from './CryptoDataTable';
import Tabs from './Tabs';
import Stats from './Stats';
import TokenDetails from './TokenDetails';
import { Loading, Error } from './LoadingAndErrors';
import useCoinGeckoData from '../hooks/useCoinGeckoData';
import useCoinGeckoPriceData from '../hooks/useCoinGeckoPriceData';
import styled from 'styled-components';
const Container = styled.div`
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
`;
const BackButton = styled.button`
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: transparent;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #647084;
cursor: pointer;
font-size: 14px;
font-weight: 500;
margin-bottom: 24px;
transition: all 0.2s ease;
&:hover {
background: #f9fafb;
border-color: #d1d5db;
}
`;
const Header = styled.div`
margin-bottom: 32px;
`;
const Title = styled.h1`
font-size: 32px;
font-weight: 600;
color: #09101c;
margin: 0 0 8px 0;
`;
const Description = styled.p`
font-size: 16px;
color: #647084;
margin: 0;
`;
const ConfigSection = styled.div`
margin-bottom: 20px;
padding: 20px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
`;
const SectionTitle = styled.h3`
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: #374151;
`;
const ButtonGroup = styled.div`
display: flex;
gap: 8px;
flex-wrap: wrap;
`;
const ToggleButton = styled.button`
padding: 8px 16px;
border: 1px solid;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
${props => props.active ? `
background-color: ${props.color || '#6366f1'};
border-color: ${props.color || '#6366f1'};
color: white;
` : `
background-color: white;
border-color: ${props.color || '#6366f1'};
color: ${props.color || '#6366f1'};
`}
&:hover {
opacity: 0.8;
}
`;
const ConfigDescription = styled.p`
margin: 12px 0 0 0;
font-size: 12px;
color: #6b7280;
line-height: 1.4;
`;
function Leaderboard({ onBack }) {
// Sub tab state
const [activeTab, setActiveTab] = useState('All');
// Endpoint state for prices
const [priceEndpoint, setPriceEndpoint] = useState('prices');
// Endpoint state for token data
const [tokenEndpoint, setTokenEndpoint] = useState('leaderboard');
// Selected token state
const [selectedToken, setSelectedToken] = useState(null);
// CoinGecko data with token endpoint parameter
const {
coinGeckoData,
isLoading: isLoadingCoinGecko,
error: coinGeckoError,
stats: coinGeckoStats
} = useCoinGeckoData(tokenEndpoint);
const {
coinGeckoPriceData,
error: coinGeckoPriceError,
stats: coinGeckoPriceStats
} = useCoinGeckoPriceData(priceEndpoint);
// Main tabs
const mainTabs = ['CoinGecko'];
// Filter tabs for each data source
const tabs = ['All', '🔥 Trending', 'New', 'Gainers', 'Losers', 'Meme', 'AI', 'Gaming', '⭐ Watchlist'];
const isLoading = isLoadingCoinGecko && coinGeckoData.length === 0;
const error = coinGeckoError || coinGeckoPriceError;
// Handle token selection
const handleTokenClick = (token) => {
setSelectedToken(token);
};
const handleCloseTokenDetails = () => {
setSelectedToken(null);
};
if (isLoading) {
return (
<Container>
<Loading />
</Container>
);
}
if (error) {
return (
<Container>
<Error message={error} />
</Container>
);
}
return (
<Container>
<BackButton onClick={onBack}>
Back to Utilities
</BackButton>
<Header>
<Title>Crypto Leaderboard</Title>
<Description>
Real-time cryptocurrency market data from CoinGecko with configurable data sources
</Description>
</Header>
{/* Token data source switcher */}
<ConfigSection>
<SectionTitle>Token Data Source</SectionTitle>
<ButtonGroup>
<ToggleButton
onClick={() => setTokenEndpoint('leaderboard')}
active={tokenEndpoint === 'leaderboard'}
color="#10b981"
>
Optimized Leaderboard
</ToggleButton>
<ToggleButton
onClick={() => setTokenEndpoint('coins')}
active={tokenEndpoint === 'coins'}
color="#10b981"
>
Coins/Markets (250)
</ToggleButton>
</ButtonGroup>
<ConfigDescription>
{tokenEndpoint === 'leaderboard'
? 'Using /v1/leaderboard/markets - optimized endpoint with curated token data'
: 'Using /v1/coins/markets?per_page=250 - first 250 tokens from standard coins endpoint'
}
</ConfigDescription>
</ConfigSection>
{/* Price endpoint switcher */}
<ConfigSection>
<SectionTitle>Price Data Source</SectionTitle>
<ButtonGroup>
<ToggleButton
onClick={() => setPriceEndpoint('prices')}
active={priceEndpoint === 'prices'}
color="#3b82f6"
>
By Symbol (Binance Format)
</ToggleButton>
<ToggleButton
onClick={() => setPriceEndpoint('simpleprices')}
active={priceEndpoint === 'simpleprices'}
color="#3b82f6"
>
By Token ID (CoinGecko Format)
</ToggleButton>
</ButtonGroup>
<ConfigDescription>
{priceEndpoint === 'prices'
? 'Using /v1/leaderboard/prices - returns prices by symbol (BTC, ETH, etc.)'
: 'Using /v1/leaderboard/simpleprices - returns prices by token ID (bitcoin, ethereum, etc.)'
}
</ConfigDescription>
</ConfigSection>
{/* Show appropriate stats */}
<>
<Stats stats={coinGeckoStats} title={`CoinGecko Data Stats (${tokenEndpoint})`} />
<Stats stats={coinGeckoPriceStats} title={`CoinGecko Price Data Stats (${priceEndpoint})`} />
</>
{/* Tabs navigation */}
<Tabs
mainTabs={mainTabs}
activeMainTab="CoinGecko"
onMainTabChange={() => {}}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Use shared table component for both data sources */}
<ErrorBoundary>
<CryptoDataTable
data={coinGeckoData}
priceData={coinGeckoPriceData}
source="CoinGecko"
priceEndpoint={priceEndpoint}
onTokenClick={handleTokenClick}
/>
</ErrorBoundary>
{/* Token details modal */}
{selectedToken && (
<TokenDetails
token={selectedToken}
onClose={handleCloseTokenDetails}
/>
)}
</Container>
);
}
export default Leaderboard;
+149
View File
@@ -0,0 +1,149 @@
import React from 'react';
import styled from 'styled-components';
const Container = styled.div`
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
`;
const Header = styled.div`
text-align: center;
margin-bottom: 60px;
`;
const Title = styled.h1`
font-size: 48px;
font-weight: 600;
color: #09101c;
margin: 0 0 16px 0;
letter-spacing: -0.02em;
`;
const Subtitle = styled.p`
font-size: 20px;
color: #647084;
margin: 0;
font-weight: 400;
`;
const UtilitiesGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-top: 40px;
`;
const UtilityCard = styled.div`
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 32px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border-color: #6366f1;
}
`;
const UtilityIcon = styled.div`
width: 48px;
height: 48px;
background: ${props => props.color || '#6366f1'};
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
font-size: 24px;
`;
const UtilityTitle = styled.h3`
font-size: 20px;
font-weight: 600;
color: #09101c;
margin: 0 0 8px 0;
`;
const UtilityDescription = styled.p`
font-size: 16px;
color: #647084;
line-height: 1.5;
margin: 0;
`;
const StatusBadge = styled.span`
display: inline-block;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
margin-top: 12px;
${props => props.status === 'available' && `
background: #dcfce7;
color: #166534;
`}
${props => props.status === 'development' && `
background: #fef3c7;
color: #92400e;
`}
`;
const MainPage = ({ onNavigate }) => {
const utilities = [
{
id: 'leaderboard',
title: 'Leaderboard',
description: 'Crypto market data dashboard with real-time prices and market statistics from CoinGecko API',
icon: '📊',
color: '#6366f1',
status: 'available'
},
{
id: 'requests-replay',
title: 'Requests Replay',
description: 'Tool for replaying HTTP requests from logged NDJSON files to test rate limiting behavior',
icon: '🔄',
color: '#8b5cf6',
status: 'available'
}
];
return (
<Container>
<Header>
<Title>Market Proxy Utilities</Title>
<Subtitle>
Development and testing tools for the Status Market Proxy service
</Subtitle>
</Header>
<UtilitiesGrid>
{utilities.map((utility) => (
<UtilityCard
key={utility.id}
onClick={() => onNavigate(utility.id)}
>
<UtilityIcon color={utility.color}>
{utility.icon}
</UtilityIcon>
<UtilityTitle>{utility.title}</UtilityTitle>
<UtilityDescription>{utility.description}</UtilityDescription>
<StatusBadge status={utility.status}>
{utility.status === 'available' ? 'Available' : 'In Development'}
</StatusBadge>
</UtilityCard>
))}
</UtilitiesGrid>
</Container>
);
};
export default MainPage;
+599
View File
@@ -0,0 +1,599 @@
import React, { useState, useEffect, useRef } from 'react';
import styled from 'styled-components';
import { proxyFetch, extractEndpointFromUrl } from '../utils/proxy_request';
const Container = styled.div`
max-width: 1400px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
`;
const Header = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #e5e7eb;
`;
const Title = styled.h1`
font-size: 32px;
font-weight: 600;
color: #09101c;
margin: 0;
`;
const BackButton = styled.button`
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #e5e7eb;
}
`;
const ControlsSection = styled.div`
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
`;
const ControlRow = styled.div`
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
`;
const FileInput = styled.input`
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
`;
const Select = styled.select`
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
background: white;
`;
const Button = styled.button`
background: ${props => props.variant === 'primary' ? '#6366f1' : '#f3f4f6'};
color: ${props => props.variant === 'primary' ? 'white' : '#374151'};
border: 1px solid ${props => props.variant === 'primary' ? '#6366f1' : '#d1d5db'};
border-radius: 8px;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: ${props => props.variant === 'primary' ? '#5856eb' : '#e5e7eb'};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const TableContainer = styled.div`
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
`;
const Table = styled.table`
width: 100%;
border-collapse: collapse;
`;
const TableHeader = styled.thead`
background: #f9fafb;
`;
const TableRow = styled.tr`
border-bottom: 1px solid #e5e7eb;
&:hover {
background: #f9fafb;
}
`;
const TableHeaderCell = styled.th`
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #374151;
font-size: 14px;
`;
const TableCell = styled.td`
padding: 12px 16px;
font-size: 14px;
color: #374151;
`;
const Checkbox = styled.input`
margin: 0;
`;
const ProgressBar = styled.div`
width: 100%;
height: 20px;
background: #e5e7eb;
border-radius: 10px;
overflow: hidden;
`;
const ProgressFill = styled.div`
height: 100%;
background: ${props => props.status === 'completed' ? '#10b981' : props.status === 'error' ? '#ef4444' : '#6366f1'};
width: ${props => props.progress}%;
transition: width 0.3s ease;
`;
const StatusBadge = styled.span`
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
${props => props.status === 'completed' && `
background: #dcfce7;
color: #166534;
`}
${props => props.status === 'error' && `
background: #fee2e2;
color: #991b1b;
`}
${props => props.status === 'running' && `
background: #dbeafe;
color: #1e40af;
`}
${props => props.status === 'pending' && `
background: #f3f4f6;
color: #374151;
`}
`;
const PlayButton = styled.button`
background: #10b981;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
transition: all 0.2s ease;
&:hover {
background: #059669;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const ClickableRow = styled(TableRow)`
cursor: pointer;
&:hover {
background: #f9fafb;
}
`;
const NonClickableCell = styled(TableCell)`
position: relative;
`;
const RequestReplay = ({ onBack }) => {
const [requests, setRequests] = useState([]);
const [selectedRequests, setSelectedRequests] = useState(new Set());
const [runMode, setRunMode] = useState('sequential');
const [isPlaying, setIsPlaying] = useState(false);
const [requestStatus, setRequestStatus] = useState({});
const fileInputRef = useRef(null);
// Load default requests.ndjson file on mount
useEffect(() => {
loadRequestsFile('/requests.ndjson');
}, []);
const loadRequestsFile = async (filePath) => {
try {
const response = await fetch(filePath);
const text = await response.text();
parseRequestsFile(text);
} catch (error) {
console.error('Error loading requests file:', error);
}
};
const parseRequestsFile = (text) => {
const lines = text.split('\n').filter(line => line.trim());
const parsedRequests = lines.map((line, index) => {
try {
const request = JSON.parse(line);
return {
id: index,
timestamp: request.ts,
delta: request.delta,
method: request.method,
url: request.url,
status: request.status,
duration: request.ms,
endpoint: extractEndpoint(request.url),
idCount: extractIdCount(request.url)
};
} catch (error) {
console.error('Error parsing line:', line, error);
return null;
}
}).filter(Boolean);
setRequests(parsedRequests);
setSelectedRequests(new Set());
setRequestStatus({});
};
const extractEndpoint = (url) => {
// Extract endpoint without parameters
const urlObj = new URL(url, 'http://localhost');
return urlObj.pathname;
};
const extractIdCount = (url) => {
// Extract ID count from URL parameters
const urlObj = new URL(url, 'http://localhost');
const ids = urlObj.searchParams.get('ids');
if (ids) {
return ids.split(',').length;
}
return 0;
};
const handleFileUpload = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
parseRequestsFile(e.target.result);
};
reader.readAsText(file);
}
};
const toggleSelectAll = () => {
if (selectedRequests.size === requests.length) {
setSelectedRequests(new Set());
} else {
setSelectedRequests(new Set(requests.map(r => r.id)));
}
};
const toggleRequestSelection = (requestId) => {
const newSelection = new Set(selectedRequests);
if (newSelection.has(requestId)) {
newSelection.delete(requestId);
} else {
newSelection.add(requestId);
}
setSelectedRequests(newSelection);
};
const executeRequest = async (request) => {
const startTime = Date.now();
try {
// Update status to running
setRequestStatus(prev => ({
...prev,
[request.id]: { status: 'running', progress: 0, startTime }
}));
// Simulate progress updates
const progressInterval = setInterval(() => {
setRequestStatus(prev => ({
...prev,
[request.id]: {
...prev[request.id],
progress: Math.min((Date.now() - startTime) / 50, 95)
}
}));
}, 50);
// Extract endpoint from the original URL and make request through proxy
const endpoint = extractEndpointFromUrl(request.url);
const response = await proxyFetch(endpoint);
const responseTime = Date.now() - startTime;
clearInterval(progressInterval);
// Check if response is from cache
const isCached = response.headers.get('x-cache-status') === 'HIT' ||
response.headers.get('cache-control') ||
response.status === 304;
const cacheStatus = response.headers.get('cache-status');
// Check response data count
let responseCount = 0;
if (response.ok) {
const data = await response.json();
if (Array.isArray(data)) {
responseCount = data.length;
} else if (data && typeof data === 'object') {
responseCount = Object.keys(data).length;
}
}
setRequestStatus(prev => ({
...prev,
[request.id]: {
status: response.ok ? 'completed' : 'error',
progress: 100,
responseTime,
responseCount,
expectedCount: request.idCount,
isCached,
cacheStatus,
statusCode: response.status
}
}));
} catch (error) {
const responseTime = Date.now() - startTime;
setRequestStatus(prev => ({
...prev,
[request.id]: {
status: 'error',
progress: 100,
responseTime,
error: error.message
}
}));
}
};
const playSingleRequest = async (request) => {
// Reset status for this request
setRequestStatus(prev => ({
...prev,
[request.id]: { status: 'pending', progress: 0 }
}));
await executeRequest(request);
};
const playRequests = async () => {
if (selectedRequests.size === 0) return;
setIsPlaying(true);
const selectedRequestList = requests.filter(r => selectedRequests.has(r.id));
// Reset status for selected requests
const resetStatus = {};
selectedRequestList.forEach(req => {
resetStatus[req.id] = { status: 'pending', progress: 0 };
});
setRequestStatus(resetStatus);
try {
if (runMode === 'simultaneous') {
// Run all requests simultaneously
await Promise.all(selectedRequestList.map(executeRequest));
} else {
// Run requests sequentially
for (let i = 0; i < selectedRequestList.length; i++) {
const request = selectedRequestList[i];
await executeRequest(request);
if (i < selectedRequestList.length - 1) {
// Calculate delay based on original timing or max 1 second
let delay = 0;
if (runMode === 'sequential') {
const nextRequest = selectedRequestList[i + 1];
delay = Math.abs(nextRequest.delta);
} else if (runMode === 'sequential-limited') {
const nextRequest = selectedRequestList[i + 1];
delay = Math.min(Math.abs(nextRequest.delta), 1000);
}
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
}
} finally {
setIsPlaying(false);
}
};
const handleRowClick = (requestId, event) => {
// Don't toggle if clicking on checkbox or play button
if (event.target.type === 'checkbox' || event.target.closest('button')) {
return;
}
toggleRequestSelection(requestId);
};
const formatDuration = (ms) => {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
};
const getProgressInfo = (request) => {
const status = requestStatus[request.id];
if (!status) return { progress: 0, text: 'Pending' };
switch (status.status) {
case 'running':
return { progress: status.progress, text: 'Running...' };
case 'completed':
const countMatch = status.expectedCount === status.responseCount;
const cacheInfo = status.isCached ? ' (cached)' : '';
const cacheStatusInfo = status.cacheStatus ? ` [${status.cacheStatus}]` : '';
return {
progress: 100,
text: `${formatDuration(status.responseTime)}${cacheInfo}${cacheStatusInfo} - ${status.responseCount}/${status.expectedCount} ${countMatch ? '✓' : '⚠️'}`
};
case 'error':
return { progress: 100, text: `Error: ${status.error || 'Request failed'}` };
default:
return { progress: 0, text: 'Pending' };
}
};
return (
<Container>
<Header>
<Title>Requests Replay</Title>
<BackButton onClick={onBack}> Back to Main</BackButton>
</Header>
<ControlsSection>
<ControlRow>
<label>Load File:</label>
<FileInput
type="file"
accept=".ndjson,.json,.txt"
onChange={handleFileUpload}
ref={fileInputRef}
/>
<Button onClick={() => loadRequestsFile('/requests.ndjson')}>
Load Default (requests.ndjson)
</Button>
</ControlRow>
<ControlRow>
<label>Run Mode:</label>
<Select value={runMode} onChange={(e) => setRunMode(e.target.value)}>
<option value="sequential">Sequential (original timing)</option>
<option value="sequential-limited">Sequential (max 1 sec delay)</option>
<option value="simultaneous">Simultaneous</option>
</Select>
<Button
variant="primary"
onClick={playRequests}
disabled={isPlaying || selectedRequests.size === 0}
>
{isPlaying ? 'Playing...' : `Play (${selectedRequests.size} selected)`}
</Button>
<Button onClick={toggleSelectAll}>
{selectedRequests.size === requests.length ? 'Deselect All' : 'Select All'}
</Button>
</ControlRow>
</ControlsSection>
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Select</TableHeaderCell>
<TableHeaderCell>Play</TableHeaderCell>
<TableHeaderCell>Time Offset</TableHeaderCell>
<TableHeaderCell>Endpoint</TableHeaderCell>
<TableHeaderCell>ID Count</TableHeaderCell>
<TableHeaderCell>Progress</TableHeaderCell>
</TableRow>
</TableHeader>
<tbody>
{requests.map((request) => {
const progressInfo = getProgressInfo(request);
const status = requestStatus[request.id];
const isRequestRunning = status?.status === 'running';
return (
<ClickableRow
key={request.id}
onClick={(e) => handleRowClick(request.id, e)}
>
<NonClickableCell>
<Checkbox
type="checkbox"
checked={selectedRequests.has(request.id)}
onChange={() => toggleRequestSelection(request.id)}
/>
</NonClickableCell>
<NonClickableCell>
<PlayButton
onClick={(e) => {
e.stopPropagation();
playSingleRequest(request);
}}
disabled={isRequestRunning}
title="Play this request"
>
</PlayButton>
</NonClickableCell>
<TableCell>{formatDuration(Math.abs(request.delta))}</TableCell>
<TableCell>{request.endpoint}</TableCell>
<TableCell>{request.idCount}</TableCell>
<TableCell>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<ProgressBar style={{ width: '120px' }}>
<ProgressFill
progress={progressInfo.progress}
status={status?.status}
/>
</ProgressBar>
<span style={{ fontSize: '12px', minWidth: '200px' }}>
{progressInfo.text}
</span>
{status?.status && (
<StatusBadge status={status.status}>
{status.status}
</StatusBadge>
)}
</div>
</TableCell>
</ClickableRow>
);
})}
</tbody>
</Table>
</TableContainer>
{requests.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px', color: '#6b7280' }}>
No requests loaded. Upload a file or load the default requests.ndjson file.
</div>
)}
</Container>
);
};
export default RequestReplay;
+5 -9
View File
@@ -1,5 +1,5 @@
import { useState, useRef } from 'react';
import axios from 'axios';
import { proxyGet } from '../utils/proxy_request';
/**
* A utility hook for making API requests with ETag support and statistics tracking
@@ -72,15 +72,11 @@ export default function useApiRequest({
}
log("send etag:", etagRef.current)
// Make the API request
const response = await axios.get(url, {
...requestConfig,
// Make the API request using proxy utility
const response = await proxyGet(url, {
headers,
auth: requestConfig.auth || {
username: process.env.REACT_APP_PROXY_USER,
password: process.env.REACT_APP_PROXY_PASSWORD
},
validateStatus: status => (status >= 200 && status < 300) || status === 304
validateStatus: status => (status >= 200 && status < 300) || status === 304,
...requestConfig
});
// Output headers in raw format
+126
View File
@@ -0,0 +1,126 @@
import axios from 'axios';
/**
* Utility function for making authenticated requests to the proxy server
* @param {string} endpoint - The API endpoint path (e.g., '/v1/prices')
* @param {Object} options - Request options
* @param {Object} options.headers - Additional headers to include
* @param {Object} options.params - URL parameters
* @param {string} options.method - HTTP method (default: 'GET')
* @param {Object} options.data - Request body data for POST/PUT requests
* @param {function} options.validateStatus - Custom status validation function
* @param {boolean} options.useAxios - Whether to use axios (default: true) or fetch
* @returns {Promise} - Promise resolving to the response
*/
export const makeProxyRequest = async (endpoint, options = {}) => {
const {
headers = {},
params = {},
method = 'GET',
data = null,
validateStatus = (status) => (status >= 200 && status < 300) || status === 304,
useAxios = true
} = options;
// Construct full URL
const baseUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080';
const fullUrl = `${baseUrl}${endpoint}`;
// Prepare authentication
const auth = {
username: process.env.REACT_APP_PROXY_USER,
password: process.env.REACT_APP_PROXY_PASSWORD
};
if (useAxios) {
// Use axios for requests (preferred for useApiRequest)
const config = {
method,
url: fullUrl,
headers,
params,
auth,
validateStatus
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
config.data = data;
}
return await axios(config);
} else {
// Use fetch for requests (for RequestReplay compatibility)
const fetchHeaders = {
...headers
};
// Add basic auth header for fetch
if (auth.username && auth.password) {
const credentials = btoa(`${auth.username}:${auth.password}`);
fetchHeaders['Authorization'] = `Basic ${credentials}`;
}
// Construct URL with params
const url = new URL(fullUrl);
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
url.searchParams.append(key, value);
}
});
const fetchConfig = {
method,
headers: fetchHeaders
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
fetchConfig.body = JSON.stringify(data);
fetchHeaders['Content-Type'] = 'application/json';
}
const response = await fetch(url.toString(), fetchConfig);
// Apply status validation similar to axios
if (!validateStatus(response.status)) {
throw new Error(`Request failed with status ${response.status}`);
}
return response;
}
};
/**
* Convenience function for making GET requests with axios (for useApiRequest)
* @param {string} endpoint - The API endpoint path
* @param {Object} options - Request options (headers, params, etc.)
* @returns {Promise} - Promise resolving to axios response
*/
export const proxyGet = (endpoint, options = {}) => {
return makeProxyRequest(endpoint, { ...options, method: 'GET', useAxios: true });
};
/**
* Convenience function for making GET requests with fetch (for RequestReplay)
* @param {string} endpoint - The API endpoint path
* @param {Object} options - Request options (headers, params, etc.)
* @returns {Promise} - Promise resolving to fetch response
*/
export const proxyFetch = (endpoint, options = {}) => {
return makeProxyRequest(endpoint, { ...options, method: 'GET', useAxios: false });
};
/**
* Helper function to extract endpoint from full URL (for RequestReplay)
* @param {string} fullUrl - The complete URL
* @returns {string} - The endpoint path
*/
export const extractEndpointFromUrl = (fullUrl) => {
try {
const url = new URL(fullUrl);
return url.pathname + url.search;
} catch (error) {
// If URL parsing fails, try to extract endpoint manually
const apiMatch = fullUrl.match(/\/api\/v1(.*)/) || fullUrl.match(/\/v1(.*)/);
return apiMatch ? `/v1${apiMatch[1]}` : fullUrl;
}
};