Skip to content
Open
1 change: 1 addition & 0 deletions common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ var TaskEnabled = true
var DataExportEnabled = true
var DataExportInterval = 5 // unit: minute
var DataExportDefaultTime = "hour" // unit: minute
var DataDashboardChartTabs = "" // comma-separated visible tab keys, empty = all
var DefaultCollapseSidebar = false // default value of collapse sidebar

// Any options with "Secret", "Token" in its key won't be return by GetOptions
Expand Down
1 change: 1 addition & 0 deletions controller/misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ func GetStatus(c *gin.Context) {
"enable_task": common.TaskEnabled,
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"data_dashboard_chart_tabs": common.DataDashboardChartTabs,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
Expand Down
9 changes: 6 additions & 3 deletions controller/usedata.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ func GetAllQuotaDates(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
username := c.Query("username")
dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username)
modelName := c.Query("model_name")
dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username, modelName)
if err != nil {
common.ApiError(c, err)
return
Expand All @@ -30,7 +31,8 @@ func GetAllQuotaDates(c *gin.Context) {
func GetQuotaDatesByUser(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp)
modelName := c.Query("model_name")
dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp, modelName)
if err != nil {
common.ApiError(c, err)
return
Expand All @@ -54,7 +56,8 @@ func GetUserQuotaDates(c *gin.Context) {
})
return
}
dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp)
modelName := c.Query("model_name")
dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp, modelName)
if err != nil {
common.ApiError(c, err)
return
Expand Down
3 changes: 3 additions & 0 deletions model/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ func InitOptionMap() {
common.OptionMap["RetryTimes"] = strconv.Itoa(common.RetryTimes)
common.OptionMap["DataExportInterval"] = strconv.Itoa(common.DataExportInterval)
common.OptionMap["DataExportDefaultTime"] = common.DataExportDefaultTime
common.OptionMap["DataDashboardChartTabs"] = common.DataDashboardChartTabs
common.OptionMap["DefaultCollapseSidebar"] = strconv.FormatBool(common.DefaultCollapseSidebar)
common.OptionMap["MjNotifyEnabled"] = strconv.FormatBool(setting.MjNotifyEnabled)
common.OptionMap["MjAccountFilterEnabled"] = strconv.FormatBool(setting.MjAccountFilterEnabled)
Expand Down Expand Up @@ -499,6 +500,8 @@ func updateOptionMap(key string, value string) (err error) {
common.DataExportInterval, _ = strconv.Atoi(value)
case "DataExportDefaultTime":
common.DataExportDefaultTime = value
case "DataDashboardChartTabs":
common.DataDashboardChartTabs = value
case "ModelRatio":
err = ratio_setting.UpdateModelRatioByJSONString(value)
case "GroupRatio":
Expand Down
42 changes: 28 additions & 14 deletions model/usedata.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,38 +101,52 @@ func increaseQuotaData(userId int, username string, modelName string, count int,
}
}

func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
func GetQuotaDataByUsername(username string, startTime int64, endTime int64, modelName string) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
// 从quota_data表中查询数据
err = DB.Table("quota_data").Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime).Find(&quotaDatas).Error
tx := DB.Table("quota_data").Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime)
if modelName != "" {
tx = tx.Where("model_name = ?", modelName)
}
err = tx.Find(&quotaDatas).Error
return quotaDatas, err
}

func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
func GetQuotaDataByUserId(userId int, startTime int64, endTime int64, modelName string) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
// 从quota_data表中查询数据
err = DB.Table("quota_data").Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime).Find(&quotaDatas).Error
tx := DB.Table("quota_data").Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime)
if modelName != "" {
tx = tx.Where("model_name = ?", modelName)
}
err = tx.Find(&quotaDatas).Error
return quotaDatas, err
}

func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
func GetQuotaDataGroupByUser(startTime int64, endTime int64, modelName string) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
err = DB.Table("quota_data").
tx := DB.Table("quota_data").
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
Where("created_at >= ? and created_at <= ?", startTime, endTime).
Group("username, created_at").
Where("created_at >= ? and created_at <= ?", startTime, endTime)
if modelName != "" {
tx = tx.Where("model_name = ?", modelName)
}
err = tx.Group("username, created_at").
Find(&quotaDatas).Error
return quotaDatas, err
}

