Ai proxy monitor (#5009)

* Time granularity and Table of a single model (#4990)

* Aiproxy ModelBoard

* Add components LineChartComponent and Make some revisions

* Time granularity and Table of a single model

* Modify the logic and sort the tables in ascending or descending order

* Use theme and present in seconds

* Add the channel name section (#5005)

* Add components LineChartComponent and Make some revisions

* Time granularity and Table of a single model

* Modify the logic and sort the tables in ascending or descending order

* Add the channel name section

* The channel_name is transmitted from the outer layer

* Restore the channel

* perf: dashboard code

* perf: ai proxy monitor

* code

---------

Co-authored-by: Zhuangzai fa <143257420+ctrlz526@users.noreply.github.com>
This commit is contained in:
Archer
2025-06-11 18:21:59 +08:00
committed by GitHub
parent abbfc436a3
commit 9f8b75fd7c
9 changed files with 998 additions and 309 deletions

View File

@@ -9,8 +9,8 @@ weight: 788
## 🚀 新增内容 ## 🚀 新增内容
1. AI proxy 服务,支持以图表形式展示模型调用情况。 1. AI proxy 监控完善,支持以图表/表格形式查看模型调用和性能情况。
1. 商业版支持知识库分块时LLM 进行自动分段识别。 2. 商业版支持知识库分块时LLM 进行自动分段识别。
## ⚙️ 优化 ## ⚙️ 优化

View File

@@ -3,6 +3,8 @@
"aipoint_usage": "AI points", "aipoint_usage": "AI points",
"all": "All", "all": "All",
"api_key": "API key", "api_key": "API key",
"avg_response_time": "Average call time (seconds)",
"avg_ttfb": "Average first word duration (seconds)",
"azure": "Azure", "azure": "Azure",
"base_url": "Base url", "base_url": "Base url",
"channel_name": "Channel", "channel_name": "Channel",
@@ -14,14 +16,28 @@
"channel_status_enabled": "Enable", "channel_status_enabled": "Enable",
"channel_status_unknown": "unknown", "channel_status_unknown": "unknown",
"channel_type": "Manufacturer", "channel_type": "Manufacturer",
"chart_mode_cumulative": "Cumulative",
"chart_mode_incremental": "Incremental",
"clear_model": "Clear the model", "clear_model": "Clear the model",
"confirm_delete_channel": "Confirm the deletion of the [{{name}}] channel?", "confirm_delete_channel": "Confirm the deletion of the [{{name}}] channel?",
"copy_model_id_success": "Copyed model id", "copy_model_id_success": "Copyed model id",
"create_channel": "Added channels", "create_channel": "Added channels",
"dashboard_call_trend": "Model Call Trend",
"dashboard_channel": "Channel",
"dashboard_cost_trend": "Cost Consumption",
"dashboard_error_calls": "Error Calls", "dashboard_error_calls": "Error Calls",
"dashboard_input_tokens": "Input Tokens",
"dashboard_model": "Model", "dashboard_model": "Model",
"dashboard_no_data": "No data available",
"dashboard_output_tokens": "Output Tokens",
"dashboard_points": "points", "dashboard_points": "points",
"dashboard_success_calls": "Success Calls",
"dashboard_token_trend": "Token Usage Trend",
"dashboard_token_usage": "Tokens", "dashboard_token_usage": "Tokens",
"dashboard_total_calls": "Total Calls:",
"dashboard_total_cost": "Total Cost",
"dashboard_total_cost_label": "Total Cost:",
"dashboard_total_tokens": "Total Tokens",
"default_url": "Default address", "default_url": "Default address",
"detail": "Detail", "detail": "Detail",
"duration": "Duration", "duration": "Duration",
@@ -38,7 +54,9 @@
"mapping": "Model Mapping", "mapping": "Model Mapping",
"mapping_tip": "A valid Json is required. \nThe model can be mapped when sending a request to the actual address. \nFor example:\n{\n \n \"gpt-4o\": \"gpt-4o-test\"\n\n}\n\nWhen FastGPT requests the gpt-4o model, the gpt-4o-test model is sent to the actual address, instead of gpt-4o.", "mapping_tip": "A valid Json is required. \nThe model can be mapped when sending a request to the actual address. \nFor example:\n{\n \n \"gpt-4o\": \"gpt-4o-test\"\n\n}\n\nWhen FastGPT requests the gpt-4o model, the gpt-4o-test model is sent to the actual address, instead of gpt-4o.",
"maxToken_tip": "Model max_tokens parameter", "maxToken_tip": "Model max_tokens parameter",
"max_rpm": "Max RPM (Requests Per Minute)",
"max_temperature_tip": "If the model temperature parameter is not filled in, it means that the model does not support the temperature parameter.", "max_temperature_tip": "If the model temperature parameter is not filled in, it means that the model does not support the temperature parameter.",
"max_tpm": "Max TPM (Tokens Per Minute)",
"model": "Model", "model": "Model",
"model_error_rate": "Error rate", "model_error_rate": "Error rate",
"model_error_request_times": "Number of failures", "model_error_request_times": "Number of failures",
@@ -60,7 +78,15 @@
"selected_model_empty": "Choose at least one model", "selected_model_empty": "Choose at least one model",
"start_test": "Batch test {{num}} models", "start_test": "Batch test {{num}} models",
"test_failed": "There are {{num}} models that report errors", "test_failed": "There are {{num}} models that report errors",
"timespan_day": "Day",
"timespan_hour": "Hour",
"timespan_label": "Time Granularity",
"timespan_minute": "Minute",
"total_call_volume": "Request amount",
"view_chart": "Chart",
"view_table": "Table",
"vlm_model": "Vlm", "vlm_model": "Vlm",
"vlm_model_tip": "Used to generate additional indexing of images in a document in the knowledge base", "vlm_model_tip": "Used to generate additional indexing of images in a document in the knowledge base",
"volunme_of_failed_calls": "Error amount",
"waiting_test": "Waiting for testing" "waiting_test": "Waiting for testing"
} }

View File

@@ -1,7 +1,10 @@
{ {
"Hunyuan": "腾讯混元", "Hunyuan": "腾讯混元",
"aipoint_usage": "积分消耗",
"all": "全部", "all": "全部",
"api_key": "API 密钥", "api_key": "API 密钥",
"avg_response_time": "平均调用时长 (秒)",
"avg_ttfb": "平均首字时长 (秒)",
"azure": "微软 Azure", "azure": "微软 Azure",
"base_url": "代理地址", "base_url": "代理地址",
"channel_name": "渠道名", "channel_name": "渠道名",
@@ -13,15 +16,28 @@
"channel_status_enabled": "启用", "channel_status_enabled": "启用",
"channel_status_unknown": "未知", "channel_status_unknown": "未知",
"channel_type": "厂商", "channel_type": "厂商",
"chart_mode_cumulative": "累积",
"chart_mode_incremental": "分时",
"clear_model": "清空模型", "clear_model": "清空模型",
"confirm_delete_channel": "确认删除 【{{name}}】渠道?", "confirm_delete_channel": "确认删除 【{{name}}】渠道?",
"copy_model_id_success": "已复制模型id", "copy_model_id_success": "已复制模型id",
"create_channel": "新增渠道", "create_channel": "新增渠道",
"aipoint_usage": "积分消耗", "dashboard_call_trend": "模型调用次数趋势",
"dashboard_channel": "渠道",
"dashboard_cost_trend": "积分消耗",
"dashboard_error_calls": "错误次数", "dashboard_error_calls": "错误次数",
"dashboard_input_tokens": "输入Tokens",
"dashboard_model": "模型", "dashboard_model": "模型",
"dashboard_no_data": "暂无数据",
"dashboard_output_tokens": "输出Tokens",
"dashboard_points": "积分", "dashboard_points": "积分",
"dashboard_success_calls": "成功次数",
"dashboard_token_trend": "Tokens使用趋势",
"dashboard_token_usage": "Tokens 消耗", "dashboard_token_usage": "Tokens 消耗",
"dashboard_total_calls": "总调用次数:",
"dashboard_total_cost": "总成本",
"dashboard_total_cost_label": "总成本:",
"dashboard_total_tokens": "总Tokens",
"default_url": "默认地址", "default_url": "默认地址",
"detail": "详情", "detail": "详情",
"duration": "耗时", "duration": "耗时",
@@ -38,7 +54,9 @@
"mapping": "模型映射", "mapping": "模型映射",
"mapping_tip": "需填写一个有效 Json。可在向实际地址发送请求时对模型进行映射。例如\n{\n \"gpt-4o\": \"gpt-4o-test\"\n}\n当 FastGPT 请求 gpt-4o 模型时,会向实际地址发送 gpt-4o-test 的模型,而不是 gpt-4o。", "mapping_tip": "需填写一个有效 Json。可在向实际地址发送请求时对模型进行映射。例如\n{\n \"gpt-4o\": \"gpt-4o-test\"\n}\n当 FastGPT 请求 gpt-4o 模型时,会向实际地址发送 gpt-4o-test 的模型,而不是 gpt-4o。",
"maxToken_tip": "模型 max_tokens 参数", "maxToken_tip": "模型 max_tokens 参数",
"max_rpm": "最大RPM (每分钟请求数)",
"max_temperature_tip": "模型 temperature 参数,不填则代表模型不支持 temperature 参数。", "max_temperature_tip": "模型 temperature 参数,不填则代表模型不支持 temperature 参数。",
"max_tpm": "最大TPM (每分钟Token数)",
"model": "模型", "model": "模型",
"model_error_rate": "失败率", "model_error_rate": "失败率",
"model_error_request_times": "失败次数", "model_error_request_times": "失败次数",
@@ -60,7 +78,15 @@
"selected_model_empty": "至少选择一个模型", "selected_model_empty": "至少选择一个模型",
"start_test": "批量测试{{num}}个模型", "start_test": "批量测试{{num}}个模型",
"test_failed": "有{{num}}个模型报错", "test_failed": "有{{num}}个模型报错",
"timespan_day": "天",
"timespan_hour": "小时",
"timespan_label": "时间颗粒度",
"timespan_minute": "分钟",
"total_call_volume": "调用总量",
"view_chart": "图表",
"view_table": "表格",
"vlm_model": "图片理解模型", "vlm_model": "图片理解模型",
"vlm_model_tip": "用于知识库中对文档中的图片进行额外的索引生成", "vlm_model_tip": "用于知识库中对文档中的图片进行额外的索引生成",
"volunme_of_failed_calls": "调用失败量",
"waiting_test": "等待测试" "waiting_test": "等待测试"
} }

View File

@@ -3,6 +3,8 @@
"aipoint_usage": "積分消耗", "aipoint_usage": "積分消耗",
"all": "全部", "all": "全部",
"api_key": "API 金鑰", "api_key": "API 金鑰",
"avg_response_time": "平均調用時長 (秒)",
"avg_ttfb": "平均首字時長 (秒)",
"azure": "Azure", "azure": "Azure",
"base_url": "代理地址", "base_url": "代理地址",
"channel_name": "管道名稱", "channel_name": "管道名稱",
@@ -14,11 +16,28 @@
"channel_status_enabled": "啟用", "channel_status_enabled": "啟用",
"channel_status_unknown": "未知", "channel_status_unknown": "未知",
"channel_type": "廠商", "channel_type": "廠商",
"chart_mode_cumulative": "累積",
"chart_mode_incremental": "分時",
"clear_model": "清空模型", "clear_model": "清空模型",
"confirm_delete_channel": "確認刪除【{{name}}】管道?", "confirm_delete_channel": "確認刪除【{{name}}】管道?",
"copy_model_id_success": "已復制模型 id", "copy_model_id_success": "已復制模型 id",
"create_channel": "新增管道", "create_channel": "新增管道",
"dashboard_call_trend": "模型呼叫次數趨勢",
"dashboard_channel": "管道",
"dashboard_cost_trend": "積分消耗",
"dashboard_error_calls": "錯誤次數",
"dashboard_input_tokens": "輸入Tokens",
"dashboard_model": "模型",
"dashboard_no_data": "暫無資料",
"dashboard_output_tokens": "輸出Tokens",
"dashboard_points": "積分",
"dashboard_success_calls": "成功次數",
"dashboard_token_trend": "Tokens使用趨勢",
"dashboard_token_usage": "Tokens 消耗", "dashboard_token_usage": "Tokens 消耗",
"dashboard_total_calls": "總呼叫次數:",
"dashboard_total_cost": "總成本",
"dashboard_total_cost_label": "總成本:",
"dashboard_total_tokens": "總Tokens",
"default_url": "預設地址", "default_url": "預設地址",
"detail": "詳細資訊", "detail": "詳細資訊",
"duration": "耗時", "duration": "耗時",
@@ -35,7 +54,9 @@
"mapping": "模型對映", "mapping": "模型對映",
"mapping_tip": "需填寫一個有效 Json。\n可在向實際地址傳送請求時對模型進行對映。\n例如\n{\n \n \"gpt-4o\": \"gpt-4o-test\"\n\n}\n\n當 FastGPT 請求 gpt-4o 模型時,會向實際地址傳送 gpt-4o-test 的模型,而不是 gpt-4o。", "mapping_tip": "需填寫一個有效 Json。\n可在向實際地址傳送請求時對模型進行對映。\n例如\n{\n \n \"gpt-4o\": \"gpt-4o-test\"\n\n}\n\n當 FastGPT 請求 gpt-4o 模型時,會向實際地址傳送 gpt-4o-test 的模型,而不是 gpt-4o。",
"maxToken_tip": "模型 max_tokens 參數", "maxToken_tip": "模型 max_tokens 參數",
"max_rpm": "最大RPM (每分鐘請求數)",
"max_temperature_tip": "模型 temperature 參數,不填則代表模型不支援 temperature 參數。", "max_temperature_tip": "模型 temperature 參數,不填則代表模型不支援 temperature 參數。",
"max_tpm": "最大TPM (每分鐘Token數)",
"model": "模型", "model": "模型",
"model_error_rate": "失敗率", "model_error_rate": "失敗率",
"model_error_request_times": "失敗次數", "model_error_request_times": "失敗次數",
@@ -57,7 +78,15 @@
"selected_model_empty": "至少選擇一個模型", "selected_model_empty": "至少選擇一個模型",
"start_test": "批次測試{{num}}個模型", "start_test": "批次測試{{num}}個模型",
"test_failed": "有{{num}}個模型報錯", "test_failed": "有{{num}}個模型報錯",
"timespan_day": "天",
"timespan_hour": "小時",
"timespan_label": "時間顆粒度",
"timespan_minute": "分鐘",
"total_call_volume": "調用總量",
"view_chart": "圖表",
"view_table": "表格",
"vlm_model": "圖片理解模型", "vlm_model": "圖片理解模型",
"vlm_model_tip": "用於知識庫中對文件中的圖片進行額外的索引生成", "vlm_model_tip": "用於知識庫中對文件中的圖片進行額外的索引生成",
"volunme_of_failed_calls": "調用失敗量",
"waiting_test": "等待測試" "waiting_test": "等待測試"
} }

