mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 18:50:49 +08:00
feat: enhance MainLayout with brand name expansion feature, sidebar toggle improvements, and responsive design adjustments for better user experience
This commit is contained in:
@@ -1,24 +1,53 @@
|
||||
@use '../styles/variables' as *;
|
||||
@use '../styles/mixins' as *;
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xl;
|
||||
.errorBox {
|
||||
padding: $spacing-md;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--danger-color);
|
||||
border-radius: $radius-md;
|
||||
color: var(--danger-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
// Stats Grid
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -30,20 +59,345 @@
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
.statHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
.statIcon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.statValueRow {
|
||||
display: flex;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.statValueSmall {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statValueLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.statValueNum {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.statMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statSuccess {
|
||||
color: var(--success-color, #22c55e);
|
||||
}
|
||||
|
||||
.statFailure {
|
||||
color: var(--danger-color, #ef4444);
|
||||
}
|
||||
|
||||
.statNeutral {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.statHint {
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// API List
|
||||
.apiList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.apiItem {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.apiHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $spacing-md;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.apiInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.apiEndpoint {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.apiStats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.apiBadge {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.expandIcon {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-left: $spacing-sm;
|
||||
}
|
||||
|
||||
.apiModels {
|
||||
padding: $spacing-md;
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 0;
|
||||
padding-top: $spacing-md;
|
||||
}
|
||||
|
||||
.modelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: $radius-sm;
|
||||
font-size: 13px;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.modelName {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.modelStat {
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
|
||||
@include mobile {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
// Table
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
|
||||
th, td {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.modelCell {
|
||||
font-weight: 500;
|
||||
max-width: 300px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
// Pricing Section
|
||||
.pricingSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.priceForm {
|
||||
padding: $spacing-md;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.formRow {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.formField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.pricesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.pricesTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pricesGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.priceItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $spacing-md;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--border-color);
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.priceInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.priceModel {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.priceMeta {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.priceActions {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Chart Section (for future use)
|
||||
.chartSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -68,4 +422,68 @@
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
min-height: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.periodButtons {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
// Chart Line Controls
|
||||
.chartLineControls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-lg;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.chartLineList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chartLineItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.chartLineLabel {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.chartLineActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chartLineCount {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chartLineHint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: $spacing-sm 0 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,56 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import type { KeyStats } from '@/utils/usage';
|
||||
import {
|
||||
formatTokensInMillions,
|
||||
formatPerMinuteValue,
|
||||
formatUsd,
|
||||
calculateTokenBreakdown,
|
||||
calculateRecentPerMinuteRates,
|
||||
calculateTotalCost,
|
||||
getModelNamesFromUsage,
|
||||
getApiStats,
|
||||
getModelStats,
|
||||
loadModelPrices,
|
||||
saveModelPrices,
|
||||
buildChartData,
|
||||
type ModelPrice
|
||||
} from '@/utils/usage';
|
||||
import styles from './UsagePage.module.scss';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface UsagePayload {
|
||||
total_requests?: number;
|
||||
success_requests?: number;
|
||||
failed_requests?: number;
|
||||
success_count?: number;
|
||||
failure_count?: number;
|
||||
total_tokens?: number;
|
||||
cached_tokens?: number;
|
||||
reasoning_tokens?: number;
|
||||
rpm_30m?: number;
|
||||
tpm_30m?: number;
|
||||
apis?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -23,86 +60,547 @@ export function UsagePage() {
|
||||
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [keyStats, setKeyStats] = useState<KeyStats | null>(null);
|
||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||
|
||||
const loadUsage = async () => {
|
||||
// Model price form state
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [promptPrice, setPromptPrice] = useState('');
|
||||
const [completionPrice, setCompletionPrice] = useState('');
|
||||
|
||||
// Expanded sections
|
||||
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
||||
|
||||
// Chart state
|
||||
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
||||
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
||||
const [chartLines, setChartLines] = useState<string[]>(['all']);
|
||||
const MAX_CHART_LINES = 9;
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await usageApi.getUsage();
|
||||
const payload = data?.usage ?? data;
|
||||
setUsage(payload);
|
||||
const stats = await usageApi.getKeyStats(payload);
|
||||
setKeyStats(stats);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('usage_stats.loading_error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsage();
|
||||
}, []);
|
||||
setModelPrices(loadModelPrices());
|
||||
}, [loadUsage]);
|
||||
|
||||
const overviewItems = [
|
||||
{ label: t('usage_stats.total_requests'), value: usage?.total_requests },
|
||||
{ label: t('usage_stats.success_requests'), value: usage?.success_requests },
|
||||
{ label: t('usage_stats.failed_requests'), value: usage?.failed_requests },
|
||||
{ label: t('usage_stats.total_tokens'), value: usage?.total_tokens },
|
||||
{ label: t('usage_stats.cached_tokens'), value: usage?.cached_tokens },
|
||||
{ label: t('usage_stats.reasoning_tokens'), value: usage?.reasoning_tokens },
|
||||
{ label: t('usage_stats.rpm_30m'), value: usage?.rpm_30m },
|
||||
{ label: t('usage_stats.tpm_30m'), value: usage?.tpm_30m }
|
||||
];
|
||||
// Calculate derived data
|
||||
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||
const rateStats = usage ? calculateRecentPerMinuteRates(30, usage) : { rpm: 0, tpm: 0 };
|
||||
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
|
||||
const modelNames = usage ? getModelNamesFromUsage(usage) : [];
|
||||
const apiStats = usage ? getApiStats(usage, modelPrices) : [];
|
||||
const modelStats = usage ? getModelStats(usage, modelPrices) : [];
|
||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||
|
||||
// Build chart data
|
||||
const requestsChartData = useMemo(() => {
|
||||
if (!usage) return { labels: [], datasets: [] };
|
||||
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
|
||||
}, [usage, requestsPeriod, chartLines]);
|
||||
|
||||
const tokensChartData = useMemo(() => {
|
||||
if (!usage) return { labels: [], datasets: [] };
|
||||
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
|
||||
}, [usage, tokensPeriod, chartLines]);
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top' as const,
|
||||
align: 'start' as const,
|
||||
labels: {
|
||||
usePointStyle: true
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.35,
|
||||
borderWidth: 2
|
||||
},
|
||||
point: {
|
||||
borderWidth: 2,
|
||||
radius: 4
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Chart line management
|
||||
const handleAddChartLine = () => {
|
||||
if (chartLines.length >= MAX_CHART_LINES) return;
|
||||
const unusedModel = modelNames.find(m => !chartLines.includes(m));
|
||||
if (unusedModel) {
|
||||
setChartLines([...chartLines, unusedModel]);
|
||||
} else {
|
||||
setChartLines([...chartLines, 'all']);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveChartLine = (index: number) => {
|
||||
if (chartLines.length <= 1) return;
|
||||
const newLines = [...chartLines];
|
||||
newLines.splice(index, 1);
|
||||
setChartLines(newLines);
|
||||
};
|
||||
|
||||
const handleChartLineChange = (index: number, value: string) => {
|
||||
const newLines = [...chartLines];
|
||||
newLines[index] = value;
|
||||
setChartLines(newLines);
|
||||
};
|
||||
|
||||
// Handle model price save
|
||||
const handleSavePrice = () => {
|
||||
if (!selectedModel) return;
|
||||
const prompt = parseFloat(promptPrice) || 0;
|
||||
const completion = parseFloat(completionPrice) || 0;
|
||||
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion } };
|
||||
setModelPrices(newPrices);
|
||||
saveModelPrices(newPrices);
|
||||
setSelectedModel('');
|
||||
setPromptPrice('');
|
||||
setCompletionPrice('');
|
||||
};
|
||||
|
||||
// Handle model price delete
|
||||
const handleDeletePrice = (model: string) => {
|
||||
const newPrices = { ...modelPrices };
|
||||
delete newPrices[model];
|
||||
setModelPrices(newPrices);
|
||||
saveModelPrices(newPrices);
|
||||
};
|
||||
|
||||
// Handle edit price
|
||||
const handleEditPrice = (model: string) => {
|
||||
const price = modelPrices[model];
|
||||
setSelectedModel(model);
|
||||
setPromptPrice(price?.prompt?.toString() || '');
|
||||
setCompletionPrice(price?.completion?.toString() || '');
|
||||
};
|
||||
|
||||
// Toggle API expansion
|
||||
const toggleApiExpand = (endpoint: string) => {
|
||||
setExpandedApis(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(endpoint)) {
|
||||
newSet.delete(endpoint);
|
||||
} else {
|
||||
newSet.add(endpoint);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<Card
|
||||
title={t('usage_stats.title')}
|
||||
extra={
|
||||
<Button variant="secondary" size="sm" onClick={loadUsage} disabled={loading}>
|
||||
{t('usage_stats.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{overviewItems.map((item) => (
|
||||
<div key={item.label} className="stat-card">
|
||||
<div className="stat-label">{item.label}</div>
|
||||
<div className="stat-value">{item.value ?? '-'}</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadUsage}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('common.loading') : t('usage_stats.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
{/* Stats Overview Cards */}
|
||||
<div className={styles.statsGrid}>
|
||||
{/* Total Requests Card */}
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statHeader}>
|
||||
<span className={styles.statIcon}>📊</span>
|
||||
<span className={styles.statLabel}>{t('usage_stats.total_requests')}</span>
|
||||
</div>
|
||||
<div className={styles.statValue}>
|
||||
{loading ? '-' : (usage?.total_requests ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className={styles.statMeta}>
|
||||
<span className={styles.statSuccess}>
|
||||
✓ {t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
|
||||
</span>
|
||||
<span className={styles.statFailure}>
|
||||
✗ {t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Tokens Card */}
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statHeader}>
|
||||
<span className={styles.statIcon}>🔤</span>
|
||||
<span className={styles.statLabel}>{t('usage_stats.total_tokens')}</span>
|
||||
</div>
|
||||
<div className={styles.statValue}>
|
||||
{loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
|
||||
</div>
|
||||
<div className={styles.statMeta}>
|
||||
<span className={styles.statNeutral}>
|
||||
💾 {t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
|
||||
</span>
|
||||
<span className={styles.statNeutral}>
|
||||
🧠 {t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RPM/TPM Card */}
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statHeader}>
|
||||
<span className={styles.statIcon}>⚡</span>
|
||||
<span className={styles.statLabel}>{t('usage_stats.rate_30m')}</span>
|
||||
</div>
|
||||
<div className={styles.statValueRow}>
|
||||
<div className={styles.statValueSmall}>
|
||||
<span className={styles.statValueLabel}>{t('usage_stats.rpm_30m')}</span>
|
||||
<span className={styles.statValueNum}>{loading ? '-' : formatPerMinuteValue(rateStats.rpm)}</span>
|
||||
</div>
|
||||
<div className={styles.statValueSmall}>
|
||||
<span className={styles.statValueLabel}>{t('usage_stats.tpm_30m')}</span>
|
||||
<span className={styles.statValueNum}>{loading ? '-' : formatPerMinuteValue(rateStats.tpm)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Cost Card */}
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statHeader}>
|
||||
<span className={styles.statIcon}>💰</span>
|
||||
<span className={styles.statLabel}>{t('usage_stats.total_cost')}</span>
|
||||
</div>
|
||||
<div className={styles.statValue}>
|
||||
{loading ? '-' : hasPrices ? formatUsd(totalCost) : '--'}
|
||||
</div>
|
||||
{!hasPrices && (
|
||||
<div className={styles.statMeta}>
|
||||
<span className={styles.statHint}>{t('usage_stats.cost_need_price')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart Line Selection */}
|
||||
<Card title={t('usage_stats.chart_line_actions_label')}>
|
||||
<div className={styles.chartLineControls}>
|
||||
<div className={styles.chartLineList}>
|
||||
{chartLines.map((line, index) => (
|
||||
<div key={index} className={styles.chartLineItem}>
|
||||
<span className={styles.chartLineLabel}>
|
||||
{t(`usage_stats.chart_line_label_${index + 1}`)}:
|
||||
</span>
|
||||
<select
|
||||
value={line}
|
||||
onChange={(e) => handleChartLineChange(index, e.target.value)}
|
||||
className={styles.select}
|
||||
>
|
||||
<option value="all">{t('usage_stats.chart_line_all')}</option>
|
||||
{modelNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
{chartLines.length > 1 && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveChartLine(index)}
|
||||
>
|
||||
{t('usage_stats.chart_line_delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.chartLineActions}>
|
||||
<span className={styles.chartLineCount}>
|
||||
{chartLines.length}/{MAX_CHART_LINES}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleAddChartLine}
|
||||
disabled={chartLines.length >= MAX_CHART_LINES}
|
||||
>
|
||||
{t('usage_stats.chart_line_add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className={styles.chartLineHint}>{t('usage_stats.chart_line_hint')}</p>
|
||||
</Card>
|
||||
|
||||
{/* Requests Chart */}
|
||||
<Card
|
||||
title={t('usage_stats.requests_trend')}
|
||||
extra={
|
||||
<div className={styles.periodButtons}>
|
||||
<Button
|
||||
variant={requestsPeriod === 'hour' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setRequestsPeriod('hour')}
|
||||
>
|
||||
{t('usage_stats.by_hour')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={requestsPeriod === 'day' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setRequestsPeriod('day')}
|
||||
>
|
||||
{t('usage_stats.by_day')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : requestsChartData.labels.length > 0 ? (
|
||||
<div className={styles.chartWrapper}>
|
||||
<Line data={requestsChartData} options={chartOptions} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Tokens Chart */}
|
||||
<Card
|
||||
title={t('usage_stats.tokens_trend')}
|
||||
extra={
|
||||
<div className={styles.periodButtons}>
|
||||
<Button
|
||||
variant={tokensPeriod === 'hour' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setTokensPeriod('hour')}
|
||||
>
|
||||
{t('usage_stats.by_hour')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={tokensPeriod === 'day' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setTokensPeriod('day')}
|
||||
>
|
||||
{t('usage_stats.by_day')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : tokensChartData.labels.length > 0 ? (
|
||||
<div className={styles.chartWrapper}>
|
||||
<Line data={tokensChartData} options={chartOptions} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* API Key Statistics */}
|
||||
<Card title={t('usage_stats.api_details')}>
|
||||
{loading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : keyStats && Object.keys(keyStats.bySource || {}).length ? (
|
||||
<div className="table">
|
||||
<div className="table-header">
|
||||
<div>{t('usage_stats.api_endpoint')}</div>
|
||||
<div>{t('stats.success')}</div>
|
||||
<div>{t('stats.failure')}</div>
|
||||
</div>
|
||||
{Object.entries(keyStats.bySource || {}).map(([source, bucket]) => (
|
||||
<div key={source} className="table-row">
|
||||
<div className="cell item-subtitle">{source}</div>
|
||||
<div className="cell">{bucket.success}</div>
|
||||
<div className="cell">{bucket.failure}</div>
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : apiStats.length > 0 ? (
|
||||
<div className={styles.apiList}>
|
||||
{apiStats.map((api) => (
|
||||
<div key={api.endpoint} className={styles.apiItem}>
|
||||
<div
|
||||
className={styles.apiHeader}
|
||||
onClick={() => toggleApiExpand(api.endpoint)}
|
||||
>
|
||||
<div className={styles.apiInfo}>
|
||||
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||
<div className={styles.apiStats}>
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests}
|
||||
</span>
|
||||
<span className={styles.apiBadge}>
|
||||
Tokens: {formatTokensInMillions(api.totalTokens)}
|
||||
</span>
|
||||
{hasPrices && api.totalCost > 0 && (
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.expandIcon}>
|
||||
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
{expandedApis.has(api.endpoint) && (
|
||||
<div className={styles.apiModels}>
|
||||
{Object.entries(api.models).map(([model, stats]) => (
|
||||
<div key={model} className={styles.modelRow}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
<span className={styles.modelStat}>{stats.requests} {t('usage_stats.requests_count')}</span>
|
||||
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hint">{t('usage_stats.no_data')}</div>
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Model Statistics */}
|
||||
<Card title={t('usage_stats.models')}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : modelStats.length > 0 ? (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usage_stats.model_name')}</th>
|
||||
<th>{t('usage_stats.requests_count')}</th>
|
||||
<th>{t('usage_stats.tokens_count')}</th>
|
||||
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modelStats.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>{stat.requests.toLocaleString()}</td>
|
||||
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Model Pricing Configuration */}
|
||||
<Card title={t('usage_stats.model_price_settings')}>
|
||||
<div className={styles.pricingSection}>
|
||||
{/* Price Form */}
|
||||
<div className={styles.priceForm}>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_name')}</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => {
|
||||
setSelectedModel(e.target.value);
|
||||
const price = modelPrices[e.target.value];
|
||||
if (price) {
|
||||
setPromptPrice(price.prompt.toString());
|
||||
setCompletionPrice(price.completion.toString());
|
||||
} else {
|
||||
setPromptPrice('');
|
||||
setCompletionPrice('');
|
||||
}
|
||||
}}
|
||||
className={styles.select}
|
||||
>
|
||||
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
|
||||
{modelNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={promptPrice}
|
||||
onChange={(e) => setPromptPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
step="0.0001"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={completionPrice}
|
||||
onChange={(e) => setCompletionPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
step="0.0001"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSavePrice}
|
||||
disabled={!selectedModel}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved Prices List */}
|
||||
<div className={styles.pricesList}>
|
||||
<h4 className={styles.pricesTitle}>{t('usage_stats.saved_prices')}</h4>
|
||||
{Object.keys(modelPrices).length > 0 ? (
|
||||
<div className={styles.pricesGrid}>
|
||||
{Object.entries(modelPrices).map(([model, price]) => (
|
||||
<div key={model} className={styles.priceItem}>
|
||||
<div className={styles.priceInfo}>
|
||||
<span className={styles.priceModel}>{model}</span>
|
||||
<div className={styles.priceMeta}>
|
||||
<span>{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M</span>
|
||||
<span>{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.priceActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleEditPrice(model)}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDeletePrice(model)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.model_price_empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user