func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
func GetAllQuotaDates(startTime int64, endTime int64, username string, modelName string) (quotaData []*QuotaData, err error) {
if username != "" {
return GetQuotaDataByUsername(username, startTime, endTime)
return GetQuotaDataByUsername(username, startTime, endTime, modelName)
}
var quotaDatas []*QuotaData
// 从quota_data表中查询数据
// only select model_name, sum(count) as count, sum(quota) as quota, model_name, created_at from quota_data group by model_name, created_at;
//err = DB.Table("quota_data").Where("created_at >= ? and created_at <= ?", startTime, endTime).Find(&quotaDatas).Error
err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find(&quotaDatas).Error
tx := DB.Table("quota_data").
Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").
Where("created_at >= ? and created_at <= ?", startTime, endTime)
if modelName != "" {
tx = tx.Where("model_name = ?", modelName)
}
err = tx.Group("model_name, created_at").Find(&quotaDatas).Error
return quotaDatas, err
}
172 changes: 141 additions & 31 deletions web/classic/src/components/dashboard/ChartsPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,33 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/

import React from 'react';
import { Card, Tabs, TabPane } from '@douyinfe/semi-ui';
import { PieChart } from 'lucide-react';
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { Card, Tabs, TabPane, Popover, Checkbox, CheckboxGroup, Button } from '@douyinfe/semi-ui';
import { PieChart, Settings } from 'lucide-react';
import { VChart } from '@visactor/react-vchart';
import { ALL_CHART_TABS, CHART_TABS_NONE, STORAGE_KEYS } from '../../constants/dashboard.constants';

// 解析 data_dashboard_chart_tabs 的取值:
// '' → null(未限制,调用方回退到"全部")
// '__none__' → [](管理员显式全部隐藏)
// 'k1,k2' → ['k1', 'k2']
const parseGlobalChartTabs = (value) => {
if (value === CHART_TABS_NONE) return [];
if (value) return value.split(',');
return null;
};

const SPEC_MAP = {
'1': 'spec_line',
'2': 'spec_model_line',
'3': 'spec_pie',
'4': 'spec_rank_bar',
'7': 'spec_token_bar',
'5': 'spec_user_rank',
'6': 'spec_user_trend',
'8': 'spec_user_token_rank',
'9': 'spec_user_token_trend',
};

const ChartsPanel = ({
activeChartTab,
Expand All @@ -29,15 +52,77 @@ const ChartsPanel = ({
spec_model_line,
spec_pie,
spec_rank_bar,
spec_token_bar,
spec_user_rank,
spec_user_trend,
spec_user_token_rank,
spec_user_token_trend,
isAdminUser,
CARD_PROPS,
CHART_CONFIG,
FLEX_CENTER_GAP2,
hasApiInfoPanel,
t,
}) => {
const specs = {
spec_line,
spec_model_line,
spec_pie,
spec_rank_bar,
spec_token_bar,
spec_user_rank,
spec_user_trend,
spec_user_token_rank,
spec_user_token_trend,
};

// ========== Tab 可见性逻辑 ==========
const [userTabs, setUserTabs] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEYS.CHART_TABS_USER);
return saved ? saved.split(',') : null;
});

const visibleTabs = useMemo(() => {
const globalSetting = localStorage.getItem(STORAGE_KEYS.CHART_TABS_GLOBAL) || '';
const globalTabs = parseGlobalChartTabs(globalSetting);
const enabledKeys = userTabs || globalTabs || ALL_CHART_TABS.map((tab) => tab.key);

return ALL_CHART_TABS.filter((tab) => {
if (tab.adminOnly && !isAdminUser) return false;
return enabledKeys.includes(tab.key);
});
}, [userTabs, isAdminUser]);

// 如果当前激活的 tab 不在可见列表里,自动切到第一个
useEffect(() => {
if (visibleTabs.length > 0 && !visibleTabs.find((tab) => tab.key === activeChartTab)) {
setActiveChartTab(visibleTabs[0].key);
}
}, [visibleTabs, activeChartTab, setActiveChartTab]);

const handleUserTabsChange = useCallback((checkedValues) => {
if (checkedValues.length === 0) return;
setUserTabs(checkedValues);
localStorage.setItem(STORAGE_KEYS.CHART_TABS_USER, checkedValues.join(','));
}, []);

const handleResetUserTabs = useCallback(() => {
setUserTabs(null);
localStorage.removeItem(STORAGE_KEYS.CHART_TABS_USER);
}, []);

