diff --git a/README.md b/README.md
index d8007ed..37cec3b 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,27 @@ Monitor your API usage through the PostgreSQL logs:
- Identify usage patterns and optimize costs
- Maintain compliance with audit requirements
+### Metrics Dashboard
+
+OpenProxy includes a lightweight Next.js dashboard for real-time metrics visualization:
+
+```bash
+cd dashboard
+npm install
+cp .env.example .env
+# Configure DATABASE_URL in .env
+npm run dev
+```
+
+The dashboard (available at `http://localhost:3008`) provides:
+- **Real-time Overview**: Total requests, tokens, costs, and response times
+- **Model Breakdown**: Usage statistics grouped by LLM model
+- **Hourly Trends**: Visual charts showing request patterns over time
+- **Recent Requests**: Detailed table of recent API calls
+- **Auto-refresh**: Automatic updates every 30 seconds
+
+See [dashboard/README.md](./dashboard/README.md) for detailed setup instructions.
+
## 🤝 Contributing
Feel free to submit issues and enhancement requests!
diff --git a/dashboard/.env.example b/dashboard/.env.example
new file mode 100644
index 0000000..9c0bc76
--- /dev/null
+++ b/dashboard/.env.example
@@ -0,0 +1,5 @@
+# PostgreSQL connection string (same as proxy server)
+DATABASE_URL=postgresql://user:password@localhost:5432/database
+
+# Database table name (default: llm_proxy)
+DATABASE_TABLE=llm_proxy
diff --git a/dashboard/.gitignore b/dashboard/.gitignore
new file mode 100644
index 0000000..8ccc874
--- /dev/null
+++ b/dashboard/.gitignore
@@ -0,0 +1,34 @@
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+.env
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/dashboard/README.md b/dashboard/README.md
new file mode 100644
index 0000000..49016ca
--- /dev/null
+++ b/dashboard/README.md
@@ -0,0 +1,195 @@
+# OpenProxy Metrics Dashboard
+
+A lightweight Next.js dashboard for visualizing OpenProxy LLM request metrics in real-time.
+
+## Features
+
+- **Real-time Metrics Overview**: Total requests, tokens, costs, and response times
+- **Model Breakdown**: Usage statistics grouped by LLM model
+- **Hourly Trends**: Visual charts showing request patterns over time
+- **Recent Requests**: Detailed table of recent API calls
+- **Auto-refresh**: Automatic updates every 30 seconds
+- **Time Range Selection**: View metrics for the last hour, 6 hours, 24 hours, or 7 days
+
+## Prerequisites
+
+- Node.js 18 or higher
+- PostgreSQL database (same as the proxy server)
+- OpenProxy proxy server running
+
+## Installation
+
+1. Navigate to the dashboard directory:
+ ```bash
+ cd dashboard
+ ```
+
+2. Install dependencies:
+ ```bash
+ npm install
+ ```
+
+3. Create a `.env` file (copy from `.env.example`):
+ ```bash
+ cp .env.example .env
+ ```
+
+4. Configure your `.env` file:
+ ```env
+ DATABASE_URL=postgresql://user:password@localhost:5432/database
+ DATABASE_TABLE=llm_proxy
+ ```
+
+## Running the Dashboard
+
+### Development Mode
+
+```bash
+npm run dev
+```
+
+The dashboard will be available at `http://localhost:3008`
+
+### Production Mode
+
+1. Build the application:
+ ```bash
+ npm run build
+ ```
+
+2. Start the production server:
+ ```bash
+ npm start
+ ```
+
+## Dashboard Sections
+
+### 1. Overview Cards
+Displays key metrics at a glance:
+- Total requests processed
+- Total tokens consumed
+- Total cost incurred
+- Average response time
+- Number of unique models used
+- Number of unique client IPs
+
+### 2. Hourly Trends
+Two charts showing:
+- Requests count and average response time over time
+- Token usage and costs over time
+
+### 3. Model Breakdown
+Table showing per-model statistics:
+- Request count
+- Total tokens used
+- Total cost
+- Average response time
+
+### 4. Recent Requests
+Detailed table of recent API calls showing:
+- Timestamp
+- Model used
+- Token breakdown (prompt + completion = total)
+- Cost
+- Response time
+- HTTP status code
+- Client IP address
+- Whether the request was streamed
+
+## Configuration
+
+### Port
+The dashboard runs on port 3008 by default. To change this, modify the `dev` and `start` scripts in `package.json`:
+
+```json
+"dev": "next dev -p YOUR_PORT",
+"start": "next start -p YOUR_PORT"
+```
+
+### Database Connection
+Ensure the `DATABASE_URL` in your `.env` file matches the PostgreSQL connection string used by the proxy server.
+
+### Time Ranges
+Available time ranges:
+- Last Hour (1 hour)
+- Last 6 Hours
+- Last 24 Hours (default)
+- Last 7 Days (168 hours)
+
+## Troubleshooting
+
+### "Failed to fetch metrics" Error
+- Verify that the `DATABASE_URL` in `.env` is correct
+- Ensure PostgreSQL is running and accessible
+- Check that the `llm_proxy` table exists in your database
+- Verify network connectivity to the database
+
+### Empty Dashboard
+- Ensure the proxy server is running and processing requests
+- Verify that requests are being logged to the database
+- Check that the `DATABASE_TABLE` name matches your configuration
+
+### Port Conflicts
+If port 3008 is already in use, change the port in `package.json` scripts.
+
+## Technology Stack
+
+- **Framework**: Next.js 14 (React 18)
+- **Charts**: Recharts
+- **Database**: PostgreSQL (via `pg` driver)
+- **Language**: TypeScript
+- **Styling**: Inline CSS (no external dependencies)
+
+## Architecture
+
+```
+dashboard/
+├── app/
+│ ├── api/
+│ │ └── metrics/
+│ │ └── route.ts # API endpoint for fetching metrics
+│ ├── layout.tsx # Root layout
+│ └── page.tsx # Main dashboard page
+├── components/
+│ ├── MetricsOverview.tsx # Overview cards component
+│ ├── ModelBreakdown.tsx # Model statistics table
+│ ├── RecentRequests.tsx # Recent requests table
+│ └── TrendsChart.tsx # Hourly trends charts
+├── package.json
+├── tsconfig.json
+├── next.config.js
+└── README.md
+```
+
+## API Endpoints
+
+### GET `/api/metrics`
+
+Query parameters:
+- `hours` (optional): Number of hours to look back (default: 24)
+- `limit` (optional): Maximum number of recent requests to return (default: 100)
+
+Response:
+```json
+{
+ "success": true,
+ "data": {
+ "summary": {
+ "totalRequests": 1234,
+ "totalTokens": 567890,
+ "totalCost": 12.34,
+ "avgResponseTime": 450.5,
+ "uniqueModels": 3,
+ "uniqueClients": 15
+ },
+ "recentRequests": [...],
+ "modelBreakdown": [...],
+ "hourlyTrends": [...]
+ },
+ "timeRange": "24 hours"
+}
+```
+
+## License
+
+Same as OpenProxy parent project.
diff --git a/dashboard/app/api/metrics/route.ts b/dashboard/app/api/metrics/route.ts
new file mode 100644
index 0000000..b8d72be
--- /dev/null
+++ b/dashboard/app/api/metrics/route.ts
@@ -0,0 +1,123 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { Pool } from 'pg';
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL,
+});
+
+const TABLE_NAME = process.env.DATABASE_TABLE || 'llm_proxy';
+
+// Validate table name against whitelist to prevent SQL injection
+const ALLOWED_TABLES = ['llm_proxy', 'llm_proxy_dev', 'llm_proxy_test'];
+const validatedTableName = ALLOWED_TABLES.includes(TABLE_NAME) ? TABLE_NAME : 'llm_proxy';
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = new URL(request.url);
+ const hours = parseInt(searchParams.get('hours') || '24', 10);
+ const limit = parseInt(searchParams.get('limit') || '100', 10);
+
+ try {
+ const client = await pool.connect();
+
+ try {
+ // Get summary statistics
+ const summaryQuery = `
+ SELECT
+ COUNT(*) as total_requests,
+ SUM(total_tokens) as total_tokens_used,
+ SUM(total_cost) as total_cost,
+ AVG(response_time) as avg_response_time,
+ COUNT(DISTINCT model) as unique_models,
+ COUNT(DISTINCT client_ip) as unique_clients
+ FROM ${validatedTableName}
+ WHERE timestamp >= NOW() - INTERVAL '$1 hours'
+ `;
+ const summaryResult = await client.query(summaryQuery, [hours]);
+ const summary = summaryResult.rows[0];
+
+ // Get recent requests
+ const recentQuery = `
+ SELECT
+ request_id,
+ timestamp,
+ model,
+ prompt_tokens,
+ completion_tokens,
+ total_tokens,
+ total_cost,
+ response_time,
+ response_status,
+ client_ip,
+ stream
+ FROM ${validatedTableName}
+ WHERE timestamp >= NOW() - INTERVAL '$1 hours'
+ ORDER BY timestamp DESC
+ LIMIT $2
+ `;
+ const recentResult = await client.query(recentQuery, [hours, limit]);
+ const recentRequests = recentResult.rows;
+
+ // Get model breakdown
+ const modelQuery = `
+ SELECT
+ model,
+ COUNT(*) as request_count,
+ SUM(total_tokens) as total_tokens,
+ SUM(total_cost) as total_cost,
+ AVG(response_time) as avg_response_time
+ FROM ${validatedTableName}
+ WHERE timestamp >= NOW() - INTERVAL '$1 hours'
+ GROUP BY model
+ ORDER BY request_count DESC
+ `;
+ const modelResult = await client.query(modelQuery, [hours]);
+ const modelBreakdown = modelResult.rows;
+
+ // Get hourly trends
+ const trendsQuery = `
+ SELECT
+ DATE_TRUNC('hour', timestamp) as hour,
+ COUNT(*) as requests,
+ SUM(total_tokens) as tokens,
+ SUM(total_cost) as cost,
+ AVG(response_time) as avg_response_time
+ FROM ${validatedTableName}
+ WHERE timestamp >= NOW() - INTERVAL '$1 hours'
+ GROUP BY hour
+ ORDER BY hour ASC
+ `;
+ const trendsResult = await client.query(trendsQuery, [hours]);
+ const hourlyTrends = trendsResult.rows;
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ summary: {
+ totalRequests: parseInt(summary.total_requests || '0'),
+ totalTokens: parseInt(summary.total_tokens_used || '0'),
+ totalCost: parseFloat(summary.total_cost || '0'),
+ avgResponseTime: parseFloat(summary.avg_response_time || '0'),
+ uniqueModels: parseInt(summary.unique_models || '0'),
+ uniqueClients: parseInt(summary.unique_clients || '0'),
+ },
+ recentRequests,
+ modelBreakdown,
+ hourlyTrends,
+ },
+ timeRange: `${hours} hours`,
+ });
+ } finally {
+ client.release();
+ }
+ } catch (error) {
+ console.error('Database error:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Failed to fetch metrics',
+ details: error instanceof Error ? error.message : 'Unknown error'
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx
new file mode 100644
index 0000000..5f9562a
--- /dev/null
+++ b/dashboard/app/layout.tsx
@@ -0,0 +1,33 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'OpenProxy Metrics Dashboard',
+ description: 'Real-time metrics and analytics for OpenProxy LLM requests',
+}
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+
+
+
+ {children}
+
+ )
+}
diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx
new file mode 100644
index 0000000..331a627
--- /dev/null
+++ b/dashboard/app/page.tsx
@@ -0,0 +1,221 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import MetricsOverview from '@/components/MetricsOverview';
+import ModelBreakdown from '@/components/ModelBreakdown';
+import RecentRequests from '@/components/RecentRequests';
+import TrendsChart from '@/components/TrendsChart';
+
+interface MetricsData {
+ summary: {
+ totalRequests: number;
+ totalTokens: number;
+ totalCost: number;
+ avgResponseTime: number;
+ uniqueModels: number;
+ uniqueClients: number;
+ };
+ recentRequests: any[];
+ modelBreakdown: any[];
+ hourlyTrends: any[];
+}
+
+export default function Dashboard() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [timeRange, setTimeRange] = useState(24);
+ const [autoRefresh, setAutoRefresh] = useState(true);
+
+ const fetchMetrics = async () => {
+ try {
+ const response = await fetch(`/api/metrics?hours=${timeRange}`);
+ const result = await response.json();
+
+ if (result.success) {
+ setData(result.data);
+ setError(null);
+ } else {
+ setError(result.error || 'Failed to fetch metrics');
+ }
+ } catch (err) {
+ setError('Network error: Unable to fetch metrics');
+ console.error('Fetch error:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchMetrics();
+ }, [timeRange]);
+
+ useEffect(() => {
+ if (!autoRefresh) return;
+
+ const interval = setInterval(() => {
+ fetchMetrics();
+ }, 30000); // Refresh every 30 seconds
+
+ return () => clearInterval(interval);
+ }, [autoRefresh, timeRange]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
Error
+
{error}
+
+
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const styles = {
+ container: {
+ minHeight: '100vh',
+ backgroundColor: '#f5f7fa',
+ },
+ header: {
+ backgroundColor: '#fff',
+ padding: '1.5rem 2rem',
+ boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ flexWrap: 'wrap' as const,
+ gap: '1rem',
+ },
+ title: {
+ fontSize: '1.8rem',
+ color: '#2c3e50',
+ fontWeight: 600,
+ },
+ controls: {
+ display: 'flex',
+ gap: '1rem',
+ alignItems: 'center',
+ },
+ select: {
+ padding: '0.5rem 1rem',
+ borderRadius: '6px',
+ border: '1px solid #ddd',
+ fontSize: '0.9rem',
+ cursor: 'pointer',
+ },
+ checkboxLabel: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ fontSize: '0.9rem',
+ },
+ refreshButton: {
+ padding: '0.5rem 1.5rem',
+ backgroundColor: '#3498db',
+ color: '#fff',
+ border: 'none',
+ borderRadius: '6px',
+ cursor: 'pointer',
+ fontSize: '0.9rem',
+ fontWeight: 500,
+ },
+ main: {
+ maxWidth: '1400px',
+ margin: '0 auto',
+ padding: '2rem',
+ },
+ loading: {
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ height: '100vh',
+ fontSize: '1.2rem',
+ color: '#7f8c8d',
+ },
+ error: {
+ display: 'flex',
+ flexDirection: 'column' as const,
+ justifyContent: 'center',
+ alignItems: 'center',
+ height: '100vh',
+ gap: '1rem',
+ color: '#e74c3c',
+ },
+ retryButton: {
+ padding: '0.5rem 1.5rem',
+ backgroundColor: '#e74c3c',
+ color: '#fff',
+ border: 'none',
+ borderRadius: '6px',
+ cursor: 'pointer',
+ fontSize: '0.9rem',
+ },
+ footer: {
+ textAlign: 'center' as const,
+ padding: '2rem',
+ color: '#7f8c8d',
+ fontSize: '0.9rem',
+ },
+};
diff --git a/dashboard/components/MetricsOverview.tsx b/dashboard/components/MetricsOverview.tsx
new file mode 100644
index 0000000..0562e2b
--- /dev/null
+++ b/dashboard/components/MetricsOverview.tsx
@@ -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 (
+
+
Overview
+
+ {metrics.map((metric) => (
+
+
{metric.icon}
+
+
{metric.label}
+
{metric.value}
+
+
+ ))}
+
+
+ );
+}
+
+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',
+ },
+};
diff --git a/dashboard/components/ModelBreakdown.tsx b/dashboard/components/ModelBreakdown.tsx
new file mode 100644
index 0000000..9a9b420
--- /dev/null
+++ b/dashboard/components/ModelBreakdown.tsx
@@ -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 (
+
+
Model Breakdown
+
+
No model data available
+
+
+ );
+ }
+
+ return (
+
+
Model Breakdown
+
+
+
+
+ | Model |
+ Requests |
+ Total Tokens |
+ Total Cost |
+ Avg Response Time |
+
+
+
+ {models.map((model) => (
+
+ |
+ {model.model}
+ |
+ {parseInt(model.request_count).toLocaleString()} |
+ {parseInt(model.total_tokens).toLocaleString()} |
+ ${parseFloat(model.total_cost).toFixed(4)} |
+ {Math.round(parseFloat(model.avg_response_time))}ms |
+
+ ))}
+
+
+
+
+ );
+}
+
+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',
+ },
+};
diff --git a/dashboard/components/RecentRequests.tsx b/dashboard/components/RecentRequests.tsx
new file mode 100644
index 0000000..0fe57eb
--- /dev/null
+++ b/dashboard/components/RecentRequests.tsx
@@ -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 (
+
+ );
+ }
+
+ return (
+
+
Recent Requests
+
+
+
+
+
+ | Timestamp |
+ Model |
+ Tokens |
+ Cost |
+ Response Time |
+ Status |
+ Client IP |
+ Stream |
+
+
+
+ {requests.map((req) => (
+
+ |
+ {new Date(req.timestamp).toLocaleString()}
+ |
+
+ {req.model}
+ |
+
+
+
+ {req.prompt_tokens} + {req.completion_tokens} = {req.total_tokens}
+
+
+ |
+ ${parseFloat(req.total_cost).toFixed(4)} |
+ {req.response_time}ms |
+
+
+ {req.response_status}
+
+ |
+ {req.client_ip} |
+ {req.stream ? '✓' : '✗'} |
+
+ ))}
+
+
+
+
+
+ );
+}
+
+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',
+ },
+};
diff --git a/dashboard/components/TrendsChart.tsx b/dashboard/components/TrendsChart.tsx
new file mode 100644
index 0000000..67f6473
--- /dev/null
+++ b/dashboard/components/TrendsChart.tsx
@@ -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 (
+
+
Hourly Trends
+
+
No trend data available
+
+
+ );
+ }
+
+ 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 (
+
+
Hourly Trends
+
+
+
Requests & Response Time
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Tokens & Cost
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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',
+ },
+};
diff --git a/dashboard/next.config.js b/dashboard/next.config.js
new file mode 100644
index 0000000..a843cbe
--- /dev/null
+++ b/dashboard/next.config.js
@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+}
+
+module.exports = nextConfig
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
new file mode 100644
index 0000000..319ada8
--- /dev/null
+++ b/dashboard/package-lock.json
@@ -0,0 +1,1029 @@
+{
+ "name": "openproxy-dashboard",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openproxy-dashboard",
+ "version": "1.0.0",
+ "dependencies": {
+ "next": "^14.2.0",
+ "pg": "^8.16.3",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "recharts": "^2.12.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "@types/pg": "^8.11.0",
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "typescript": "^5.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.33.tgz",
+ "integrity": "sha512-CgVHNZ1fRIlxkLhIX22flAZI/HmpDaZ8vwyJ/B0SDPTBuLZ1PJ+DWMjCHhqnExfmSQzA/PbZi8OAc7PAq2w9IA==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
+ "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
+ "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
+ "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
+ "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
+ "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
+ "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
+ "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-ia32-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
+ "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
+ "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
+ "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
+ "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.25",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
+ "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/pg": {
+ "version": "8.15.6",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz",
+ "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.27",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
+ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001755",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz",
+ "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "node_modules/fast-equals": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz",
+ "integrity": "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "14.2.33",
+ "resolved": "https://registry.npmjs.org/next/-/next-14.2.33.tgz",
+ "integrity": "sha512-GiKHLsD00t4ACm1p00VgrI0rUFAC9cRDGReKyERlM57aeEZkOQGcZTpIbsGn0b562FTPJWmYfKwplfO9EaT6ng==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "14.2.33",
+ "@swc/helpers": "0.5.5",
+ "busboy": "1.6.0",
+ "caniuse-lite": "^1.0.30001579",
+ "graceful-fs": "^4.2.11",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.1"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "14.2.33",
+ "@next/swc-darwin-x64": "14.2.33",
+ "@next/swc-linux-arm64-gnu": "14.2.33",
+ "@next/swc-linux-arm64-musl": "14.2.33",
+ "@next/swc-linux-x64-gnu": "14.2.33",
+ "@next/swc-linux-x64-musl": "14.2.33",
+ "@next/swc-win32-arm64-msvc": "14.2.33",
+ "@next/swc-win32-ia32-msvc": "14.2.33",
+ "@next/swc-win32-x64-msvc": "14.2.33"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.41.2",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pg": {
+ "version": "8.16.3",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
+ "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.9.1",
+ "pg-pool": "^3.10.1",
+ "pg-protocol": "^1.10.3",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.2.7"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
+ "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
+ "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
+ "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
+ "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
+ "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
+ "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/dashboard/package.json b/dashboard/package.json
new file mode 100644
index 0000000..017653d
--- /dev/null
+++ b/dashboard/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "openproxy-dashboard",
+ "version": "1.0.0",
+ "description": "Lightweight Next.js dashboard for OpenProxy metrics",
+ "private": true,
+ "scripts": {
+ "dev": "next dev -p 3008",
+ "build": "next build",
+ "start": "next start -p 3008",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "next": "^14.2.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "pg": "^8.16.3",
+ "recharts": "^2.12.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "@types/pg": "^8.11.0",
+ "typescript": "^5.9.0"
+ }
+}
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
new file mode 100644
index 0000000..5ddf5a5
--- /dev/null
+++ b/dashboard/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}