3ea058e0db
Ensure non-admin users cannot enable columns reserved for administrators across the following hooks: * web/src/hooks/usage-logs/useUsageLogsData.js - Force-hide CHANNEL, USERNAME and RETRY columns for non-admins. * web/src/hooks/mj-logs/useMjLogsData.js - Force-hide CHANNEL and SUBMIT_RESULT columns for non-admins. * web/src/hooks/task-logs/useTaskLogsData.js - Force-hide CHANNEL column for non-admins. The checks run when loading column preferences from localStorage, overriding any tampered settings to keep sensitive information hidden from unauthorized users.
626 lines
18 KiB
JavaScript
626 lines
18 KiB
JavaScript
/*
|
|
Copyright (C) 2025 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Modal } from '@douyinfe/semi-ui';
|
|
import {
|
|
API,
|
|
getTodayStartTimestamp,
|
|
isAdmin,
|
|
showError,
|
|
showSuccess,
|
|
timestamp2string,
|
|
renderQuota,
|
|
renderNumber,
|
|
getLogOther,
|
|
copy,
|
|
renderClaudeLogContent,
|
|
renderLogContent,
|
|
renderAudioModelPrice,
|
|
renderClaudeModelPrice,
|
|
renderModelPrice
|
|
} from '../../helpers';
|
|
import { ITEMS_PER_PAGE } from '../../constants';
|
|
import { useTableCompactMode } from '../common/useTableCompactMode';
|
|
|
|
export const useLogsData = () => {
|
|
const { t } = useTranslation();
|
|
|
|
// Define column keys for selection
|
|
const COLUMN_KEYS = {
|
|
TIME: 'time',
|
|
CHANNEL: 'channel',
|
|
USERNAME: 'username',
|
|
TOKEN: 'token',
|
|
GROUP: 'group',
|
|
TYPE: 'type',
|
|
MODEL: 'model',
|
|
USE_TIME: 'use_time',
|
|
PROMPT: 'prompt',
|
|
COMPLETION: 'completion',
|
|
COST: 'cost',
|
|
RETRY: 'retry',
|
|
IP: 'ip',
|
|
DETAILS: 'details',
|
|
};
|
|
|
|
// Basic state
|
|
const [logs, setLogs] = useState([]);
|
|
const [expandData, setExpandData] = useState({});
|
|
const [showStat, setShowStat] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadingStat, setLoadingStat] = useState(false);
|
|
const [activePage, setActivePage] = useState(1);
|
|
const [logCount, setLogCount] = useState(0);
|
|
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
|
const [logType, setLogType] = useState(0);
|
|
|
|
// User and admin
|
|
const isAdminUser = isAdmin();
|
|
|
|
// Statistics state
|
|
const [stat, setStat] = useState({
|
|
quota: 0,
|
|
token: 0,
|
|
});
|
|
|
|
// Form state
|
|
const [formApi, setFormApi] = useState(null);
|
|
let now = new Date();
|
|
const formInitValues = {
|
|
username: '',
|
|
token_name: '',
|
|
model_name: '',
|
|
channel: '',
|
|
group: '',
|
|
dateRange: [
|
|
timestamp2string(getTodayStartTimestamp()),
|
|
timestamp2string(now.getTime() / 1000 + 3600),
|
|
],
|
|
logType: '0',
|
|
};
|
|
|
|
// Column visibility state
|
|
const [visibleColumns, setVisibleColumns] = useState({});
|
|
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
|
|
|
// Compact mode
|
|
const [compactMode, setCompactMode] = useTableCompactMode('logs');
|
|
|
|
// User info modal state
|
|
const [showUserInfo, setShowUserInfoModal] = useState(false);
|
|
const [userInfoData, setUserInfoData] = useState(null);
|
|
|
|
// Load saved column preferences from localStorage
|
|
useEffect(() => {
|
|
const savedColumns = localStorage.getItem('logs-table-columns');
|
|
if (savedColumns) {
|
|
try {
|
|
const parsed = JSON.parse(savedColumns);
|
|
const defaults = getDefaultColumnVisibility();
|
|
const merged = { ...defaults, ...parsed };
|
|
// If not admin, force hide columns only visible to admins
|
|
if (!isAdminUser) {
|
|
merged[COLUMN_KEYS.CHANNEL] = false;
|
|
merged[COLUMN_KEYS.USERNAME] = false;
|
|
merged[COLUMN_KEYS.RETRY] = false;
|
|
}
|
|
setVisibleColumns(merged);
|
|
} catch (e) {
|
|
console.error('Failed to parse saved column preferences', e);
|
|
initDefaultColumns();
|
|
}
|
|
} else {
|
|
initDefaultColumns();
|
|
}
|
|
}, []);
|
|
|
|
// Get default column visibility based on user role
|
|
const getDefaultColumnVisibility = () => {
|
|
return {
|
|
[COLUMN_KEYS.TIME]: true,
|
|
[COLUMN_KEYS.CHANNEL]: isAdminUser,
|
|
[COLUMN_KEYS.USERNAME]: isAdminUser,
|
|
[COLUMN_KEYS.TOKEN]: true,
|
|
[COLUMN_KEYS.GROUP]: true,
|
|
[COLUMN_KEYS.TYPE]: true,
|
|
[COLUMN_KEYS.MODEL]: true,
|
|
[COLUMN_KEYS.USE_TIME]: true,
|
|
[COLUMN_KEYS.PROMPT]: true,
|
|
[COLUMN_KEYS.COMPLETION]: true,
|
|
[COLUMN_KEYS.COST]: true,
|
|
[COLUMN_KEYS.RETRY]: isAdminUser,
|
|
[COLUMN_KEYS.IP]: true,
|
|
[COLUMN_KEYS.DETAILS]: true,
|
|
};
|
|
};
|
|
|
|
// Initialize default column visibility
|
|
const initDefaultColumns = () => {
|
|
const defaults = getDefaultColumnVisibility();
|
|
setVisibleColumns(defaults);
|
|
localStorage.setItem('logs-table-columns', JSON.stringify(defaults));
|
|
};
|
|
|
|
// Handle column visibility change
|
|
const handleColumnVisibilityChange = (columnKey, checked) => {
|
|
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
|
|
setVisibleColumns(updatedColumns);
|
|
};
|
|
|
|
// Handle "Select All" checkbox
|
|
const handleSelectAll = (checked) => {
|
|
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
|
|
const updatedColumns = {};
|
|
|
|
allKeys.forEach((key) => {
|
|
if (
|
|
(key === COLUMN_KEYS.CHANNEL ||
|
|
key === COLUMN_KEYS.USERNAME ||
|
|
key === COLUMN_KEYS.RETRY) &&
|
|
!isAdminUser
|
|
) {
|
|
updatedColumns[key] = false;
|
|
} else {
|
|
updatedColumns[key] = checked;
|
|
}
|
|
});
|
|
|
|
setVisibleColumns(updatedColumns);
|
|
};
|
|
|
|
// Update table when column visibility changes
|
|
useEffect(() => {
|
|
if (Object.keys(visibleColumns).length > 0) {
|
|
localStorage.setItem(
|
|
'logs-table-columns',
|
|
JSON.stringify(visibleColumns),
|
|
);
|
|
}
|
|
}, [visibleColumns]);
|
|
|
|
// 获取表单值的辅助函数,确保所有值都是字符串
|
|
const getFormValues = () => {
|
|
const formValues = formApi ? formApi.getValues() : {};
|
|
|
|
let start_timestamp = timestamp2string(getTodayStartTimestamp());
|
|
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
|
|
|
|
if (
|
|
formValues.dateRange &&
|
|
Array.isArray(formValues.dateRange) &&
|
|
formValues.dateRange.length === 2
|
|
) {
|
|
start_timestamp = formValues.dateRange[0];
|
|
end_timestamp = formValues.dateRange[1];
|
|
}
|
|
|
|
return {
|
|
username: formValues.username || '',
|
|
token_name: formValues.token_name || '',
|
|
model_name: formValues.model_name || '',
|
|
start_timestamp,
|
|
end_timestamp,
|
|
channel: formValues.channel || '',
|
|
group: formValues.group || '',
|
|
logType: formValues.logType ? parseInt(formValues.logType) : 0,
|
|
};
|
|
};
|
|
|
|
// Statistics functions
|
|
const getLogSelfStat = async () => {
|
|
const {
|
|
token_name,
|
|
model_name,
|
|
start_timestamp,
|
|
end_timestamp,
|
|
group,
|
|
logType: formLogType,
|
|
} = getFormValues();
|
|
const currentLogType = formLogType !== undefined ? formLogType : logType;
|
|
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
|
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
|
let url = `/api/log/self/stat?type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
|
|
url = encodeURI(url);
|
|
let res = await API.get(url);
|
|
const { success, message, data } = res.data;
|
|
if (success) {
|
|
setStat(data);
|
|
} else {
|
|
showError(message);
|
|
}
|
|
};
|
|
|
|
const getLogStat = async () => {
|
|
const {
|
|
username,
|
|
token_name,
|
|
model_name,
|
|
start_timestamp,
|
|
end_timestamp,
|
|
channel,
|
|
group,
|
|
logType: formLogType,
|
|
} = getFormValues();
|
|
const currentLogType = formLogType !== undefined ? formLogType : logType;
|
|
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
|
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
|
let url = `/api/log/stat?type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
|
|
url = encodeURI(url);
|
|
let res = await API.get(url);
|
|
const { success, message, data } = res.data;
|
|
if (success) {
|
|
setStat(data);
|
|
} else {
|
|
showError(message);
|
|
}
|
|
};
|
|
|
|
const handleEyeClick = async () => {
|
|
if (loadingStat) {
|
|
return;
|
|
}
|
|
setLoadingStat(true);
|
|
if (isAdminUser) {
|
|
await getLogStat();
|
|
} else {
|
|
await getLogSelfStat();
|
|
}
|
|
setShowStat(true);
|
|
setLoadingStat(false);
|
|
};
|
|
|
|
// User info function
|
|
const showUserInfoFunc = async (userId) => {
|
|
if (!isAdminUser) {
|
|
return;
|
|
}
|
|
const res = await API.get(`/api/user/${userId}`);
|
|
const { success, message, data } = res.data;
|
|
if (success) {
|
|
setUserInfoData(data);
|
|
setShowUserInfoModal(true);
|
|
} else {
|
|
showError(message);
|
|
}
|
|
};
|
|
|
|
// Format logs data
|
|
const setLogsFormat = (logs) => {
|
|
let expandDatesLocal = {};
|
|
for (let i = 0; i < logs.length; i++) {
|
|
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
|
|
logs[i].key = logs[i].id;
|
|
let other = getLogOther(logs[i].other);
|
|
let expandDataLocal = [];
|
|
|
|
if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2)) {
|
|
expandDataLocal.push({
|
|
key: t('渠道信息'),
|
|
value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
|
|
});
|
|
}
|
|
if (other?.ws || other?.audio) {
|
|
expandDataLocal.push({
|
|
key: t('语音输入'),
|
|
value: other.audio_input,
|
|
});
|
|
expandDataLocal.push({
|
|
key: t('语音输出'),
|
|
value: other.audio_output,
|
|
});
|
|
expandDataLocal.push({
|
|
key: t('文字输入'),
|
|
value: other.text_input,
|
|
});
|
|
expandDataLocal.push({
|
|
key: t('文字输出'),
|
|
value: other.text_output,
|
|
});
|
|
}
|
|
if (other?.cache_tokens > 0) {
|
|
expandDataLocal.push({
|
|
key: t('缓存 Tokens'),
|
|
value: other.cache_tokens,
|
|
});
|
|
}
|
|
if (other?.cache_creation_tokens > 0) {
|
|
expandDataLocal.push({
|
|
key: t('缓存创建 Tokens'),
|
|
value: other.cache_creation_tokens,
|
|
});
|
|
}
|
|
if (logs[i].type === 2) {
|
|
expandDataLocal.push({
|
|
key: t('日志详情'),
|
|
value: other?.claude
|
|
? renderClaudeLogContent(
|
|
other?.model_ratio,
|
|
other.completion_ratio,
|
|
other.model_price,
|
|
other.group_ratio,
|
|
other?.user_group_ratio,
|
|
other.cache_ratio || 1.0,
|
|
other.cache_creation_ratio || 1.0,
|
|
)
|
|
: renderLogContent(
|
|
other?.model_ratio,
|
|
other.completion_ratio,
|
|
other.model_price,
|
|
other.group_ratio,
|
|
other?.user_group_ratio,
|
|
false,
|
|
1.0,
|
|
other.web_search || false,
|
|
other.web_search_call_count || 0,
|
|
other.file_search || false,
|
|
other.file_search_call_count || 0,
|
|
),
|
|
});
|
|
}
|
|
if (logs[i].type === 2) {
|
|
let modelMapped =
|
|
other?.is_model_mapped &&
|
|
other?.upstream_model_name &&
|
|
other?.upstream_model_name !== '';
|
|
if (modelMapped) {
|
|
expandDataLocal.push({
|
|
key: t('请求并计费模型'),
|
|
value: logs[i].model_name,
|
|
});
|
|
expandDataLocal.push({
|
|
key: t('实际模型'),
|
|
value: other.upstream_model_name,
|
|
});
|
|
}
|
|
let content = '';
|
|
if (other?.ws || other?.audio) {
|
|
content = renderAudioModelPrice(
|
|
other?.text_input,
|
|
other?.text_output,
|
|
other?.model_ratio,
|
|
other?.model_price,
|
|
other?.completion_ratio,
|
|
other?.audio_input,
|
|
other?.audio_output,
|
|
other?.audio_ratio,
|
|
other?.audio_completion_ratio,
|
|
other?.group_ratio,
|
|
other?.user_group_ratio,
|
|
other?.cache_tokens || 0,
|
|
other?.cache_ratio || 1.0,
|
|
);
|
|
} else if (other?.claude) {
|
|
content = renderClaudeModelPrice(
|
|
logs[i].prompt_tokens,
|
|
logs[i].completion_tokens,
|
|
other.model_ratio,
|
|
other.model_price,
|
|
other.completion_ratio,
|
|
other.group_ratio,
|
|
other?.user_group_ratio,
|
|
other.cache_tokens || 0,
|
|
other.cache_ratio || 1.0,
|
|
other.cache_creation_tokens || 0,
|
|
other.cache_creation_ratio || 1.0,
|
|
);
|
|
} else {
|
|
content = renderModelPrice(
|
|
logs[i].prompt_tokens,
|
|
logs[i].completion_tokens,
|
|
other?.model_ratio,
|
|
other?.model_price,
|
|
other?.completion_ratio,
|
|
other?.group_ratio,
|
|
other?.user_group_ratio,
|
|
other?.cache_tokens || 0,
|
|
other?.cache_ratio || 1.0,
|
|
other?.image || false,
|
|
other?.image_ratio || 0,
|
|
other?.image_output || 0,
|
|
other?.web_search || false,
|
|
other?.web_search_call_count || 0,
|
|
other?.web_search_price || 0,
|
|
other?.file_search || false,
|
|
other?.file_search_call_count || 0,
|
|
other?.file_search_price || 0,
|
|
other?.audio_input_seperate_price || false,
|
|
other?.audio_input_token_count || 0,
|
|
other?.audio_input_price || 0,
|
|
);
|
|
}
|
|
expandDataLocal.push({
|
|
key: t('计费过程'),
|
|
value: content,
|
|
});
|
|
if (other?.reasoning_effort) {
|
|
expandDataLocal.push({
|
|
key: t('Reasoning Effort'),
|
|
value: other.reasoning_effort,
|
|
});
|
|
}
|
|
}
|
|
expandDatesLocal[logs[i].key] = expandDataLocal;
|
|
}
|
|
|
|
setExpandData(expandDatesLocal);
|
|
setLogs(logs);
|
|
};
|
|
|
|
// Load logs function
|
|
const loadLogs = async (startIdx, pageSize, customLogType = null) => {
|
|
setLoading(true);
|
|
|
|
let url = '';
|
|
const {
|
|
username,
|
|
token_name,
|
|
model_name,
|
|
start_timestamp,
|
|
end_timestamp,
|
|
channel,
|
|
group,
|
|
logType: formLogType,
|
|
} = getFormValues();
|
|
|
|
const currentLogType =
|
|
customLogType !== null
|
|
? customLogType
|
|
: formLogType !== undefined
|
|
? formLogType
|
|
: logType;
|
|
|
|
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
|
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
|
if (isAdminUser) {
|
|
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
|
|
} else {
|
|
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
|
|
}
|
|
url = encodeURI(url);
|
|
const res = await API.get(url);
|
|
const { success, message, data } = res.data;
|
|
if (success) {
|
|
const newPageData = data.items;
|
|
setActivePage(data.page);
|
|
setPageSize(data.page_size);
|
|
setLogCount(data.total);
|
|
|
|
setLogsFormat(newPageData);
|
|
} else {
|
|
showError(message);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
// Page handlers
|
|
const handlePageChange = (page) => {
|
|
setActivePage(page);
|
|
loadLogs(page, pageSize).then((r) => { });
|
|
};
|
|
|
|
const handlePageSizeChange = async (size) => {
|
|
localStorage.setItem('page-size', size + '');
|
|
setPageSize(size);
|
|
setActivePage(1);
|
|
loadLogs(activePage, size)
|
|
.then()
|
|
.catch((reason) => {
|
|
showError(reason);
|
|
});
|
|
};
|
|
|
|
// Refresh function
|
|
const refresh = async () => {
|
|
setActivePage(1);
|
|
handleEyeClick();
|
|
await loadLogs(1, pageSize);
|
|
};
|
|
|
|
// Copy text function
|
|
const copyText = async (e, text) => {
|
|
e.stopPropagation();
|
|
if (await copy(text)) {
|
|
showSuccess('已复制:' + text);
|
|
} else {
|
|
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
|
}
|
|
};
|
|
|
|
// Initialize data
|
|
useEffect(() => {
|
|
const localPageSize =
|
|
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
|
setPageSize(localPageSize);
|
|
loadLogs(activePage, localPageSize)
|
|
.then()
|
|
.catch((reason) => {
|
|
showError(reason);
|
|
});
|
|
}, []);
|
|
|
|
// Initialize statistics when formApi is available
|
|
useEffect(() => {
|
|
if (formApi) {
|
|
handleEyeClick();
|
|
}
|
|
}, [formApi]);
|
|
|
|
// Check if any record has expandable content
|
|
const hasExpandableRows = () => {
|
|
return logs.some(
|
|
(log) => expandData[log.key] && expandData[log.key].length > 0,
|
|
);
|
|
};
|
|
|
|
return {
|
|
// Basic state
|
|
logs,
|
|
expandData,
|
|
showStat,
|
|
loading,
|
|
loadingStat,
|
|
activePage,
|
|
logCount,
|
|
pageSize,
|
|
logType,
|
|
stat,
|
|
isAdminUser,
|
|
|
|
// Form state
|
|
formApi,
|
|
setFormApi,
|
|
formInitValues,
|
|
getFormValues,
|
|
|
|
// Column visibility
|
|
visibleColumns,
|
|
showColumnSelector,
|
|
setShowColumnSelector,
|
|
handleColumnVisibilityChange,
|
|
handleSelectAll,
|
|
initDefaultColumns,
|
|
COLUMN_KEYS,
|
|
|
|
// Compact mode
|
|
compactMode,
|
|
setCompactMode,
|
|
|
|
// User info modal
|
|
showUserInfo,
|
|
setShowUserInfoModal,
|
|
userInfoData,
|
|
showUserInfoFunc,
|
|
|
|
// Functions
|
|
loadLogs,
|
|
handlePageChange,
|
|
handlePageSizeChange,
|
|
refresh,
|
|
copyText,
|
|
handleEyeClick,
|
|
setLogsFormat,
|
|
hasExpandableRows,
|
|
setLogType,
|
|
|
|
// Translation
|
|
t,
|
|
};
|
|
};
|