// 用户偏好设置的可选项:仅受权限限制,不受管理员全局设置限制。
// 优先级:user preference > admin global > show all —— 用户应能覆盖管理员默认,
// 否则当 global 为 __none__ 时 Popover 将无任何可选项,形成死锁。
const availableTabs = useMemo(() => {
return ALL_CHART_TABS.filter((tab) => {
if (tab.adminOnly && !isAdminUser) return false;
return true;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, [isAdminUser]);

const checkedUserTabs = userTabs || visibleTabs.map((tab) => tab.key);

return (
<Card
{...CARD_PROPS}
Expand All @@ -47,46 +132,71 @@ const ChartsPanel = ({
<div className={FLEX_CENTER_GAP2}>
<PieChart size={16} />
{t('模型数据分析')}
<Popover
content={
<div className='p-3' style={{ maxWidth: 320 }}>
<div className='text-sm font-medium mb-2'>{t('图表显示设置')}</div>
<CheckboxGroup
direction='vertical'
value={checkedUserTabs}
onChange={handleUserTabsChange}
>
{availableTabs.map((tab) => (
<Checkbox key={tab.key} value={tab.key}>
{t(tab.label)}
</Checkbox>
))}
</CheckboxGroup>
{userTabs && (
<Button
size='small'
type='tertiary'
className='mt-2'
onClick={handleResetUserTabs}
>
{t('重置为默认')}
</Button>
)}
</div>
}
trigger='click'
position='bottomLeft'
>
<Button
icon={<Settings size={14} />}
size='small'
type='tertiary'
theme='borderless'
aria-label={t('图表显示设置')}
className='text-gray-400 hover:text-gray-600'
/>
</Popover>
</div>
<Tabs
type='slash'
activeKey={activeChartTab}
onChange={setActiveChartTab}
>
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
<TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
)}
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
)}
{visibleTabs.map((tab) => (
<TabPane
key={tab.key}
tab={<span>{t(tab.label)}</span>}
itemKey={tab.key}
/>
))}
</Tabs>
</div>
}
bodyStyle={{ padding: 0 }}
>
<div className='h-96 p-2'>
{activeChartTab === '1' && (
<VChart spec={spec_line} option={CHART_CONFIG} />
)}
{activeChartTab === '2' && (
<VChart spec={spec_model_line} option={CHART_CONFIG} />
)}
{activeChartTab === '3' && (
<VChart spec={spec_pie} option={CHART_CONFIG} />
)}
{activeChartTab === '4' && (
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
)}
{activeChartTab === '5' && isAdminUser && (
<VChart spec={spec_user_rank} option={CHART_CONFIG} />
)}
{activeChartTab === '6' && isAdminUser && (
<VChart spec={spec_user_trend} option={CHART_CONFIG} />
)}
{visibleTabs.map((tab) => {
if (activeChartTab !== tab.key) return null;
const specKey = SPEC_MAP[tab.key];
return (
<VChart key={tab.key} spec={specs[specKey]} option={CHART_CONFIG} />
);
})}
</div>
</Card>
);
Expand Down
3 changes: 3 additions & 0 deletions web/classic/src/components/dashboard/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,11 @@ const Dashboard = () => {
spec_model_line={dashboardCharts.spec_model_line}
spec_pie={dashboardCharts.spec_pie}
spec_rank_bar={dashboardCharts.spec_rank_bar}
spec_token_bar={dashboardCharts.spec_token_bar}
spec_user_rank={dashboardCharts.spec_user_rank}
spec_user_trend={dashboardCharts.spec_user_trend}
spec_user_token_rank={dashboardCharts.spec_user_token_rank}
spec_user_token_trend={dashboardCharts.spec_user_token_trend}
isAdminUser={dashboardData.isAdminUser}
CARD_PROPS={CARD_PROPS}
CHART_CONFIG={CHART_CONFIG}
Expand Down
11 changes: 10 additions & 1 deletion web/classic/src/components/dashboard/modals/SearchModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const SearchModal = ({
<Component {...FORM_FIELD_PROPS} {...props} />
);

const { start_timestamp, end_timestamp, username } = inputs;
const { start_timestamp, end_timestamp, username, model_name } = inputs;

return (
<Modal
Expand Down Expand Up @@ -86,6 +86,15 @@ const SearchModal = ({
handleInputChange(value, 'data_export_default_time'),
})}

{createFormField(Form.Input, {
field: 'model_name',
label: t('模型名称'),
value: model_name,
placeholder: t('可选值'),
name: 'model_name',
onChange: (value) => handleInputChange(value, 'model_name'),
})}

{isAdminUser &&
createFormField(Form.Input, {
field: 'username',
Expand Down
1 change: 1 addition & 0 deletions web/classic/src/components/settings/DashboardSetting.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const DashboardSetting = () => {
DataExportEnabled: false,
DataExportDefaultTime: 'hour',
DataExportInterval: 5,
DataDashboardChartTabs: '',
});

let [loading, setLoading] = useState(false);
Expand Down
Loading