first-update
This commit is contained in:
188
easy-dataset-main/app/monitoring/components/Charts.js
Normal file
188
easy-dataset-main/app/monitoring/components/Charts.js
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box, useTheme } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend
|
||||
} from 'recharts';
|
||||
|
||||
export default function Charts({ trendData, modelDistribution }) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const COLORS = [
|
||||
theme.palette.primary.main,
|
||||
theme.palette.secondary.main,
|
||||
theme.palette.success.main,
|
||||
theme.palette.warning.main,
|
||||
theme.palette.error.main,
|
||||
theme.palette.info.main
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||
{/* 趋势图 */}
|
||||
<Box sx={{ flex: '1 1 calc(66.67% - 16px)', minWidth: 500 }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: 400,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ height: '100%' }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{t('monitoring.charts.tokenTrend')}
|
||||
</Typography>
|
||||
<Box display="flex" gap={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: theme.palette.primary.main }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('monitoring.charts.inputLegend')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: theme.palette.success.main }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('monitoring.charts.outputLegend')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<ResponsiveContainer width="100%" height="85%">
|
||||
<AreaChart data={trendData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorInput" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={theme.palette.primary.main} stopOpacity={0.1} />
|
||||
<stop offset="95%" stopColor={theme.palette.primary.main} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorOutput" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={theme.palette.success.main} stopOpacity={0.1} />
|
||||
<stop offset="95%" stopColor={theme.palette.success.main} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={theme.palette.divider} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: theme.palette.text.secondary, fontSize: 12 }}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: theme.palette.text.secondary, fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 8
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="input"
|
||||
stroke={theme.palette.primary.main}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorInput)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="output"
|
||||
stroke={theme.palette.success.main}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorOutput)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* 模型分布图 */}
|
||||
<Box sx={{ flex: '1 1 calc(33.33% - 16px)', minWidth: 350 }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: 400,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ height: '100%' }}>
|
||||
<Typography variant="h6" fontWeight="bold" mb={2}>
|
||||
{t('monitoring.charts.distributionTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={4}>
|
||||
{t('monitoring.charts.distributionSubtitle')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ height: '70%', position: 'relative' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={modelDistribution}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{modelDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={value => t('monitoring.charts.tokensTooltip', { value: (value / 1000).toFixed(1) })}
|
||||
contentStyle={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 8
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
formatter={(value, entry, index) => (
|
||||
<span style={{ color: theme.palette.text.primary, fontSize: 12, marginLeft: 5 }}>{value}</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{/* 中间文字 */}
|
||||
{/* <Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{modelDistribution.length}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Models
|
||||
</Typography>
|
||||
</Box> */}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
145
easy-dataset-main/app/monitoring/components/StatsCards.js
Normal file
145
easy-dataset-main/app/monitoring/components/StatsCards.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { Box, Card, CardContent, Grid, Typography, Stack, useTheme, alpha } from '@mui/material';
|
||||
import {
|
||||
Storage as StorageIcon,
|
||||
Balance as BalanceIcon,
|
||||
Bolt as BoltIcon,
|
||||
AccessTime as AccessTimeIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function StatCard({ title, value, subValue, icon: Icon, color }) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: '100%',
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
boxShadow: theme.shadows[4],
|
||||
transform: 'translateY(-2px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(color, 0.1),
|
||||
color: color,
|
||||
display: 'flex'
|
||||
}}
|
||||
>
|
||||
<Icon fontSize="medium" />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" fontWeight={500}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="h3" fontWeight="bold" sx={{ mb: 1.5, color: 'text.primary' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
|
||||
{subValue && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}>
|
||||
{subValue}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatsCards({ data }) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = num => {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||
return num;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||
{/* 总 Token 消耗 */}
|
||||
<Box sx={{ flex: '1 1 calc(25% - 18px)', minWidth: 250 }}>
|
||||
<StatCard
|
||||
title={t('monitoring.stats.totalTokens')}
|
||||
value={formatNumber(data.totalTokens)}
|
||||
subValue={t('monitoring.stats.inputOutput', {
|
||||
input: formatNumber(data.inputTokens),
|
||||
output: formatNumber(data.outputTokens)
|
||||
})}
|
||||
icon={StorageIcon}
|
||||
color={theme.palette.primary.main}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 平均 Token 消耗/次 */}
|
||||
<Box sx={{ flex: '1 1 calc(25% - 18px)', minWidth: 250 }}>
|
||||
<StatCard
|
||||
title={t('monitoring.stats.avgTokensPerCall')}
|
||||
value={formatNumber(data.avgTokensPerCall)}
|
||||
subValue={t('monitoring.stats.inputOutput', {
|
||||
input: formatNumber(Math.round(data.inputTokens / (data.totalCalls || 1))),
|
||||
output: formatNumber(Math.round(data.outputTokens / (data.totalCalls || 1)))
|
||||
})}
|
||||
icon={BalanceIcon}
|
||||
color={theme.palette.info.main}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 总调用次数 */}
|
||||
<Box sx={{ flex: '1 1 calc(25% - 18px)', minWidth: 250 }}>
|
||||
<StatCard
|
||||
title={t('monitoring.stats.totalCalls')}
|
||||
value={formatNumber(data.totalCalls)}
|
||||
subValue={
|
||||
<Box component="span">
|
||||
<Box component="span" sx={{ color: theme.palette.success.main, fontWeight: 600 }}>
|
||||
{t('monitoring.stats.successCalls', { count: formatNumber(data.successCalls) })}
|
||||
</Box>
|
||||
<Box component="span" sx={{ mx: 1 }}>
|
||||
·
|
||||
</Box>
|
||||
<Box component="span" sx={{ color: theme.palette.error.main, fontWeight: 600 }}>
|
||||
{t('monitoring.stats.failedCalls', { count: formatNumber(data.failedCalls) })}
|
||||
</Box>
|
||||
{data.totalCalls > 0 && (
|
||||
<Box component="span" sx={{ ml: 1, color: 'text.disabled' }}>
|
||||
({t('monitoring.stats.failureRate', { rate: ((data.failureRate || 0) * 100).toFixed(1) })})
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
icon={BoltIcon}
|
||||
color={theme.palette.success.main}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 平均响应耗时 */}
|
||||
<Box sx={{ flex: '1 1 calc(25% - 18px)', minWidth: 250 }}>
|
||||
<StatCard
|
||||
title={t('monitoring.stats.avgLatency')}
|
||||
value={`${(data.avgLatency / 1000).toFixed(2)}s`}
|
||||
subValue={
|
||||
data.successCalls > 0
|
||||
? t('monitoring.stats.basedOnSuccessCalls', { count: formatNumber(data.successCalls) })
|
||||
: t('monitoring.stats.noSuccessCalls')
|
||||
}
|
||||
icon={AccessTimeIcon}
|
||||
color={theme.palette.warning.main}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
215
easy-dataset-main/app/monitoring/components/UsageTable.js
Normal file
215
easy-dataset-main/app/monitoring/components/UsageTable.js
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
TablePagination,
|
||||
useTheme,
|
||||
alpha,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const statusColors = {
|
||||
SUCCESS: 'success',
|
||||
FAILED: 'error'
|
||||
};
|
||||
|
||||
export default function UsageTable({
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
searchTerm,
|
||||
onSearchChange
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChangePage = (event, newPage) => {
|
||||
onPageChange(newPage + 1); // MUI uses 0-indexed, our API uses 1-indexed
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = event => {
|
||||
onPageSizeChange(parseInt(event.target.value, 10));
|
||||
};
|
||||
|
||||
const handleSearchChange = event => {
|
||||
onSearchChange(event.target.value);
|
||||
};
|
||||
|
||||
// 直接使用传入的数据,分页和搜索已在后端完成
|
||||
|
||||
return (
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 2,
|
||||
mt: 3
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{t('monitoring.table.title')}
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder={t('monitoring.table.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" color="action" />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
sx={{ width: 300 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="usage table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
|
||||
{t('monitoring.table.columns.projectName')}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
|
||||
{t('monitoring.table.columns.provider')}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
|
||||
{t('monitoring.table.columns.model')}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
|
||||
{t('monitoring.table.columns.status')}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
|
||||
{t('monitoring.table.columns.failureReason')}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
|
||||
{t('monitoring.table.columns.inputTokens')}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
|
||||
{t('monitoring.table.columns.outputTokens')}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
|
||||
{t('monitoring.table.columns.totalTokens')}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
|
||||
{t('monitoring.table.columns.calls')}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
|
||||
{t('monitoring.table.columns.avgLatency')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.map((row, index) => (
|
||||
<TableRow key={index} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
|
||||
<TableCell component="th" scope="row">
|
||||
<Typography variant="body2" fontWeight="500">
|
||||
{row.projectName}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={row.provider}
|
||||
size="small"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.1),
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{row.model}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={row.status === 'SUCCESS' ? t('monitoring.status.success') : t('monitoring.status.failed')}
|
||||
color={statusColors[row.status] || 'default'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
borderColor: theme.palette[statusColors[row.status]]?.main,
|
||||
color: theme.palette[statusColors[row.status]]?.main,
|
||||
bgcolor: alpha(theme.palette[statusColors[row.status]]?.main || '#000', 0.05)
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.failureReason ? (
|
||||
<Tooltip title={row.failureReason} arrow placement="top">
|
||||
<Chip
|
||||
label={
|
||||
row.failureReason.length > 20 ? row.failureReason.slice(0, 20) + '...' : row.failureReason
|
||||
}
|
||||
size="small"
|
||||
color="error"
|
||||
variant="soft"
|
||||
sx={{
|
||||
maxWidth: 200,
|
||||
bgcolor: alpha(theme.palette.error.main, 0.1),
|
||||
color: theme.palette.error.dark,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">{row.inputTokens.toLocaleString()}</TableCell>
|
||||
<TableCell align="right">{row.outputTokens.toLocaleString()}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{row.totalTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell align="right">{row.calls}</TableCell>
|
||||
<TableCell align="right">{row.avgLatency}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" sx={{ py: 8 }}>
|
||||
<Typography color="text.secondary">{t('monitoring.table.empty')}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[5, 10, 25, 50]}
|
||||
component="div"
|
||||
count={total}
|
||||
rowsPerPage={pageSize}
|
||||
page={page - 1}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
labelRowsPerPage={t('monitoring.table.rowsPerPage')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
128
easy-dataset-main/app/monitoring/hooks/useMonitoringData.js
Normal file
128
easy-dataset-main/app/monitoring/hooks/useMonitoringData.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function useMonitoringData() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [summaryData, setSummaryData] = useState({
|
||||
summary: {
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCalls: 0,
|
||||
successCalls: 0,
|
||||
failedCalls: 0,
|
||||
totalLatency: 0,
|
||||
avgLatency: 0,
|
||||
avgTokensPerCall: 0,
|
||||
failureRate: 0
|
||||
},
|
||||
trend: [],
|
||||
modelDistribution: [],
|
||||
projects: [],
|
||||
providers: []
|
||||
});
|
||||
|
||||
const [logsData, setLogsData] = useState({
|
||||
details: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
timeRange: '7d',
|
||||
projectId: 'all',
|
||||
provider: 'all',
|
||||
status: 'all'
|
||||
});
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
});
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 获取汇总数据
|
||||
const fetchSummary = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/monitoring/summary', {
|
||||
params: filters
|
||||
});
|
||||
setSummaryData(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch monitoring summary:', error);
|
||||
toast.error(t('monitoring.errors.fetchSummaryFailed'));
|
||||
}
|
||||
}, [filters, t]);
|
||||
|
||||
// 获取日志列表
|
||||
const fetchLogs = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/monitoring/logs', {
|
||||
params: {
|
||||
...filters,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
search: searchTerm
|
||||
}
|
||||
});
|
||||
setLogsData(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch monitoring logs:', error);
|
||||
toast.error(t('monitoring.errors.fetchLogsFailed'));
|
||||
}
|
||||
}, [filters, pagination, searchTerm, t]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([fetchSummary(), fetchLogs()]);
|
||||
setLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [fetchSummary, fetchLogs]);
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
setPagination(prev => ({ ...prev, page: 1 })); // 重置到第一页
|
||||
};
|
||||
|
||||
const handlePageChange = newPage => {
|
||||
setPagination(prev => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
const handlePageSizeChange = newPageSize => {
|
||||
setPagination({ page: 1, pageSize: newPageSize });
|
||||
};
|
||||
|
||||
const handleSearchChange = term => {
|
||||
setSearchTerm(term);
|
||||
setPagination(prev => ({ ...prev, page: 1 })); // 重置到第一页
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([fetchSummary(), fetchLogs()]);
|
||||
setLoading(false);
|
||||
}, [fetchSummary, fetchLogs]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
summaryData,
|
||||
logsData,
|
||||
filters,
|
||||
pagination,
|
||||
searchTerm,
|
||||
handleFilterChange,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleSearchChange,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
244
easy-dataset-main/app/monitoring/page.js
Normal file
244
easy-dataset-main/app/monitoring/page.js
Normal file
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Stack,
|
||||
Button,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
CircularProgress,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Download as DownloadIcon,
|
||||
FilterList as FilterListIcon,
|
||||
CloudQueue as CloudQueueIcon,
|
||||
CheckCircle as CheckCircleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Navbar from '@/components/Navbar/index';
|
||||
import StatsCards from './components/StatsCards';
|
||||
import Charts from './components/Charts';
|
||||
import UsageTable from './components/UsageTable';
|
||||
import { useMonitoringData } from './hooks/useMonitoringData';
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const {
|
||||
loading,
|
||||
summaryData,
|
||||
logsData,
|
||||
filters,
|
||||
pagination,
|
||||
searchTerm,
|
||||
handleFilterChange,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleSearchChange
|
||||
} = useMonitoringData();
|
||||
|
||||
// 获取项目列表用于 Navbar
|
||||
useEffect(() => {
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const response = await fetch('/api/projects');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProjects(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch projects:', error);
|
||||
}
|
||||
}
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const handleTimeRangeChange = (event, newRange) => {
|
||||
if (newRange !== null) {
|
||||
handleFilterChange('timeRange', newRange);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
// 简单的导出功能实现,将当前 logsData.details 导出为 CSV
|
||||
if (!logsData.details || logsData.details.length === 0) return;
|
||||
|
||||
const headers = [
|
||||
t('monitoring.table.columns.projectName'),
|
||||
t('monitoring.table.columns.provider'),
|
||||
t('monitoring.table.columns.model'),
|
||||
t('monitoring.table.columns.status'),
|
||||
t('monitoring.table.columns.failureReason'),
|
||||
t('monitoring.table.columns.inputTokens'),
|
||||
t('monitoring.table.columns.outputTokens'),
|
||||
t('monitoring.table.columns.totalTokens'),
|
||||
t('monitoring.table.columns.calls'),
|
||||
t('monitoring.table.columns.avgLatency')
|
||||
];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...logsData.details.map(row =>
|
||||
[
|
||||
row.projectName,
|
||||
row.provider,
|
||||
row.model,
|
||||
row.status,
|
||||
(row.failureReason || '').replace(/,/g, ' '),
|
||||
row.inputTokens,
|
||||
row.outputTokens,
|
||||
row.totalTokens,
|
||||
row.calls,
|
||||
row.avgLatency
|
||||
].join(',')
|
||||
)
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `llm-monitoring-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar projects={projects} currentProject={null} />
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 8 }}>
|
||||
<Container maxWidth="xl" sx={{ pt: 4 }}>
|
||||
{/* Header Area */}
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'flex-start', md: 'center' }}
|
||||
spacing={2}
|
||||
mb={4}
|
||||
>
|
||||
{/* Time Range Selector */}
|
||||
<ToggleButtonGroup
|
||||
value={filters.timeRange}
|
||||
exclusive
|
||||
onChange={handleTimeRangeChange}
|
||||
aria-label="time range"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
'& .MuiToggleButton-root': {
|
||||
px: 3,
|
||||
py: 1,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark'
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="24h">{t('monitoring.timeRange.24h')}</ToggleButton>
|
||||
<ToggleButton value="7d">{t('monitoring.timeRange.7d')}</ToggleButton>
|
||||
<ToggleButton value="30d">{t('monitoring.timeRange.30d')}</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{/* Filters & Actions */}
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={filters.projectId}
|
||||
onChange={e => handleFilterChange('projectId', e.target.value)}
|
||||
displayEmpty
|
||||
startAdornment={<FilterListIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />}
|
||||
sx={{ bgcolor: 'background.paper' }}
|
||||
>
|
||||
<MenuItem value="all">{t('monitoring.filters.allProjects')}</MenuItem>
|
||||
{summaryData.projects.map(p => (
|
||||
<MenuItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={filters.provider}
|
||||
onChange={e => handleFilterChange('provider', e.target.value)}
|
||||
displayEmpty
|
||||
startAdornment={<CloudQueueIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />}
|
||||
sx={{ bgcolor: 'background.paper' }}
|
||||
>
|
||||
<MenuItem value="all">{t('monitoring.filters.allProviders')}</MenuItem>
|
||||
{summaryData.providers.map(provider => (
|
||||
<MenuItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onChange={e => handleFilterChange('status', e.target.value)}
|
||||
displayEmpty
|
||||
startAdornment={<CheckCircleIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />}
|
||||
sx={{ bgcolor: 'background.paper' }}
|
||||
>
|
||||
<MenuItem value="all">{t('monitoring.filters.allStatus')}</MenuItem>
|
||||
<MenuItem value="SUCCESS">{t('monitoring.status.success')}</MenuItem>
|
||||
<MenuItem value="FAILED">{t('monitoring.status.failed')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleExport}
|
||||
sx={{ textTransform: 'none', px: 3 }}
|
||||
>
|
||||
{t('monitoring.actions.export')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
{/* 统计卡片 */}
|
||||
<StatsCards data={summaryData.summary} />
|
||||
|
||||
{/* 图表区域 */}
|
||||
<Charts trendData={summaryData.trend} modelDistribution={summaryData.modelDistribution} />
|
||||
|
||||
{/* 详细表格 */}
|
||||
<UsageTable
|
||||
data={logsData.details}
|
||||
total={logsData.total}
|
||||
page={pagination.page}
|
||||
pageSize={pagination.pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user