mirror of
https://github.com/status-im/market-proxy.git
synced 2026-08-27 11:51:11 +00:00
feat(test-api)_: Requests Replay util page
This commit is contained in:
@@ -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
@@ -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,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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user