mirror of
https://github.com/praveentcom/openproxy.git
synced 2026-06-06 14:43:58 +02:00
feat: add Next.js metrics dashboard for real-time visualization
Add a lightweight Next.js dashboard to visualize OpenProxy metrics in real-time. The dashboard provides comprehensive insights into LLM API usage, costs, and performance. Features: - Real-time metrics overview (requests, tokens, costs, response times) - Model breakdown with usage statistics - Hourly trends visualization with charts - Recent requests table with detailed information - Auto-refresh every 30 seconds - Configurable time ranges (1h, 6h, 24h, 7d) Technical details: - Built with Next.js 14 and React 18 - Uses Recharts for data visualization - Connects directly to PostgreSQL database - Runs on port 3008 by default - TypeScript for type safety - Minimal dependencies for lightweight deployment The dashboard complements the proxy server by providing a user-friendly interface for monitoring and analyzing LLM API usage patterns.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
interface MetricsOverviewProps {
|
||||
summary: {
|
||||
totalRequests: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
avgResponseTime: number;
|
||||
uniqueModels: number;
|
||||
uniqueClients: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function MetricsOverview({ summary }: MetricsOverviewProps) {
|
||||
const metrics = [
|
||||
{
|
||||
label: 'Total Requests',
|
||||
value: summary.totalRequests.toLocaleString(),
|
||||
icon: '📊',
|
||||
},
|
||||
{
|
||||
label: 'Total Tokens',
|
||||
value: summary.totalTokens.toLocaleString(),
|
||||
icon: '🔢',
|
||||
},
|
||||
{
|
||||
label: 'Total Cost',
|
||||
value: `$${summary.totalCost.toFixed(4)}`,
|
||||
icon: '💰',
|
||||
},
|
||||
{
|
||||
label: 'Avg Response Time',
|
||||
value: `${Math.round(summary.avgResponseTime)}ms`,
|
||||
icon: '⚡',
|
||||
},
|
||||
{
|
||||
label: 'Unique Models',
|
||||
value: summary.uniqueModels.toString(),
|
||||
icon: '🤖',
|
||||
},
|
||||
{
|
||||
label: 'Unique Clients',
|
||||
value: summary.uniqueClients.toString(),
|
||||
icon: '👥',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.title}>Overview</h2>
|
||||
<div style={styles.grid}>
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.label} style={styles.card}>
|
||||
<div style={styles.icon}>{metric.icon}</div>
|
||||
<div style={styles.content}>
|
||||
<div style={styles.label}>{metric.label}</div>
|
||||
<div style={styles.value}>{metric.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
marginBottom: '2rem',
|
||||
},
|
||||
title: {
|
||||
fontSize: '1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
color: '#2c3e50',
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '1rem',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
alignItems: 'center',
|
||||
},
|
||||
icon: {
|
||||
fontSize: '2rem',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
label: {
|
||||
fontSize: '0.9rem',
|
||||
color: '#7f8c8d',
|
||||
marginBottom: '0.25rem',
|
||||
},
|
||||
value: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
color: '#2c3e50',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
interface ModelBreakdownProps {
|
||||
models: {
|
||||
model: string;
|
||||
request_count: string;
|
||||
total_tokens: string;
|
||||
total_cost: string;
|
||||
avg_response_time: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function ModelBreakdown({ models }: ModelBreakdownProps) {
|
||||
if (!models || models.length === 0) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.title}>Model Breakdown</h2>
|
||||
<div style={styles.card}>
|
||||
<p style={styles.noData}>No model data available</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.title}>Model Breakdown</h2>
|
||||
<div style={styles.card}>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr style={styles.headerRow}>
|
||||
<th style={styles.th}>Model</th>
|
||||
<th style={styles.th}>Requests</th>
|
||||
<th style={styles.th}>Total Tokens</th>
|
||||
<th style={styles.th}>Total Cost</th>
|
||||
<th style={styles.th}>Avg Response Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((model) => (
|
||||
<tr key={model.model} style={styles.row}>
|
||||
<td style={styles.td}>
|
||||
<strong>{model.model}</strong>
|
||||
</td>
|
||||
<td style={styles.td}>{parseInt(model.request_count).toLocaleString()}</td>
|
||||
<td style={styles.td}>{parseInt(model.total_tokens).toLocaleString()}</td>
|
||||
<td style={styles.td}>${parseFloat(model.total_cost).toFixed(4)}</td>
|
||||
<td style={styles.td}>{Math.round(parseFloat(model.avg_response_time))}ms</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
marginBottom: '2rem',
|
||||
},
|
||||
title: {
|
||||
fontSize: '1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
color: '#2c3e50',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse' as const,
|
||||
},
|
||||
headerRow: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
},
|
||||
th: {
|
||||
padding: '1rem',
|
||||
textAlign: 'left' as const,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
color: '#2c3e50',
|
||||
borderBottom: '2px solid #e9ecef',
|
||||
},
|
||||
row: {
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
},
|
||||
td: {
|
||||
padding: '1rem',
|
||||
fontSize: '0.9rem',
|
||||
color: '#495057',
|
||||
},
|
||||
noData: {
|
||||
padding: '2rem',
|
||||
textAlign: 'center' as const,
|
||||
color: '#7f8c8d',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
interface RecentRequestsProps {
|
||||
requests: {
|
||||
request_id: string;
|
||||
timestamp: string;
|
||||
model: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
total_cost: string;
|
||||
response_time: number;
|
||||
response_status: number;
|
||||
client_ip: string;
|
||||
stream: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function RecentRequests({ requests }: RecentRequestsProps) {
|
||||
if (!requests || requests.length === 0) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.title}>Recent Requests</h2>
|
||||
<div style={styles.card}>
|
||||
<p style={styles.noData}>No recent requests</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.title}>Recent Requests</h2>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.tableWrapper}>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr style={styles.headerRow}>
|
||||
<th style={styles.th}>Timestamp</th>
|
||||
<th style={styles.th}>Model</th>
|
||||
<th style={styles.th}>Tokens</th>
|
||||
<th style={styles.th}>Cost</th>
|
||||
<th style={styles.th}>Response Time</th>
|
||||
<th style={styles.th}>Status</th>
|
||||
<th style={styles.th}>Client IP</th>
|
||||
<th style={styles.th}>Stream</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requests.map((req) => (
|
||||
<tr key={req.request_id} style={styles.row}>
|
||||
<td style={styles.td}>
|
||||
{new Date(req.timestamp).toLocaleString()}
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.modelBadge}>{req.model}</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<div style={styles.tokenBreakdown}>
|
||||
<small style={styles.tokenDetail}>
|
||||
{req.prompt_tokens} + {req.completion_tokens} = {req.total_tokens}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
<td style={styles.td}>${parseFloat(req.total_cost).toFixed(4)}</td>
|
||||
<td style={styles.td}>{req.response_time}ms</td>
|
||||
<td style={styles.td}>
|
||||
<span
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
...(req.response_status === 200
|
||||
? styles.statusSuccess
|
||||
: styles.statusError),
|
||||
}}
|
||||
>
|
||||
{req.response_status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={styles.td}>{req.client_ip}</td>
|
||||
<td style={styles.td}>{req.stream ? '✓' : '✗'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
marginBottom: '2rem',
|
||||
},
|
||||
title: {
|
||||
fontSize: '1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
color: '#2c3e50',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
tableWrapper: {
|
||||
overflowX: 'auto' as const,
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse' as const,
|
||||
minWidth: '1000px',
|
||||
},
|
||||
headerRow: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
},
|
||||
th: {
|
||||
padding: '1rem',
|
||||
textAlign: 'left' as const,
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
color: '#2c3e50',
|
||||
borderBottom: '2px solid #e9ecef',
|
||||
},
|
||||
row: {
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
},
|
||||
td: {
|
||||
padding: '0.75rem 1rem',
|
||||
fontSize: '0.85rem',
|
||||
color: '#495057',
|
||||
},
|
||||
modelBadge: {
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
tokenBreakdown: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
},
|
||||
tokenDetail: {
|
||||
color: '#7f8c8d',
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
statusBadge: {
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
statusSuccess: {
|
||||
backgroundColor: '#d4edda',
|
||||
color: '#155724',
|
||||
},
|
||||
statusError: {
|
||||
backgroundColor: '#f8d7da',
|
||||
color: '#721c24',
|
||||
},
|
||||
noData: {
|
||||
padding: '2rem',
|
||||
textAlign: 'center' as const,
|
||||
color: '#7f8c8d',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface TrendsChartProps {
|
||||
trends: {
|
||||
hour: string;
|
||||
requests: string;
|
||||
tokens: string;
|
||||
cost: string;
|
||||
avg_response_time: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function TrendsChart({ trends }: TrendsChartProps) {
|
||||
if (!trends || trends.length === 0) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.title}>Hourly Trends</h2>
|
||||
<div style={styles.card}>
|
||||
<p style={styles.noData}>No trend data available</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = trends.map((trend) => ({
|
||||
time: new Date(trend.hour).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
}),
|
||||
requests: parseInt(trend.requests),
|
||||
tokens: parseInt(trend.tokens),
|
||||
cost: parseFloat(trend.cost),
|
||||
responseTime: Math.round(parseFloat(trend.avg_response_time)),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.title}>Hourly Trends</h2>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.chartContainer}>
|
||||
<h3 style={styles.chartTitle}>Requests & Response Time</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<XAxis dataKey="time" stroke="#7f8c8d" fontSize={12} />
|
||||
<YAxis yAxisId="left" stroke="#3498db" fontSize={12} />
|
||||
<YAxis yAxisId="right" orientation="right" stroke="#e74c3c" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="requests"
|
||||
stroke="#3498db"
|
||||
strokeWidth={2}
|
||||
name="Requests"
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="responseTime"
|
||||
stroke="#e74c3c"
|
||||
strokeWidth={2}
|
||||
name="Avg Response Time (ms)"
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div style={styles.chartContainer}>
|
||||
<h3 style={styles.chartTitle}>Tokens & Cost</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<XAxis dataKey="time" stroke="#7f8c8d" fontSize={12} />
|
||||
<YAxis yAxisId="left" stroke="#9b59b6" fontSize={12} />
|
||||
<YAxis yAxisId="right" orientation="right" stroke="#27ae60" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="tokens"
|
||||
stroke="#9b59b6"
|
||||
strokeWidth={2}
|
||||
name="Tokens"
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="cost"
|
||||
stroke="#27ae60"
|
||||
strokeWidth={2}
|
||||
name="Cost ($)"
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
marginBottom: '2rem',
|
||||
},
|
||||
title: {
|
||||
fontSize: '1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
color: '#2c3e50',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
padding: '1.5rem',
|
||||
},
|
||||
chartContainer: {
|
||||
marginBottom: '2rem',
|
||||
},
|
||||
chartTitle: {
|
||||
fontSize: '1.1rem',
|
||||
marginBottom: '1rem',
|
||||
color: '#2c3e50',
|
||||
},
|
||||
noData: {
|
||||
padding: '2rem',
|
||||
textAlign: 'center' as const,
|
||||
color: '#7f8c8d',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user