mirror of
https://github.com/labring/FastGPT.git
synced 2025-07-21 03:35:36 +00:00
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:
@@ -9,8 +9,8 @@ weight: 788
|
||||
|
||||
## 🚀 新增内容
|
||||
|
||||
1. AI proxy 服务,支持以图表形式展示模型调用情况。
|
||||
1. 商业版支持知识库分块时,LLM 进行自动分段识别。
|
||||
1. AI proxy 监控完善,支持以图表/表格形式查看模型调用和性能情况。
|
||||
2. 商业版支持知识库分块时,LLM 进行自动分段识别。
|
||||
|
||||
## ⚙️ 优化
|
||||
|
||||
|
@@ -3,6 +3,8 @@
|
||||
"aipoint_usage": "AI points",
|
||||
"all": "All",
|
||||
"api_key": "API key",
|
||||
"avg_response_time": "Average call time (seconds)",
|
||||
"avg_ttfb": "Average first word duration (seconds)",
|
||||
"azure": "Azure",
|
||||
"base_url": "Base url",
|
||||
"channel_name": "Channel",
|
||||
@@ -14,14 +16,28 @@
|
||||
"channel_status_enabled": "Enable",
|
||||
"channel_status_unknown": "unknown",
|
||||
"channel_type": "Manufacturer",
|
||||
"chart_mode_cumulative": "Cumulative",
|
||||
"chart_mode_incremental": "Incremental",
|
||||
"clear_model": "Clear the model",
|
||||
"confirm_delete_channel": "Confirm the deletion of the [{{name}}] channel?",
|
||||
"copy_model_id_success": "Copyed model id",
|
||||
"create_channel": "Added channels",
|
||||
"dashboard_call_trend": "Model Call Trend",
|
||||
"dashboard_channel": "Channel",
|
||||
"dashboard_cost_trend": "Cost Consumption",
|
||||
"dashboard_error_calls": "Error Calls",
|
||||
"dashboard_input_tokens": "Input Tokens",
|
||||
"dashboard_model": "Model",
|
||||
"dashboard_no_data": "No data available",
|
||||
"dashboard_output_tokens": "Output Tokens",
|
||||
"dashboard_points": "points",
|
||||
"dashboard_success_calls": "Success Calls",
|
||||
"dashboard_token_trend": "Token Usage Trend",
|
||||
"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",
|
||||
"detail": "Detail",
|
||||
"duration": "Duration",
|
||||
@@ -38,7 +54,9 @@
|
||||
"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.",
|
||||
"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_tpm": "Max TPM (Tokens Per Minute)",
|
||||
"model": "Model",
|
||||
"model_error_rate": "Error rate",
|
||||
"model_error_request_times": "Number of failures",
|
||||
@@ -60,7 +78,15 @@
|
||||
"selected_model_empty": "Choose at least one model",
|
||||
"start_test": "Batch test {{num}} models",
|
||||
"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_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"
|
||||
}
|
||||
|
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"Hunyuan": "腾讯混元",
|
||||
"aipoint_usage": "积分消耗",
|
||||
"all": "全部",
|
||||
"api_key": "API 密钥",
|
||||
"avg_response_time": "平均调用时长 (秒)",
|
||||
"avg_ttfb": "平均首字时长 (秒)",
|
||||
"azure": "微软 Azure",
|
||||
"base_url": "代理地址",
|
||||
"channel_name": "渠道名",
|
||||
@@ -13,15 +16,28 @@
|
||||
"channel_status_enabled": "启用",
|
||||
"channel_status_unknown": "未知",
|
||||
"channel_type": "厂商",
|
||||
"chart_mode_cumulative": "累积",
|
||||
"chart_mode_incremental": "分时",
|
||||
"clear_model": "清空模型",
|
||||
"confirm_delete_channel": "确认删除 【{{name}}】渠道?",
|
||||
"copy_model_id_success": "已复制模型id",
|
||||
"create_channel": "新增渠道",
|
||||
"aipoint_usage": "积分消耗",
|
||||
"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_total_calls": "总调用次数:",
|
||||
"dashboard_total_cost": "总成本",
|
||||
"dashboard_total_cost_label": "总成本:",
|
||||
"dashboard_total_tokens": "总Tokens",
|
||||
"default_url": "默认地址",
|
||||
"detail": "详情",
|
||||
"duration": "耗时",
|
||||
@@ -38,7 +54,9 @@
|
||||
"mapping": "模型映射",
|
||||
"mapping_tip": "需填写一个有效 Json。可在向实际地址发送请求时,对模型进行映射。例如:\n{\n \"gpt-4o\": \"gpt-4o-test\"\n}\n当 FastGPT 请求 gpt-4o 模型时,会向实际地址发送 gpt-4o-test 的模型,而不是 gpt-4o。",
|
||||
"maxToken_tip": "模型 max_tokens 参数",
|
||||
"max_rpm": "最大RPM (每分钟请求数)",
|
||||
"max_temperature_tip": "模型 temperature 参数,不填则代表模型不支持 temperature 参数。",
|
||||
"max_tpm": "最大TPM (每分钟Token数)",
|
||||
"model": "模型",
|
||||
"model_error_rate": "失败率",
|
||||
"model_error_request_times": "失败次数",
|
||||
@@ -60,7 +78,15 @@
|
||||
"selected_model_empty": "至少选择一个模型",
|
||||
"start_test": "批量测试{{num}}个模型",
|
||||
"test_failed": "有{{num}}个模型报错",
|
||||
"timespan_day": "天",
|
||||
"timespan_hour": "小时",
|
||||
"timespan_label": "时间颗粒度",
|
||||
"timespan_minute": "分钟",
|
||||
"total_call_volume": "调用总量",
|
||||
"view_chart": "图表",
|
||||
"view_table": "表格",
|
||||
"vlm_model": "图片理解模型",
|
||||
"vlm_model_tip": "用于知识库中对文档中的图片进行额外的索引生成",
|
||||
"volunme_of_failed_calls": "调用失败量",
|
||||
"waiting_test": "等待测试"
|
||||
}
|
||||
|
@@ -3,6 +3,8 @@
|
||||
"aipoint_usage": "積分消耗",
|
||||
"all": "全部",
|
||||
"api_key": "API 金鑰",
|
||||
"avg_response_time": "平均調用時長 (秒)",
|
||||
"avg_ttfb": "平均首字時長 (秒)",
|
||||
"azure": "Azure",
|
||||
"base_url": "代理地址",
|
||||
"channel_name": "管道名稱",
|
||||
@@ -14,11 +16,28 @@
|
||||
"channel_status_enabled": "啟用",
|
||||
"channel_status_unknown": "未知",
|
||||
"channel_type": "廠商",
|
||||
"chart_mode_cumulative": "累積",
|
||||
"chart_mode_incremental": "分時",
|
||||
"clear_model": "清空模型",
|
||||
"confirm_delete_channel": "確認刪除【{{name}}】管道?",
|
||||
"copy_model_id_success": "已復制模型 id",
|
||||
"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_total_calls": "總呼叫次數:",
|
||||
"dashboard_total_cost": "總成本",
|
||||
"dashboard_total_cost_label": "總成本:",
|
||||
"dashboard_total_tokens": "總Tokens",
|
||||
"default_url": "預設地址",
|
||||
"detail": "詳細資訊",
|
||||
"duration": "耗時",
|
||||
@@ -35,7 +54,9 @@
|
||||
"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。",
|
||||
"maxToken_tip": "模型 max_tokens 參數",
|
||||
"max_rpm": "最大RPM (每分鐘請求數)",
|
||||
"max_temperature_tip": "模型 temperature 參數,不填則代表模型不支援 temperature 參數。",
|
||||
"max_tpm": "最大TPM (每分鐘Token數)",
|
||||
"model": "模型",
|
||||
"model_error_rate": "失敗率",
|
||||
"model_error_request_times": "失敗次數",
|
||||
@@ -57,7 +78,15 @@
|
||||
"selected_model_empty": "至少選擇一個模型",
|
||||
"start_test": "批次測試{{num}}個模型",
|
||||
"test_failed": "有{{num}}個模型報錯",
|
||||
"timespan_day": "天",
|
||||
"timespan_hour": "小時",
|
||||
"timespan_label": "時間顆粒度",
|
||||
"timespan_minute": "分鐘",
|
||||
"total_call_volume": "調用總量",
|
||||
"view_chart": "圖表",
|
||||
"view_table": "表格",
|
||||
"vlm_model": "圖片理解模型",
|
||||
"vlm_model_tip": "用於知識庫中對文件中的圖片進行額外的索引生成",
|
||||
"volunme_of_failed_calls": "調用失敗量",
|
||||
"waiting_test": "等待測試"
|
||||
}
|
||||
|
11
projects/app/src/global/aiproxy/type.d.ts
vendored
11
projects/app/src/global/aiproxy/type.d.ts
vendored
@@ -54,11 +54,16 @@ export type ChannelLogListItemType = {
|
||||
};
|
||||
|
||||
export type DashboardDataItemType = {
|
||||
channel_id?: number;
|
||||
model: string;
|
||||
request_count: number;
|
||||
used_amount: number;
|
||||
exception_count: number;
|
||||
request_count?: number;
|
||||
used_amount?: number;
|
||||
exception_count?: number;
|
||||
total_time_milliseconds?: number;
|
||||
total_ttfb_milliseconds?: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
max_rpm?: number;
|
||||
max_tpm?: number;
|
||||
};
|
||||
|
@@ -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;
|
@@ -12,10 +12,9 @@ import {
|
||||
} from 'recharts';
|
||||
import { type NameType, type ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||
import { formatNumber } from '@fastgpt/global/common/math/tools';
|
||||
|
||||
type XAxisConfig = {
|
||||
dataKey: string;
|
||||
};
|
||||
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
type LineConfig = {
|
||||
dataKey: string;
|
||||
@@ -29,15 +28,16 @@ type TooltipItem = {
|
||||
dataKey: string;
|
||||
color: string;
|
||||
formatter?: (value: number) => string;
|
||||
customValue?: (data: any) => number;
|
||||
customValue?: (data: Record<string, any>) => number;
|
||||
};
|
||||
|
||||
type LineChartComponentProps = {
|
||||
data: Record<string, any>[];
|
||||
title: string;
|
||||
HeaderRightChildren?: React.ReactNode;
|
||||
HeaderLeftChildren?: React.ReactNode;
|
||||
lines: LineConfig[];
|
||||
tooltipItems?: TooltipItem[];
|
||||
enableCumulative?: boolean;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({
|
||||
@@ -47,48 +47,51 @@ const CustomTooltip = ({
|
||||
}: TooltipProps<ValueType, NameType> & { tooltipItems?: TooltipItem[] }) => {
|
||||
const data = payload?.[0]?.payload;
|
||||
|
||||
if (active && data && tooltipItems) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
if (!active || !data || !tooltipItems) {
|
||||
return null;
|
||||
}
|
||||
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 = ({
|
||||
data,
|
||||
title,
|
||||
HeaderRightChildren,
|
||||
HeaderLeftChildren,
|
||||
lines,
|
||||
tooltipItems
|
||||
tooltipItems,
|
||||
enableCumulative = true
|
||||
}: LineChartComponentProps) => {
|
||||
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
|
||||
const formatYAxisNumber = useCallback((value: number): string => {
|
||||
@@ -100,11 +103,35 @@ const LineChartComponent = ({
|
||||
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
|
||||
const gradientDefs = useMemo(() => {
|
||||
return (
|
||||
const gradientDefs = useMemo(
|
||||
() => (
|
||||
<defs>
|
||||
{lines.map((line, index) => (
|
||||
{lines.map((line) => (
|
||||
<linearGradient
|
||||
key={`gradient-${line.color}`}
|
||||
id={`gradient-${line.color}`}
|
||||
@@ -118,8 +145,9 @@ const LineChartComponent = ({
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
);
|
||||
}, [lines]);
|
||||
),
|
||||
[lines]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -127,33 +155,44 @@ const LineChartComponent = ({
|
||||
<Box fontSize={'sm'} color={'myGray.900'} fontWeight={'medium'}>
|
||||
{title}
|
||||
</Box>
|
||||
{HeaderRightChildren && HeaderRightChildren}
|
||||
<HStack spacing={2}>
|
||||
{HeaderLeftChildren}
|
||||
{enableCumulative && (
|
||||
<FillRowTabs<'incremental' | 'cumulative'>
|
||||
list={tabList}
|
||||
py={0.5}
|
||||
px={2}
|
||||
value={displayMode}
|
||||
onChange={setDisplayMode}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
<ResponsiveContainer width="100%" height={'100%'}>
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 30, left: 0, bottom: HeaderRightChildren ? 30 : 15 }}
|
||||
data={processedData}
|
||||
margin={{ top: 5, right: 30, left: 0, bottom: HeaderLeftChildren ? 20 : 15 }}
|
||||
>
|
||||
{gradientDefs}
|
||||
<XAxis
|
||||
dataKey={'x'}
|
||||
dataKey="x"
|
||||
tickMargin={10}
|
||||
tick={{ fontSize: '12px', color: theme.colors.myGray['500'], fontWeight: '500' }}
|
||||
interval={'preserveStartEnd'}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickSize={0}
|
||||
tickMargin={10}
|
||||
tick={{ fontSize: '12px', color: theme.colors.myGray['500'], fontWeight: '500' }}
|
||||
interval={'preserveStartEnd'}
|
||||
interval="preserveStartEnd"
|
||||
tickFormatter={formatYAxisNumber}
|
||||
/>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} />
|
||||
{tooltipItems && <Tooltip content={<CustomTooltip tooltipItems={tooltipItems} />} />}
|
||||
{lines.map((line, index) => (
|
||||
<Area
|
||||
key={index}
|
||||
key={line.dataKey}
|
||||
type="monotone"
|
||||
name={line.name}
|
||||
dataKey={line.dataKey}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { BoxProps } from '@chakra-ui/react';
|
||||
import { Box, Flex, Grid, HStack, useTheme } from '@chakra-ui/react';
|
||||
import { formatNumber } from '@fastgpt/global/common/math/tools';
|
||||
import { Box, Grid, HStack, useTheme } from '@chakra-ui/react';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
@@ -18,9 +17,11 @@ import { getModelProvider } from '@fastgpt/global/core/ai/provider';
|
||||
import LineChartComponent from './LineChartComponent';
|
||||
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import DataTableComponent from './DataTableComponent';
|
||||
|
||||
export type ModelDashboardData = {
|
||||
x: string;
|
||||
xLabel?: string;
|
||||
totalCalls: number;
|
||||
errorCalls: number;
|
||||
errorRate: number;
|
||||
@@ -28,6 +29,10 @@ export type ModelDashboardData = {
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
avgResponseTime: number;
|
||||
avgTtfb: number;
|
||||
maxRpm: number;
|
||||
maxTpm: number;
|
||||
};
|
||||
|
||||
const ChartsBoxStyles: BoxProps = {
|
||||
@@ -40,31 +45,52 @@ const ChartsBoxStyles: BoxProps = {
|
||||
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 { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
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<{
|
||||
channelId?: string;
|
||||
model?: string;
|
||||
dateRange: DateRangeType;
|
||||
timespan: 'minute' | 'hour' | 'day';
|
||||
}>({
|
||||
channelId: undefined,
|
||||
model: undefined,
|
||||
dateRange: {
|
||||
from: (() => {
|
||||
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;
|
||||
})()
|
||||
}
|
||||
timespan: 'day',
|
||||
dateRange: getDefaultDateRange()
|
||||
});
|
||||
|
||||
// Fetch channel list with "All" option
|
||||
@@ -113,85 +139,9 @@ const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => {
|
||||
...res
|
||||
];
|
||||
}, [systemModelList, t]);
|
||||
|
||||
// Fetch dashboard data with date range and channel filters
|
||||
const { data: dashboardData = [], loading: isLoading } = useRequest2(
|
||||
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<
|
||||
// Model price map
|
||||
const modelPriceMap = useMemo(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{
|
||||
inputPrice?: number;
|
||||
@@ -200,24 +150,188 @@ const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => {
|
||||
}
|
||||
>();
|
||||
systemModelList.forEach((model) => {
|
||||
modelPriceMap.set(model.model, {
|
||||
map.set(model.model, {
|
||||
inputPrice: model.inputPrice,
|
||||
outputPrice: model.outputPrice,
|
||||
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) => {
|
||||
const date = dayjs(item.timestamp * 1000).format('MM-DD');
|
||||
const totalCalls = item.models.reduce((acc, model) => acc + model.request_count, 0);
|
||||
const errorCalls = item.models.reduce((acc, model) => acc + model.exception_count, 0);
|
||||
// Format date based on timespan
|
||||
const dateFormat = (() => {
|
||||
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 inputTokens = item.models.reduce((acc, model) => acc + (model?.input_tokens || 0), 0);
|
||||
const outputTokens = item.models.reduce((acc, model) => acc + (model?.output_tokens || 0), 0);
|
||||
const totalTokens = item.models.reduce((acc, model) => acc + (model?.total_tokens || 0), 0);
|
||||
const inputTokens = summary.reduce((acc, model) => acc + (model.input_tokens || 0), 0);
|
||||
const outputTokens = summary.reduce((acc, model) => acc + (model?.output_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);
|
||||
|
||||
if (modelPricing) {
|
||||
@@ -239,198 +353,346 @@ const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => {
|
||||
|
||||
return {
|
||||
x: date,
|
||||
xLabel: xLabel,
|
||||
totalCalls,
|
||||
errorCalls,
|
||||
errorRate,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
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<
|
||||
'inputTokens' | 'outputTokens' | 'totalTokens'
|
||||
>('totalTokens');
|
||||
console.log(chartData);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>{Tab}</Box>
|
||||
|
||||
<HStack spacing={4}>
|
||||
<HStack>
|
||||
<FormLabel>{t('common:user.Time')}</FormLabel>
|
||||
<Box>
|
||||
<DateRangePicker
|
||||
defaultDate={filterProps.dateRange}
|
||||
dateRange={filterProps.dateRange}
|
||||
position="bottom"
|
||||
onSuccess={(e) => setFilterProps({ ...filterProps, dateRange: e })}
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<FormLabel>{t('account_model:channel_name')}</FormLabel>
|
||||
<Box flex={'1 0 0'}>
|
||||
<MySelect<string>
|
||||
bg={'myGray.50'}
|
||||
isSearch
|
||||
list={channelList}
|
||||
placeholder={t('account_model:select_channel')}
|
||||
value={filterProps.channelId}
|
||||
onChange={(val) => setFilterProps({ ...filterProps, channelId: val })}
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<FormLabel>{t('account_model:model_name')}</FormLabel>
|
||||
<Box flex={'1 0 0'}>
|
||||
<MySelect<string>
|
||||
bg={'myGray.50'}
|
||||
isSearch
|
||||
list={modelList}
|
||||
placeholder={t('account_model:select_model')}
|
||||
value={filterProps.model}
|
||||
onChange={(val) => setFilterProps({ ...filterProps, model: val })}
|
||||
/>
|
||||
</Box>
|
||||
<HStack spacing={4} justifyContent="space-between">
|
||||
<HStack spacing={4}>
|
||||
<HStack>
|
||||
<FormLabel>{t('common:user.Time')}</FormLabel>
|
||||
<Box>
|
||||
<DateRangePicker
|
||||
defaultDate={filterProps.dateRange}
|
||||
dateRange={filterProps.dateRange}
|
||||
position="bottom"
|
||||
onSuccess={handleDateRangeChange}
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<FormLabel>{t('account_model:channel_name')}</FormLabel>
|
||||
<Box flex={'1 0 0'}>
|
||||
<MySelect<string>
|
||||
bg={'myGray.50'}
|
||||
isSearch
|
||||
list={channelList}
|
||||
placeholder={t('account_model:select_channel')}
|
||||
value={filterProps.channelId}
|
||||
onChange={(val) => setFilterProps({ ...filterProps, channelId: val })}
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<FormLabel>{t('account_model:model_name')}</FormLabel>
|
||||
<Box flex={'1 0 0'}>
|
||||
<MySelect<string>
|
||||
bg={'myGray.50'}
|
||||
isSearch
|
||||
list={modelList}
|
||||
placeholder={t('account_model:select_model')}
|
||||
value={filterProps.model}
|
||||
onChange={(val) => setFilterProps({ ...filterProps, model: val })}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<MyBox flex={'1 0 0'} h={0} overflowY={'auto'} isLoading={isLoading}>
|
||||
{dashboardData && 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}>
|
||||
{viewMode === 'chart' ? (
|
||||
dashboardData.length > 0 && (
|
||||
<>
|
||||
<Box {...ChartsBoxStyles}>
|
||||
<LineChartComponent
|
||||
data={chartData}
|
||||
title={t('account_model:model_error_request_times')}
|
||||
title={t('account_model:model_request_times')}
|
||||
enableCumulative={true}
|
||||
lines={[
|
||||
{
|
||||
dataKey: 'errorCalls',
|
||||
name: t('account_model:model_error_request_times'),
|
||||
color: '#f98e1a'
|
||||
dataKey: 'totalCalls',
|
||||
name: t('account_model:model_request_times'),
|
||||
color: theme.colors.primary['600']
|
||||
}
|
||||
]}
|
||||
tooltipItems={[
|
||||
{
|
||||
label: t('account_model:model_error_request_times'),
|
||||
dataKey: 'errorCalls',
|
||||
color: '#f98e1a'
|
||||
label: t('account_model:model_request_times'),
|
||||
dataKey: 'totalCalls',
|
||||
color: theme.colors.primary['600']
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</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}>
|
||||
<LineChartComponent
|
||||
data={chartData}
|
||||
title={t('account_model:dashboard_token_usage')}
|
||||
lines={[
|
||||
{
|
||||
dataKey: tokensUsageType,
|
||||
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={[
|
||||
<Grid mt={5} gridTemplateColumns={['1fr', '1fr 1fr']} gap={5}>
|
||||
<Box {...ChartsBoxStyles}>
|
||||
<LineChartComponent
|
||||
data={chartData}
|
||||
title={t('account_model:model_error_request_times')}
|
||||
enableCumulative={false}
|
||||
lines={[
|
||||
{
|
||||
label: t('account_model:all'),
|
||||
value: 'totalTokens'
|
||||
},
|
||||
{
|
||||
label: t('account_model:input'),
|
||||
value: 'inputTokens'
|
||||
},
|
||||
{
|
||||
label: t('account_model:output'),
|
||||
value: 'outputTokens'
|
||||
dataKey: 'errorCalls',
|
||||
name: t('account_model:model_error_request_times'),
|
||||
color: '#f98e1a'
|
||||
}
|
||||
]}
|
||||
tooltipItems={[
|
||||
{
|
||||
label: t('account_model:model_error_request_times'),
|
||||
dataKey: 'errorCalls',
|
||||
color: '#f98e1a'
|
||||
}
|
||||
]}
|
||||
py={1}
|
||||
px={5}
|
||||
value={tokensUsageType}
|
||||
onChange={(val) => setTokensUsageType(val)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box {...ChartsBoxStyles}>
|
||||
<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}>
|
||||
<LineChartComponent
|
||||
data={chartData}
|
||||
title={t('account_model:aipoint_usage')}
|
||||
title={t('account_model:dashboard_token_usage')}
|
||||
enableCumulative={true}
|
||||
lines={[
|
||||
{
|
||||
dataKey: 'totalCost',
|
||||
name: t('account_model:aipoint_usage'),
|
||||
color: '#8774EE'
|
||||
dataKey: tokensUsageType,
|
||||
name: t('account_model:dashboard_token_usage'),
|
||||
color: theme.colors.primary['600']
|
||||
}
|
||||
]}
|
||||
tooltipItems={[
|
||||
{
|
||||
label: t('account_model:aipoint_usage'),
|
||||
dataKey: 'totalCost',
|
||||
color: '#8774EE'
|
||||
label: t('account_model:dashboard_token_usage'),
|
||||
dataKey: tokensUsageType,
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
|
||||
{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>
|
||||
</>
|
||||
|
@@ -191,22 +191,17 @@ export const getLogDetail = (id: number) =>
|
||||
|
||||
export const getDashboardV2 = (params: {
|
||||
channel?: number;
|
||||
model?: string;
|
||||
start_timestamp?: number;
|
||||
end_timestamp?: number;
|
||||
timezone?: string;
|
||||
timespan?: 'day' | 'hour';
|
||||
timezone: string;
|
||||
timespan: 'day' | 'hour' | 'minute';
|
||||
}) =>
|
||||
GET<
|
||||
{
|
||||
timestamp: number;
|
||||
models: DashboardDataItemType[];
|
||||
summary: DashboardDataItemType[];
|
||||
}[]
|
||||
>('/dashboardv2/', {
|
||||
channel: params.channel,
|
||||
start_timestamp: params.start_timestamp,
|
||||
end_timestamp: params.end_timestamp,
|
||||
timezone: params.timezone || 'Local',
|
||||
timespan: params.timespan || 'day'
|
||||
});
|
||||
>('/dashboardv2/', params);
|
||||
|
||||
export { responseSuccess, checkRes, responseError, instance, request };
|
||||
|
Reference in New Issue
Block a user