View File

@@ -54,11 +54,16 @@ export type ChannelLogListItemType = {
}; };
export type DashboardDataItemType = { export type DashboardDataItemType = {
channel_id?: number;
model: string; model: string;
request_count: number; request_count?: number;
used_amount: number; used_amount?: number;
exception_count: number; exception_count?: number;
total_time_milliseconds?: number;
total_ttfb_milliseconds?: number;
input_tokens?: number; input_tokens?: number;
output_tokens?: number; output_tokens?: number;
total_tokens?: number; total_tokens?: number;
max_rpm?: number;
max_tpm?: number;
}; };

View File

@@ -0,0 +1,307 @@
import React, { useMemo, useState } from 'react';
import { Table, TableContainer, Thead, Tbody, Tr, Th, Td, Button } from '@chakra-ui/react';
import { formatNumber } from '@fastgpt/global/common/math/tools';
import { useTranslation } from 'next-i18next';
import MyBox from '@fastgpt/web/components/common/MyBox';
import MyIcon from '@fastgpt/web/components/common/Icon';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import type { DashboardDataItemType } from '@/global/aiproxy/type.d';
import { useSystemStore } from '@/web/common/system/useSystemStore';
export type DashboardDataEntry = {
timestamp: number;
summary: DashboardDataItemType[];
};
export type DataTableComponentProps = {
data: DashboardDataEntry[];
filterProps: {
channelId?: string;
model?: string;
};
channelList: {
label: string;
value: string;
}[];
modelPriceMap: Map<
string,
{
inputPrice?: number;
outputPrice?: number;
charsPointsPrice?: number;
}
>;
onViewDetail: (model: string) => void;
};
type SortFieldType = 'totalCalls' | 'errorCalls' | 'totalCost';
const DataTableComponent = ({
data,
filterProps,
onViewDetail,
channelList,
modelPriceMap
}: DataTableComponentProps) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const [sortField, setSortField] = useState<SortFieldType>('totalCalls');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
// Create a mapping from channel ID to channel name
const channelIdToNameMap = useMemo(() => {
const map = new Map<number, string>();
channelList.forEach((channel) => {
if (channel.value && channel.value !== '') {
const channelId = parseInt(channel.value);
if (!isNaN(channelId)) {
map.set(channelId, channel.label);
}
}
});
return map;
}, [channelList]);
// display the channel column
const showChannelColumn = !!filterProps.model;
const tableData = useMemo(() => {
if (data.length === 0) {
return [];
}
const rows: {
model: string;
channelName?: string;
totalCalls: number;
errorCalls: number;
totalCost: number;
avgResponseTime: number;
avgTtfb: number;
}[] = [];
if (showChannelColumn) {
// When a specific model is selected, aggregate the data by channel_id
const channelMap = new Map<
string,
{
model: string;
totalCalls: number;
errorCalls: number;
totalCost: number;
totalResponseTime: number;
totalTtfb: number;
}
>();
data.forEach((dayData) => {
const summary = dayData.summary;
summary.forEach((item: DashboardDataItemType) => {
const channelId = `${item.channel_id!}`;
const existing = channelMap.get(channelId) || {
model: item.model || '-',
totalCalls: 0,
errorCalls: 0,
totalCost: 0,
totalResponseTime: 0,
totalTtfb: 0
};
existing.totalCalls += item.request_count || 0;
existing.errorCalls += item.exception_count || 0;
existing.totalResponseTime += item.total_time_milliseconds || 0;
existing.totalTtfb += item.total_ttfb_milliseconds || 0;
const modelPricing = modelPriceMap.get(item.model);
if (modelPricing) {
const inputTokens = item.input_tokens || 0;
const outputTokens = item.output_tokens || 0;
const isIOPriceType =
typeof modelPricing.inputPrice === 'number' && modelPricing.inputPrice > 0;
const totalPoints = isIOPriceType
? (modelPricing.inputPrice || 0) * (inputTokens / 1000) +
(modelPricing.outputPrice || 0) * (outputTokens / 1000)
: ((modelPricing.charsPointsPrice || 0) * (inputTokens + outputTokens)) / 1000;
existing.totalCost += totalPoints;
}
channelMap.set(channelId, existing);
});
});
channelMap.forEach((item, channelId) => {
const successCalls = item.totalCalls - item.errorCalls;
rows.push({
channelName: channelIdToNameMap.get(parseInt(channelId)) || '',
model: item.model,
totalCalls: item.totalCalls,
errorCalls: item.errorCalls,
totalCost: item.totalCost,
avgResponseTime: successCalls > 0 ? item.totalResponseTime / successCalls / 1000 : 0,
avgTtfb: successCalls > 0 ? item.totalTtfb / successCalls / 1000 : 0
});
});
} else {
// When no specific model is selected, aggregate the data by the model.
const modelMap = new Map<
string,
{
totalCalls: number;
errorCalls: number;
totalCost: number;
totalResponseTime: number;
totalTtfb: number;
}
>();
data.forEach((dayData) => {
const summary = dayData.summary;
summary.forEach((item: DashboardDataItemType) => {
const modelName = item.model || '-';
const existing = modelMap.get(modelName) || {
totalCalls: 0,
errorCalls: 0,
totalCost: 0,
totalResponseTime: 0,
totalTtfb: 0
};
existing.totalCalls += item.request_count || 0;
existing.errorCalls += item.exception_count || 0;
existing.totalResponseTime += item.total_time_milliseconds || 0;
existing.totalTtfb += item.total_ttfb_milliseconds || 0;
const modelPricing = modelPriceMap.get(item.model);
if (modelPricing) {
const inputTokens = item.input_tokens || 0;
const outputTokens = item.output_tokens || 0;
const isIOPriceType =
typeof modelPricing.inputPrice === 'number' && modelPricing.inputPrice > 0;
const totalPoints = isIOPriceType
? (modelPricing.inputPrice || 0) * (inputTokens / 1000) +
(modelPricing.outputPrice || 0) * (outputTokens / 1000)
: ((modelPricing.charsPointsPrice || 0) * (inputTokens + outputTokens)) / 1000;
existing.totalCost += totalPoints;
}
modelMap.set(modelName, existing);
});
});
modelMap.forEach((item, modelName) => {
const successCalls = item.totalCalls - item.errorCalls;
rows.push({
model: modelName,
totalCalls: item.totalCalls,
errorCalls: item.errorCalls,
totalCost: item.totalCost,
avgResponseTime: successCalls > 0 ? item.totalResponseTime / successCalls / 1000 : 0,
avgTtfb: successCalls > 0 ? item.totalTtfb / successCalls / 1000 : 0
});
});
}
// sort
if (sortField) {
rows.sort((a, b) => {
const aVal = a[sortField];
const bVal = b[sortField];
return sortDirection === 'desc' ? bVal - aVal : aVal - bVal;
});
}
return rows;
}, [data, showChannelColumn, sortField, modelPriceMap, channelIdToNameMap, sortDirection]);
const handleSort = (field: SortFieldType) => {
if (sortField === field) {
// Toggle between desc and asc
setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc');
} else {
// Switch to new field, default to desc
setSortField(field);
setSortDirection('desc');
}
};
const getSortIcon = (field: SortFieldType) => {
if (sortField !== field) return null;
return sortDirection === 'desc' ? '↓' : '↑';
};
return (
<MyBox h={'100%'}>
<TableContainer fontSize={'sm'}>
<Table>
<Thead>
<Tr userSelect={'none'}>
<Th>{t('account_model:dashboard_model')}</Th>
{showChannelColumn && <Th>{t('account_model:dashboard_channel')}</Th>}
<Th
cursor="pointer"
onClick={() => handleSort('totalCalls')}
_hover={{ color: 'primary.600' }}
>
{t('account_model:total_call_volume')} {getSortIcon('totalCalls')}
</Th>
<Th
cursor="pointer"
onClick={() => handleSort('errorCalls')}
_hover={{ color: 'primary.600' }}
>
{t('account_model:volunme_of_failed_calls')} {getSortIcon('errorCalls')}
</Th>
{feConfigs?.isPlus && (
<Th
cursor="pointer"
onClick={() => handleSort('totalCost')}
_hover={{ color: 'primary.600' }}
>
{t('account_model:aipoint_usage')} {getSortIcon('totalCost')}
</Th>
)}
<Th>{t('account_model:avg_response_time')}</Th>
<Th>{t('account_model:avg_ttfb')}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{tableData.map((item, index) => (
<Tr key={index}>
<Td>{item.model}</Td>
{showChannelColumn && <Td>{item.channelName}</Td>}
<Td color={'primary.700'}>{formatNumber(item.totalCalls).toLocaleString()}</Td>
<Td color={'red.700'}>{formatNumber(item.errorCalls)}</Td>
{feConfigs?.isPlus && <Td>{formatNumber(item.totalCost).toLocaleString()}</Td>}
<Td color={item.avgResponseTime > 10 ? 'yellow.700' : ''}>
{item.avgResponseTime > 0 ? `${item.avgResponseTime.toFixed(2)}` : '-'}
</Td>
<Td>{item.avgTtfb > 0 ? `${item.avgTtfb.toFixed(2)}` : '-'}</Td>
<Td>
<Button
leftIcon={<MyIcon name={'menu'} w={'1rem'} />}
size={'sm'}
variant={'whiteBase'}
onClick={() => onViewDetail(item.model)}
>
{t('account_model:detail')}
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
{tableData.length === 0 && <EmptyTip text={t('account_model:dashboard_no_data')} />}
</MyBox>
);
};
export default DataTableComponent;

View File

@@ -12,10 +12,9 @@ import {
} from 'recharts'; } from 'recharts';
import { type NameType, type ValueType } from 'recharts/types/component/DefaultTooltipContent'; import { type NameType, type ValueType } from 'recharts/types/component/DefaultTooltipContent';
import { formatNumber } from '@fastgpt/global/common/math/tools'; import { formatNumber } from '@fastgpt/global/common/math/tools';
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
type XAxisConfig = { import { useTranslation } from 'next-i18next';
dataKey: string; import { cloneDeep } from 'lodash';
};
type LineConfig = { type LineConfig = {
dataKey: string; dataKey: string;
@@ -29,15 +28,16 @@ type TooltipItem = {
dataKey: string; dataKey: string;
color: string; color: string;
formatter?: (value: number) => string; formatter?: (value: number) => string;
customValue?: (data: any) => number; customValue?: (data: Record<string, any>) => number;
}; };
type LineChartComponentProps = { type LineChartComponentProps = {
data: Record<string, any>[]; data: Record<string, any>[];
title: string; title: string;
HeaderRightChildren?: React.ReactNode; HeaderLeftChildren?: React.ReactNode;
lines: LineConfig[]; lines: LineConfig[];
tooltipItems?: TooltipItem[]; tooltipItems?: TooltipItem[];
enableCumulative?: boolean;
}; };
const CustomTooltip = ({ const CustomTooltip = ({
@@ -47,48 +47,51 @@ const CustomTooltip = ({
}: TooltipProps<ValueType, NameType> & { tooltipItems?: TooltipItem[] }) => { }: TooltipProps<ValueType, NameType> & { tooltipItems?: TooltipItem[] }) => {
const data = payload?.[0]?.payload; const data = payload?.[0]?.payload;
if (active && data && tooltipItems) { if (!active || !data || !tooltipItems) {
return ( return null;
<Box bg={'white'} p={3} borderRadius={'md'} border={'base'} boxShadow={'sm'}>
<Box fontSize={'sm'} color={'myGray.900'} mb={2}>
{data.x}
</Box>
{tooltipItems.map((item, index) => {
const value = (() => {
if (item.customValue) {
return item.customValue(data);
} else {
return data[item.dataKey];
}
})();
const displayValue = (() => {
const val = item.formatter ? item.formatter(value) : formatNumber(value);
return val.toLocaleString();
})();
return (
<HStack key={index} fontSize={'sm'} _notLast={{ mb: 1 }}>
<Box w={2} h={2} borderRadius={'full'} bg={item.color} />
<Box>{item.label}</Box>
<Box>{displayValue}</Box>
</HStack>
);
})}
</Box>
);
} }
return null;
return (
<Box bg="white" p={3} borderRadius="md" border="base" boxShadow="sm">
<Box fontSize="sm" color="myGray.900" mb={2}>
{data.xLabel || data.x}
</Box>
{tooltipItems.map((item, index) => {
const value = item.customValue ? item.customValue(data) : data[item.dataKey];
const displayValue = item.formatter ? item.formatter(value) : formatNumber(value);
return (
<HStack key={index} fontSize="sm" _notLast={{ mb: 1 }}>
<Box w={2} h={2} borderRadius="full" bg={item.color} />
<Box>{item.label}</Box>
<Box>{displayValue.toLocaleString()}</Box>
</HStack>
);
})}
</Box>
);
}; };
const LineChartComponent = ({ const LineChartComponent = ({
data, data,
title, title,
HeaderRightChildren, HeaderLeftChildren,
lines, lines,
tooltipItems tooltipItems,
enableCumulative = true
}: LineChartComponentProps) => { }: LineChartComponentProps) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation();
const [displayMode, setDisplayMode] = useState<'incremental' | 'cumulative'>('incremental');
// Tab list constant
const tabList = useMemo(
() => [
{ label: t('account_model:chart_mode_incremental'), value: 'incremental' as const },
{ label: t('account_model:chart_mode_cumulative'), value: 'cumulative' as const }
],
[t]
);
// Y-axis number formatter function // Y-axis number formatter function
const formatYAxisNumber = useCallback((value: number): string => { const formatYAxisNumber = useCallback((value: number): string => {
@@ -100,11 +103,35 @@ const LineChartComponent = ({
return value.toString(); return value.toString();
}, []); }, []);
// Process data based on display mode
const processedData = useMemo(() => {
if (displayMode === 'incremental' || !enableCumulative) {
return data;
}
// Cumulative mode: accumulate values for each line's dataKey
const cloneData = cloneDeep(data);
const dataKeys = lines.map((item) => item.dataKey);
return cloneData.map((item, index) => {
if (index === 0) return item;
dataKeys.forEach((key) => {
if (typeof item[key] === 'number') {
item[key] += cloneData[index - 1][key];
}
});
return item;
});
}, [data, displayMode, lines, enableCumulative]);
// Generate gradient definitions // Generate gradient definitions
const gradientDefs = useMemo(() => { const gradientDefs = useMemo(
return ( () => (
<defs> <defs>
{lines.map((line, index) => ( {lines.map((line) => (
<linearGradient <linearGradient
key={`gradient-${line.color}`} key={`gradient-${line.color}`}
id={`gradient-${line.color}`} id={`gradient-${line.color}`}
@@ -118,8 +145,9 @@ const LineChartComponent = ({
</linearGradient> </linearGradient>
))} ))}
</defs> </defs>
); ),
}, [lines]); [lines]
);
return ( return (
<> <>
@@ -127,33 +155,44 @@ const LineChartComponent = ({
<Box fontSize={'sm'} color={'myGray.900'} fontWeight={'medium'}> <Box fontSize={'sm'} color={'myGray.900'} fontWeight={'medium'}>
{title} {title}
</Box> </Box>
{HeaderRightChildren && HeaderRightChildren} <HStack spacing={2}>
{HeaderLeftChildren}
{enableCumulative && (
<FillRowTabs<'incremental' | 'cumulative'>
list={tabList}
py={0.5}
px={2}
value={displayMode}
onChange={setDisplayMode}
/>
)}
</HStack>
</HStack> </HStack>
<ResponsiveContainer width="100%" height={'100%'}> <ResponsiveContainer width="100%" height={'100%'}>
<AreaChart <AreaChart
data={data} data={processedData}
margin={{ top: 5, right: 30, left: 0, bottom: HeaderRightChildren ? 30 : 15 }} margin={{ top: 5, right: 30, left: 0, bottom: HeaderLeftChildren ? 20 : 15 }}
> >
{gradientDefs} {gradientDefs}
<XAxis <XAxis
dataKey={'x'} dataKey="x"
tickMargin={10} tickMargin={10}
tick={{ fontSize: '12px', color: theme.colors.myGray['500'], fontWeight: '500' }} tick={{ fontSize: '12px', color: theme.colors.myGray['500'], fontWeight: '500' }}
interval={'preserveStartEnd'} interval="preserveStartEnd"
/> />
<YAxis <YAxis
axisLine={false} axisLine={false}
tickSize={0} tickSize={0}
tickMargin={10} tickMargin={10}
tick={{ fontSize: '12px', color: theme.colors.myGray['500'], fontWeight: '500' }} tick={{ fontSize: '12px', color: theme.colors.myGray['500'], fontWeight: '500' }}
interval={'preserveStartEnd'} interval="preserveStartEnd"
tickFormatter={formatYAxisNumber} tickFormatter={formatYAxisNumber}
/> />
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} /> <CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} />
{tooltipItems && <Tooltip content={<CustomTooltip tooltipItems={tooltipItems} />} />} {tooltipItems && <Tooltip content={<CustomTooltip tooltipItems={tooltipItems} />} />}
{lines.map((line, index) => ( {lines.map((line, index) => (
<Area <Area
key={index} key={line.dataKey}
type="monotone" type="monotone"
name={line.name} name={line.name}
dataKey={line.dataKey} dataKey={line.dataKey}

View File

@@ -1,7 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import type { BoxProps } from '@chakra-ui/react'; import type { BoxProps } from '@chakra-ui/react';
import { Box, Flex, Grid, HStack, useTheme } from '@chakra-ui/react'; import { Box, Grid, HStack, useTheme } from '@chakra-ui/react';
import { formatNumber } from '@fastgpt/global/common/math/tools';
import MyBox from '@fastgpt/web/components/common/MyBox'; import MyBox from '@fastgpt/web/components/common/MyBox';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
@@ -18,9 +17,11 @@ import { getModelProvider } from '@fastgpt/global/core/ai/provider';
import LineChartComponent from './LineChartComponent'; import LineChartComponent from './LineChartComponent';
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs'; import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useSystemStore } from '@/web/common/system/useSystemStore';
import DataTableComponent from './DataTableComponent';
export type ModelDashboardData = { export type ModelDashboardData = {
x: string; x: string;
xLabel?: string;
totalCalls: number; totalCalls: number;
errorCalls: number; errorCalls: number;
errorRate: number; errorRate: number;
@@ -28,6 +29,10 @@ export type ModelDashboardData = {
outputTokens: number; outputTokens: number;
totalTokens: number; totalTokens: number;
totalCost: number; totalCost: number;
avgResponseTime: number;
avgTtfb: number;
maxRpm: number;
maxTpm: number;
}; };
const ChartsBoxStyles: BoxProps = { const ChartsBoxStyles: BoxProps = {
@@ -40,31 +45,52 @@ const ChartsBoxStyles: BoxProps = {
overflow: 'hidden' overflow: 'hidden'
}; };
// Displays model usage statistics, token consumption and cost visualization // Default date range: Past 7 days
const getDefaultDateRange = (): DateRangeType => {
const from = addDays(new Date(), -7);
from.setHours(0, 0, 0, 0);
const to = new Date();
to.setHours(23, 59, 59, 999);
return { from, to };
};
const calculateTimeDiffs = (from: Date, to: Date) => {
const startDate = dayjs(from);
const endDate = dayjs(to);
return {
daysDiff: endDate.diff(startDate, 'day'),
hoursDiff: endDate.diff(startDate, 'hour')
};
};
const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => { const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const { feConfigs } = useSystemStore(); const { feConfigs } = useSystemStore();
const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart');
// view detail handler
const handleViewDetail = (model: string) => {
setFilterProps({
...filterProps,
model
});
setViewMode('chart');
};
const [filterProps, setFilterProps] = useState<{ const [filterProps, setFilterProps] = useState<{
channelId?: string; channelId?: string;
model?: string; model?: string;
dateRange: DateRangeType; dateRange: DateRangeType;
timespan: 'minute' | 'hour' | 'day';
}>({ }>({
channelId: undefined, channelId: undefined,
model: undefined, model: undefined,
dateRange: { timespan: 'day',
from: (() => { dateRange: getDefaultDateRange()
const today = addDays(new Date(), -7);
today.setHours(0, 0, 0, 0);
return today;
})(),
to: (() => {
const today = new Date();
today.setHours(23, 59, 59, 999);
return today;
})()
}
}); });
// Fetch channel list with "All" option // Fetch channel list with "All" option
@@ -113,85 +139,9 @@ const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => {
...res ...res
]; ];
}, [systemModelList, t]); }, [systemModelList, t]);
// Model price map
// Fetch dashboard data with date range and channel filters const modelPriceMap = useMemo(() => {
const { data: dashboardData = [], loading: isLoading } = useRequest2( const map = new Map<
async () => {
const params = {
channel: filterProps.channelId ? parseInt(filterProps.channelId) : 0,
start_timestamp: filterProps.dateRange.from
? Math.floor(filterProps.dateRange.from.getTime())
: undefined,
end_timestamp: filterProps.dateRange.to
? Math.floor(filterProps.dateRange.to.getTime())
: undefined,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
timespan: 'day' as const
};
let data = await getDashboardV2(params);
if (filterProps.model) {
data = data.map((item) => {
const filterModels = item.models.filter((model) => model.model === filterProps.model);
return {
...item,
models: filterModels
};
});
}
// Auto-fill missing days
if (filterProps.dateRange.from && filterProps.dateRange.to) {
const startDate = dayjs(filterProps.dateRange.from);
const endDate = dayjs(filterProps.dateRange.to);
const daysDiff = endDate.diff(startDate, 'day') + 1;
// Create complete date list
const completeDateList = Array.from({ length: daysDiff }, (_, i) =>
startDate.add(i, 'day')
);
// Create a map of existing data by timestamp
const existingDataMap = new Map(
data.map((item) => [dayjs(item.timestamp * 1000).format('YYYY-MM-DD'), item])
);
// Fill missing days with empty data
const completeData = completeDateList.map((date) => {
const dateKey = date.format('YYYY-MM-DD');
const existingItem = existingDataMap.get(dateKey);
if (existingItem) {
return existingItem;
} else {
// Create empty data structure for missing days
return {
timestamp: Math.floor(date.valueOf() / 1000),
models: []
};
}
});
data = completeData;
}
return data;
},
{
manual: false,
refreshDeps: [filterProps.channelId, filterProps.dateRange, filterProps.model]
}
);
// Process chart data - aggregate daily model calls, token usage and cost data
const chartData: ModelDashboardData[] = useMemo(() => {
if (dashboardData.length === 0) {
return [];
}
// Model price map
const modelPriceMap = new Map<
string, string,
{ {
inputPrice?: number; inputPrice?: number;
@@ -200,24 +150,188 @@ const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => {
} }
>(); >();
systemModelList.forEach((model) => { systemModelList.forEach((model) => {
modelPriceMap.set(model.model, { map.set(model.model, {
inputPrice: model.inputPrice, inputPrice: model.inputPrice,
outputPrice: model.outputPrice, outputPrice: model.outputPrice,
charsPointsPrice: model.charsPointsPrice charsPointsPrice: model.charsPointsPrice
}); });
}); });
return map;
}, [systemModelList]);
const computeTimespan = (daysDiff: number, hoursDiff: number) => {
const options: { label: string; value: 'minute' | 'hour' | 'day' }[] = [];
if (daysDiff <= 1) {
options.push({ label: t('account_model:timespan_minute'), value: 'minute' });
}
if (daysDiff < 7) {
options.push({ label: t('account_model:timespan_hour'), value: 'hour' });
}
if (daysDiff >= 1) {
options.push({ label: t('account_model:timespan_day'), value: 'day' });
}
const defaultTimespan: 'minute' | 'hour' | 'day' = (() => {
if (hoursDiff < 1) {
return 'minute';
} else if (daysDiff < 2) {
return 'hour';
} else {
return 'day';
}
})();
return { options, defaultTimespan };
};
const [timespanOptions, setTimespanOptions] = useState(computeTimespan(30, 60).options);
// Handle date range change with automatic timespan adjustment
const handleDateRangeChange = (dateRange: DateRangeType) => {
const newFilterProps = { ...filterProps, dateRange };
// Computed timespan
if (dateRange.from && dateRange.to) {
const { daysDiff, hoursDiff } = calculateTimeDiffs(dateRange.from, dateRange.to);
const { options: newTimespanOptions, defaultTimespan: newDefaultTimespan } = computeTimespan(
daysDiff,
hoursDiff
);
setTimespanOptions(newTimespanOptions);
newFilterProps.timespan = newDefaultTimespan;
}
setFilterProps(newFilterProps);
};
// Fetch dashboard data with date range and channel filters
const { data: dashboardData = [], loading: isLoading } = useRequest2(
async () => {
const params = {
channel: filterProps.channelId ? parseInt(filterProps.channelId) : undefined,
model: filterProps.model,
start_timestamp: filterProps.dateRange.from
? Math.floor(filterProps.dateRange.from.getTime())
: undefined,
end_timestamp: filterProps.dateRange.to
? Math.floor(filterProps.dateRange.to.getTime())
: undefined,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
timespan: filterProps.timespan
};
const data = await getDashboardV2(params);
// Auto-fill missing periods based on timespan
const startDate = dayjs(filterProps.dateRange.from);
const currentTime = dayjs();
const endDate = dayjs(filterProps.dateRange.to).isBefore(currentTime)
? dayjs(filterProps.dateRange.to)
: currentTime;
const timespan = filterProps.timespan;
const { periodCount } = (() => {
if (timespan === 'minute') {
return {
periodCount: endDate.diff(startDate, 'minute') + 1
};
} else if (timespan === 'hour') {
return {
periodCount: endDate.diff(startDate, 'hour') + 1
};
} else {
return {
periodCount: endDate.diff(startDate, 'day') + 1
};
}
})();
// Create complete period list
const completePeriodList = Array.from({ length: periodCount }, (_, i) =>
startDate.add(i, timespan)
);
// Create a map of existing data by timestamp
const existingDataMap = new Map(
data.map((item) => [dayjs(item.timestamp * 1000).format('YYYY-MM-DD HH:mm'), item])
);
// Fill missing periods with empty data
return completePeriodList.map((period) => {
const periodKey = period.format('YYYY-MM-DD HH:mm');
const existingItem = existingDataMap.get(periodKey);
if (existingItem) {
return existingItem;
} else {
// Create empty data structure for missing periods
return {
timestamp: Math.floor(period.valueOf() / 1000),
summary: []
};
}
});
},
{
manual: false,
refreshDeps: [
filterProps.channelId,
filterProps.dateRange,
filterProps.model,
filterProps.timespan
]
}
);
// Process chart data - aggregate model calls, token usage and cost data based on timespan
const chartData: ModelDashboardData[] = useMemo(() => {
if (dashboardData.length === 0) {
return [];
}
return dashboardData.map((item) => { return dashboardData.map((item) => {
const date = dayjs(item.timestamp * 1000).format('MM-DD'); // Format date based on timespan
const totalCalls = item.models.reduce((acc, model) => acc + model.request_count, 0); const dateFormat = (() => {
const errorCalls = item.models.reduce((acc, model) => acc + model.exception_count, 0); if (filterProps.timespan === 'minute') {
return 'HH:mm';
} else if (filterProps.timespan === 'hour') {
return 'HH:00';
} else {
return 'MM-DD';
}
})();
const date = dayjs(item.timestamp * 1000).format(dateFormat);
const xLabel = dayjs(item.timestamp * 1000).format('YYYY-MM-DD HH:mm');
const summary = item.summary || [];
const totalCalls = summary.reduce((acc, model) => acc + (model.request_count || 0), 0);
const errorCalls = summary.reduce((acc, model) => acc + (model.exception_count || 0), 0);
const errorRate = totalCalls === 0 ? 0 : Number((errorCalls / totalCalls).toFixed(2)); const errorRate = totalCalls === 0 ? 0 : Number((errorCalls / totalCalls).toFixed(2));
const inputTokens = item.models.reduce((acc, model) => acc + (model?.input_tokens || 0), 0); const inputTokens = summary.reduce((acc, model) => acc + (model.input_tokens || 0), 0);
const outputTokens = item.models.reduce((acc, model) => acc + (model?.output_tokens || 0), 0); const outputTokens = summary.reduce((acc, model) => acc + (model?.output_tokens || 0), 0);
const totalTokens = item.models.reduce((acc, model) => acc + (model?.total_tokens || 0), 0); const totalTokens = summary.reduce((acc, model) => acc + (model.total_tokens || 0), 0);
const totalCost = item.models.reduce((acc, model) => { const successCalls = totalCalls - errorCalls;
const avgResponseTime = successCalls
? summary.reduce((acc, model) => acc + (model.total_time_milliseconds || 0), 0) /
successCalls /
1000
: 0;
const avgTtfb = successCalls
? summary.reduce((acc, model) => acc + (model.total_ttfb_milliseconds || 0), 0) /
successCalls /
1000
: 0;
const maxRpm = filterProps.model
? summary.reduce((acc, model) => Math.max(acc, model.max_rpm || 0), 0)
: 0;
const maxTpm = filterProps.model
? summary.reduce((acc, model) => Math.max(acc, model.max_tpm || 0), 0)
: 0;
const totalCost = summary.reduce((acc, model) => {
const modelPricing = modelPriceMap.get(model.model); const modelPricing = modelPriceMap.get(model.model);
if (modelPricing) { if (modelPricing) {
@@ -239,198 +353,346 @@ const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => {
return { return {
x: date, x: date,
xLabel: xLabel,
totalCalls, totalCalls,
errorCalls, errorCalls,
errorRate, errorRate,
inputTokens, inputTokens,
outputTokens, outputTokens,
totalTokens, totalTokens,
totalCost totalCost,
avgResponseTime: Math.round(avgResponseTime * 100) / 100,
avgTtfb: Math.round(avgTtfb * 100) / 100,
maxRpm,
maxTpm
}; };
}); });
}, [dashboardData, systemModelList]); }, [dashboardData, filterProps.model, filterProps.timespan, modelPriceMap]);
const [tokensUsageType, setTokensUsageType] = useState< const [tokensUsageType, setTokensUsageType] = useState<
'inputTokens' | 'outputTokens' | 'totalTokens' 'inputTokens' | 'outputTokens' | 'totalTokens'
>('totalTokens'); >('totalTokens');
console.log(chartData);
return ( return (
<> <>
<Box>{Tab}</Box> <Box>{Tab}</Box>
<HStack spacing={4}> <HStack spacing={4} justifyContent="space-between">
<HStack> <HStack spacing={4}>
<FormLabel>{t('common:user.Time')}</FormLabel> <HStack>
<Box> <FormLabel>{t('common:user.Time')}</FormLabel>
<DateRangePicker <Box>
defaultDate={filterProps.dateRange} <DateRangePicker
dateRange={filterProps.dateRange} defaultDate={filterProps.dateRange}
position="bottom" dateRange={filterProps.dateRange}
onSuccess={(e) => setFilterProps({ ...filterProps, dateRange: e })} position="bottom"
/> onSuccess={handleDateRangeChange}
</Box> />
</HStack> </Box>
<HStack> </HStack>
<FormLabel>{t('account_model:channel_name')}</FormLabel> <HStack>
<Box flex={'1 0 0'}> <FormLabel>{t('account_model:channel_name')}</FormLabel>
<MySelect<string> <Box flex={'1 0 0'}>
bg={'myGray.50'} <MySelect<string>
isSearch bg={'myGray.50'}
list={channelList} isSearch
placeholder={t('account_model:select_channel')} list={channelList}
value={filterProps.channelId} placeholder={t('account_model:select_channel')}
onChange={(val) => setFilterProps({ ...filterProps, channelId: val })} value={filterProps.channelId}
/> onChange={(val) => setFilterProps({ ...filterProps, channelId: val })}
</Box> />
</HStack> </Box>
<HStack> </HStack>
<FormLabel>{t('account_model:model_name')}</FormLabel> <HStack>
<Box flex={'1 0 0'}> <FormLabel>{t('account_model:model_name')}</FormLabel>
<MySelect<string> <Box flex={'1 0 0'}>
bg={'myGray.50'} <MySelect<string>
isSearch bg={'myGray.50'}
list={modelList} isSearch
placeholder={t('account_model:select_model')} list={modelList}
value={filterProps.model} placeholder={t('account_model:select_model')}
onChange={(val) => setFilterProps({ ...filterProps, model: val })} value={filterProps.model}
/> onChange={(val) => setFilterProps({ ...filterProps, model: val })}
</Box> />
</Box>
</HStack>
{viewMode === 'chart' && (
<HStack>
<FormLabel>{t('account_model:timespan_label')}</FormLabel>
<Box flex={'1 0 0'}>
<MySelect<'minute' | 'hour' | 'day'>
bg={'myGray.50'}
list={timespanOptions}
value={filterProps.timespan}
onChange={(val) => {
setFilterProps({ ...filterProps, timespan: val });
}}
/>
</Box>
</HStack>
)}
</HStack> </HStack>
<FillRowTabs<'chart' | 'table'>
list={[
{
label: t('account_model:view_chart'),
value: 'chart'
},
{
label: t('account_model:view_table'),
value: 'table'
}
]}
py={1.5}
px={4}
value={viewMode}
onChange={(val) => setViewMode(val)}
/>
</HStack> </HStack>
<MyBox flex={'1 0 0'} h={0} overflowY={'auto'} isLoading={isLoading}> <MyBox flex={'1 0 0'} h={0} overflowY={'auto'} isLoading={isLoading}>
{dashboardData && dashboardData.length > 0 && ( {viewMode === 'chart' ? (
<> dashboardData.length > 0 && (
<Box {...ChartsBoxStyles}> <>
<LineChartComponent
data={chartData}
title={t('account_model:model_request_times')}
lines={[
{
dataKey: 'totalCalls',
name: t('account_model:model_request_times'),
color: theme.colors.primary['600']
}
]}
tooltipItems={[
{
label: t('account_model:model_request_times'),
dataKey: 'totalCalls',
color: theme.colors.primary['600']
}
]}
/>
</Box>
<Grid mt={5} gridTemplateColumns={['1fr', '1fr 1fr']} gap={5}>
<Box {...ChartsBoxStyles}> <Box {...ChartsBoxStyles}>
<LineChartComponent <LineChartComponent
data={chartData} data={chartData}
title={t('account_model:model_error_request_times')} title={t('account_model:model_request_times')}
enableCumulative={true}
lines={[ lines={[
{ {
dataKey: 'errorCalls', dataKey: 'totalCalls',
name: t('account_model:model_error_request_times'), name: t('account_model:model_request_times'),
color: '#f98e1a' color: theme.colors.primary['600']
} }
]} ]}
tooltipItems={[ tooltipItems={[
{ {
label: t('account_model:model_error_request_times'), label: t('account_model:model_request_times'),
dataKey: 'errorCalls', dataKey: 'totalCalls',
color: '#f98e1a' color: theme.colors.primary['600']
} }
]} ]}
/> />
</Box> </Box>
<Box {...ChartsBoxStyles}>
<LineChartComponent
data={chartData}
title={t('account_model:model_error_rate')}
lines={[
{
dataKey: 'errorRate',
name: t('account_model:model_error_rate'),
color: '#e84738'
}
]}
tooltipItems={[
{
label: t('account_model:model_error_rate'),
dataKey: 'errorRate',
color: '#e84738'
}
]}
/>
</Box>
</Grid>
<Box mt={5} {...ChartsBoxStyles}> <Grid mt={5} gridTemplateColumns={['1fr', '1fr 1fr']} gap={5}>
<LineChartComponent <Box {...ChartsBoxStyles}>
data={chartData} <LineChartComponent
title={t('account_model:dashboard_token_usage')} data={chartData}
lines={[ title={t('account_model:model_error_request_times')}
{ enableCumulative={false}
dataKey: tokensUsageType, lines={[
name: t('account_model:dashboard_token_usage'),
color: theme.colors.primary['600']
}
]}
tooltipItems={[
{
label: t('account_model:dashboard_token_usage'),
dataKey: tokensUsageType,
color: theme.colors.primary['600']
}
]}
HeaderRightChildren={
<FillRowTabs<'inputTokens' | 'outputTokens' | 'totalTokens'>
list={[
{ {
label: t('account_model:all'), dataKey: 'errorCalls',
value: 'totalTokens' name: t('account_model:model_error_request_times'),
}, color: '#f98e1a'
{ }
label: t('account_model:input'), ]}
value: 'inputTokens' tooltipItems={[
}, {
{ label: t('account_model:model_error_request_times'),
label: t('account_model:output'), dataKey: 'errorCalls',
value: 'outputTokens' color: '#f98e1a'
} }
]} ]}
py={1}
px={5}
value={tokensUsageType}
onChange={(val) => setTokensUsageType(val)}
/> />
} </Box>
/> <Box {...ChartsBoxStyles}>
</Box> <LineChartComponent
data={chartData}
title={t('account_model:model_error_rate')}
enableCumulative={false}
lines={[
{
dataKey: 'errorRate',
name: t('account_model:model_error_rate'),
color: '#e84738'
}
]}
tooltipItems={[
{
label: t('account_model:model_error_rate'),
dataKey: 'errorRate',
color: '#e84738'
}
]}
/>
</Box>
</Grid>
{feConfigs?.isPlus && (
<Box mt={5} {...ChartsBoxStyles}> <Box mt={5} {...ChartsBoxStyles}>
<LineChartComponent <LineChartComponent
data={chartData} data={chartData}
title={t('account_model:aipoint_usage')} title={t('account_model:dashboard_token_usage')}
enableCumulative={true}
lines={[ lines={[
{ {
dataKey: 'totalCost', dataKey: tokensUsageType,
name: t('account_model:aipoint_usage'), name: t('account_model:dashboard_token_usage'),
color: '#8774EE' color: theme.colors.primary['600']
} }
]} ]}
tooltipItems={[ tooltipItems={[
{ {
label: t('account_model:aipoint_usage'), label: t('account_model:dashboard_token_usage'),
dataKey: 'totalCost', dataKey: tokensUsageType,
color: '#8774EE' color: theme.colors.primary['600']
} }
]} ]}
HeaderLeftChildren={
<FillRowTabs<'inputTokens' | 'outputTokens' | 'totalTokens'>
list={[
{
label: t('account_model:all'),
value: 'totalTokens'
},
{
label: t('account_model:input'),
value: 'inputTokens'
},
{
label: t('account_model:output'),
value: 'outputTokens'
}
]}
py={0.5}
px={2}
value={tokensUsageType}
onChange={(val) => setTokensUsageType(val)}
/>
}
/> />
</Box> </Box>
)}
</> {feConfigs?.isPlus && (
<Box mt={5} {...ChartsBoxStyles}>
<LineChartComponent
data={chartData}
title={t('account_model:aipoint_usage')}
enableCumulative={true}
lines={[
{
dataKey: 'totalCost',
name: t('account_model:aipoint_usage'),
color: '#8774EE'
}
]}
tooltipItems={[
{
label: t('account_model:aipoint_usage'),
dataKey: 'totalCost',
color: '#8774EE'
}
]}
/>
</Box>
)}
<Grid mt={5} gridTemplateColumns={['1fr', '1fr 1fr']} gap={5}>
<Box {...ChartsBoxStyles}>
<LineChartComponent
data={chartData}
title={t('account_model:avg_response_time')}
enableCumulative={false}
lines={[
{
dataKey: 'avgResponseTime',
name: t('account_model:avg_response_time'),
color: '#36B37E'
}
]}
tooltipItems={[
{
label: t('account_model:avg_response_time'),
dataKey: 'avgResponseTime',
color: '#36B37E',
formatter: (value: number) => `${value.toFixed(2)}s`
}
]}
/>
</Box>
<Box {...ChartsBoxStyles}>
<LineChartComponent
data={chartData}
title={t('account_model:avg_ttfb')}
enableCumulative={false}
lines={[
{
dataKey: 'avgTtfb',
name: t('account_model:avg_ttfb'),
color: '#FF5630'
}
]}
tooltipItems={[
{
label: t('account_model:avg_ttfb'),
dataKey: 'avgTtfb',
color: '#FF5630',
formatter: (value: number) => `${value.toFixed(2)}s`
}
]}
/>
</Box>
</Grid>
{filterProps?.model && (
<Grid mt={5} gridTemplateColumns={['1fr', '1fr 1fr']} gap={5}>
<Box {...ChartsBoxStyles}>
<LineChartComponent
data={chartData}
title={t('account_model:max_rpm')}
enableCumulative={false}
lines={[
{
dataKey: 'maxRpm',
name: t('account_model:max_rpm'),
color: '#6554C0'
}
]}
tooltipItems={[
{
label: t('account_model:max_rpm'),
dataKey: 'maxRpm',
color: '#6554C0'
}
]}
/>
</Box>
<Box {...ChartsBoxStyles}>
<LineChartComponent
data={chartData}
title={t('account_model:max_tpm')}
enableCumulative={false}
lines={[
{
dataKey: 'maxTpm',
name: t('account_model:max_tpm'),
color: '#FF8B00'
}
]}
tooltipItems={[
{
label: t('account_model:max_tpm'),
dataKey: 'maxTpm',
color: '#FF8B00'
}
]}
/>
</Box>
</Grid>
)}
</>
)
) : (
<DataTableComponent
data={dashboardData}
filterProps={filterProps}
channelList={channelList}
modelPriceMap={modelPriceMap}
onViewDetail={handleViewDetail}
/>
)} )}
</MyBox> </MyBox>
</> </>

View File

@@ -191,22 +191,17 @@ export const getLogDetail = (id: number) =>
export const getDashboardV2 = (params: { export const getDashboardV2 = (params: {
channel?: number; channel?: number;
model?: string;
start_timestamp?: number; start_timestamp?: number;
end_timestamp?: number; end_timestamp?: number;
timezone?: string; timezone: string;
timespan?: 'day' | 'hour'; timespan: 'day' | 'hour' | 'minute';
}) => }) =>
GET< GET<
{ {
timestamp: number; timestamp: number;
models: DashboardDataItemType[]; summary: DashboardDataItemType[];
}[] }[]
>('/dashboardv2/', { >('/dashboardv2/', params);
channel: params.channel,
start_timestamp: params.start_timestamp,
end_timestamp: params.end_timestamp,
timezone: params.timezone || 'Local',
timespan: params.timespan || 'day'
});
export { responseSuccess, checkRes, responseError, instance, request }; export { responseSuccess, checkRes, responseError, instance, request };