UsageLogsColumnDefs.jsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React from 'react';
  16. import {
  17. Avatar,
  18. Space,
  19. Tag,
  20. Tooltip,
  21. Popover,
  22. Typography,
  23. } from '@douyinfe/semi-ui';
  24. import {
  25. timestamp2string,
  26. renderGroup,
  27. renderQuota,
  28. stringToColor,
  29. getLogOther,
  30. renderModelTag,
  31. renderClaudeLogContent,
  32. renderLogContent,
  33. renderModelPriceSimple,
  34. renderAudioModelPrice,
  35. renderClaudeModelPrice,
  36. renderModelPrice,
  37. } from '../../../helpers';
  38. import { IconHelpCircle } from '@douyinfe/semi-icons';
  39. import { Route } from 'lucide-react';
  40. const colors = [
  41. 'amber',
  42. 'blue',
  43. 'cyan',
  44. 'green',
  45. 'grey',
  46. 'indigo',
  47. 'light-blue',
  48. 'lime',
  49. 'orange',
  50. 'pink',
  51. 'purple',
  52. 'red',
  53. 'teal',
  54. 'violet',
  55. 'yellow',
  56. ];
  57. // Render functions
  58. function renderType(type, t) {
  59. switch (type) {
  60. case 1:
  61. return (
  62. <Tag color='cyan' shape='circle'>
  63. {t('充值')}
  64. </Tag>
  65. );
  66. case 2:
  67. return (
  68. <Tag color='lime' shape='circle'>
  69. {t('消费')}
  70. </Tag>
  71. );
  72. case 3:
  73. return (
  74. <Tag color='orange' shape='circle'>
  75. {t('管理')}
  76. </Tag>
  77. );
  78. case 4:
  79. return (
  80. <Tag color='purple' shape='circle'>
  81. {t('系统')}
  82. </Tag>
  83. );
  84. case 5:
  85. return (
  86. <Tag color='red' shape='circle'>
  87. {t('错误')}
  88. </Tag>
  89. );
  90. default:
  91. return (
  92. <Tag color='grey' shape='circle'>
  93. {t('未知')}
  94. </Tag>
  95. );
  96. }
  97. }
  98. function renderIsStream(bool, t) {
  99. if (bool) {
  100. return (
  101. <Tag color='blue' shape='circle'>
  102. {t('流')}
  103. </Tag>
  104. );
  105. } else {
  106. return (
  107. <Tag color='purple' shape='circle'>
  108. {t('非流')}
  109. </Tag>
  110. );
  111. }
  112. }
  113. function renderUseTime(type, t) {
  114. const time = parseInt(type);
  115. if (time < 101) {
  116. return (
  117. <Tag color='green' shape='circle'>
  118. {' '}
  119. {time} s{' '}
  120. </Tag>
  121. );
  122. } else if (time < 300) {
  123. return (
  124. <Tag color='orange' shape='circle'>
  125. {' '}
  126. {time} s{' '}
  127. </Tag>
  128. );
  129. } else {
  130. return (
  131. <Tag color='red' shape='circle'>
  132. {' '}
  133. {time} s{' '}
  134. </Tag>
  135. );
  136. }
  137. }
  138. function renderFirstUseTime(type, t) {
  139. let time = parseFloat(type) / 1000.0;
  140. time = time.toFixed(1);
  141. if (time < 3) {
  142. return (
  143. <Tag color='green' shape='circle'>
  144. {' '}
  145. {time} s{' '}
  146. </Tag>
  147. );
  148. } else if (time < 10) {
  149. return (
  150. <Tag color='orange' shape='circle'>
  151. {' '}
  152. {time} s{' '}
  153. </Tag>
  154. );
  155. } else {
  156. return (
  157. <Tag color='red' shape='circle'>
  158. {' '}
  159. {time} s{' '}
  160. </Tag>
  161. );
  162. }
  163. }
  164. function renderModelName(record, copyText, t) {
  165. let other = getLogOther(record.other);
  166. let modelMapped =
  167. other?.is_model_mapped &&
  168. other?.upstream_model_name &&
  169. other?.upstream_model_name !== '';
  170. if (!modelMapped) {
  171. return renderModelTag(record.model_name, {
  172. onClick: (event) => {
  173. copyText(event, record.model_name).then((r) => {});
  174. },
  175. });
  176. } else {
  177. return (
  178. <>
  179. <Space vertical align={'start'}>
  180. <Popover
  181. content={
  182. <div style={{ padding: 10 }}>
  183. <Space vertical align={'start'}>
  184. <div className='flex items-center'>
  185. <Typography.Text strong style={{ marginRight: 8 }}>
  186. {t('请求并计费模型')}:
  187. </Typography.Text>
  188. {renderModelTag(record.model_name, {
  189. onClick: (event) => {
  190. copyText(event, record.model_name).then((r) => {});
  191. },
  192. })}
  193. </div>
  194. <div className='flex items-center'>
  195. <Typography.Text strong style={{ marginRight: 8 }}>
  196. {t('实际模型')}:
  197. </Typography.Text>
  198. {renderModelTag(other.upstream_model_name, {
  199. onClick: (event) => {
  200. copyText(event, other.upstream_model_name).then(
  201. (r) => {},
  202. );
  203. },
  204. })}
  205. </div>
  206. </Space>
  207. </div>
  208. }
  209. >
  210. {renderModelTag(record.model_name, {
  211. onClick: (event) => {
  212. copyText(event, record.model_name).then((r) => {});
  213. },
  214. suffixIcon: (
  215. <Route
  216. style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
  217. />
  218. ),
  219. })}
  220. </Popover>
  221. </Space>
  222. </>
  223. );
  224. }
  225. }
  226. export const getLogsColumns = ({
  227. t,
  228. COLUMN_KEYS,
  229. copyText,
  230. showUserInfoFunc,
  231. isAdminUser,
  232. }) => {
  233. return [
  234. {
  235. key: COLUMN_KEYS.TIME,
  236. title: t('时间'),
  237. dataIndex: 'timestamp2string',
  238. },
  239. {
  240. key: COLUMN_KEYS.CHANNEL,
  241. title: t('渠道'),
  242. dataIndex: 'channel',
  243. render: (text, record, index) => {
  244. let isMultiKey = false;
  245. let multiKeyIndex = -1;
  246. let other = getLogOther(record.other);
  247. if (other?.admin_info) {
  248. let adminInfo = other.admin_info;
  249. if (adminInfo?.is_multi_key) {
  250. isMultiKey = true;
  251. multiKeyIndex = adminInfo.multi_key_index;
  252. }
  253. }
  254. return isAdminUser &&
  255. (record.type === 0 || record.type === 2 || record.type === 5) ? (
  256. <Space>
  257. <Tooltip content={record.channel_name || t('未知渠道')}>
  258. <span>
  259. <Tag
  260. color={colors[parseInt(text) % colors.length]}
  261. shape='circle'
  262. >
  263. {text}
  264. </Tag>
  265. </span>
  266. </Tooltip>
  267. {isMultiKey && (
  268. <Tag color='white' shape='circle'>
  269. {multiKeyIndex}
  270. </Tag>
  271. )}
  272. </Space>
  273. ) : null;
  274. },
  275. },
  276. {
  277. key: COLUMN_KEYS.USERNAME,
  278. title: t('用户'),
  279. dataIndex: 'username',
  280. render: (text, record, index) => {
  281. return isAdminUser ? (
  282. <div>
  283. <Avatar
  284. size='extra-small'
  285. color={stringToColor(text)}
  286. style={{ marginRight: 4 }}
  287. onClick={(event) => {
  288. event.stopPropagation();
  289. showUserInfoFunc(record.user_id);
  290. }}
  291. >
  292. {typeof text === 'string' && text.slice(0, 1)}
  293. </Avatar>
  294. {text}
  295. </div>
  296. ) : (
  297. <></>
  298. );
  299. },
  300. },
  301. {
  302. key: COLUMN_KEYS.TOKEN,
  303. title: t('令牌'),
  304. dataIndex: 'token_name',
  305. render: (text, record, index) => {
  306. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  307. <div>
  308. <Tag
  309. color='grey'
  310. shape='circle'
  311. onClick={(event) => {
  312. copyText(event, text);
  313. }}
  314. >
  315. {' '}
  316. {t(text)}{' '}
  317. </Tag>
  318. </div>
  319. ) : (
  320. <></>
  321. );
  322. },
  323. },
  324. {
  325. key: COLUMN_KEYS.GROUP,
  326. title: t('分组'),
  327. dataIndex: 'group',
  328. render: (text, record, index) => {
  329. if (record.type === 0 || record.type === 2 || record.type === 5) {
  330. if (record.group) {
  331. return <>{renderGroup(record.group)}</>;
  332. } else {
  333. let other = null;
  334. try {
  335. other = JSON.parse(record.other);
  336. } catch (e) {
  337. console.error(
  338. `Failed to parse record.other: "${record.other}".`,
  339. e,
  340. );
  341. }
  342. if (other === null) {
  343. return <></>;
  344. }
  345. if (other.group !== undefined) {
  346. return <>{renderGroup(other.group)}</>;
  347. } else {
  348. return <></>;
  349. }
  350. }
  351. } else {
  352. return <></>;
  353. }
  354. },
  355. },
  356. {
  357. key: COLUMN_KEYS.TYPE,
  358. title: t('类型'),
  359. dataIndex: 'type',
  360. render: (text, record, index) => {
  361. return <>{renderType(text, t)}</>;
  362. },
  363. },
  364. {
  365. key: COLUMN_KEYS.MODEL,
  366. title: t('模型'),
  367. dataIndex: 'model_name',
  368. render: (text, record, index) => {
  369. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  370. <>{renderModelName(record, copyText, t)}</>
  371. ) : (
  372. <></>
  373. );
  374. },
  375. },
  376. {
  377. key: COLUMN_KEYS.USE_TIME,
  378. title: t('用时/首字'),
  379. dataIndex: 'use_time',
  380. render: (text, record, index) => {
  381. if (!(record.type === 2 || record.type === 5)) {
  382. return <></>;
  383. }
  384. if (record.is_stream) {
  385. let other = getLogOther(record.other);
  386. return (
  387. <>
  388. <Space>
  389. {renderUseTime(text, t)}
  390. {renderFirstUseTime(other?.frt, t)}
  391. {renderIsStream(record.is_stream, t)}
  392. </Space>
  393. </>
  394. );
  395. } else {
  396. return (
  397. <>
  398. <Space>
  399. {renderUseTime(text, t)}
  400. {renderIsStream(record.is_stream, t)}
  401. </Space>
  402. </>
  403. );
  404. }
  405. },
  406. },
  407. {
  408. key: COLUMN_KEYS.PROMPT,
  409. title: t('输入'),
  410. dataIndex: 'prompt_tokens',
  411. render: (text, record, index) => {
  412. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  413. <>{<span> {text} </span>}</>
  414. ) : (
  415. <></>
  416. );
  417. },
  418. },
  419. {
  420. key: COLUMN_KEYS.COMPLETION,
  421. title: t('输出'),
  422. dataIndex: 'completion_tokens',
  423. render: (text, record, index) => {
  424. return parseInt(text) > 0 &&
  425. (record.type === 0 || record.type === 2 || record.type === 5) ? (
  426. <>{<span> {text} </span>}</>
  427. ) : (
  428. <></>
  429. );
  430. },
  431. },
  432. {
  433. key: COLUMN_KEYS.COST,
  434. title: t('花费'),
  435. dataIndex: 'quota',
  436. render: (text, record, index) => {
  437. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  438. <>{renderQuota(text, 6)}</>
  439. ) : (
  440. <></>
  441. );
  442. },
  443. },
  444. {
  445. key: COLUMN_KEYS.IP,
  446. title: (
  447. <div className='flex items-center gap-1'>
  448. {t('IP')}
  449. <Tooltip
  450. content={t(
  451. '只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录',
  452. )}
  453. >
  454. <IconHelpCircle className='text-gray-400 cursor-help' />
  455. </Tooltip>
  456. </div>
  457. ),
  458. dataIndex: 'ip',
  459. render: (text, record, index) => {
  460. return (record.type === 2 || record.type === 5) && text ? (
  461. <Tooltip content={text}>
  462. <span>
  463. <Tag
  464. color='orange'
  465. shape='circle'
  466. onClick={(event) => {
  467. copyText(event, text);
  468. }}
  469. >
  470. {text}
  471. </Tag>
  472. </span>
  473. </Tooltip>
  474. ) : (
  475. <></>
  476. );
  477. },
  478. },
  479. {
  480. key: COLUMN_KEYS.RETRY,
  481. title: t('重试'),
  482. dataIndex: 'retry',
  483. render: (text, record, index) => {
  484. if (!(record.type === 2 || record.type === 5)) {
  485. return <></>;
  486. }
  487. let content = t('渠道') + `:${record.channel}`;
  488. if (record.other !== '') {
  489. let other = JSON.parse(record.other);
  490. if (other === null) {
  491. return <></>;
  492. }
  493. if (other.admin_info !== undefined) {
  494. if (
  495. other.admin_info.use_channel !== null &&
  496. other.admin_info.use_channel !== undefined &&
  497. other.admin_info.use_channel !== ''
  498. ) {
  499. let useChannel = other.admin_info.use_channel;
  500. let useChannelStr = useChannel.join('->');
  501. content = t('渠道') + `:${useChannelStr}`;
  502. }
  503. }
  504. }
  505. return isAdminUser ? <div>{content}</div> : <></>;
  506. },
  507. },
  508. {
  509. key: COLUMN_KEYS.DETAILS,
  510. title: t('详情'),
  511. dataIndex: 'content',
  512. fixed: 'right',
  513. render: (text, record, index) => {
  514. let other = getLogOther(record.other);
  515. if (other == null || record.type !== 2) {
  516. return (
  517. <Typography.Paragraph
  518. ellipsis={{
  519. rows: 2,
  520. showTooltip: {
  521. type: 'popover',
  522. opts: { style: { width: 240 } },
  523. },
  524. }}
  525. style={{ maxWidth: 240 }}
  526. >
  527. {text}
  528. </Typography.Paragraph>
  529. );
  530. }
  531. let content = other?.claude
  532. ? renderModelPriceSimple(
  533. other.model_ratio,
  534. other.model_price,
  535. other.group_ratio,
  536. other?.user_group_ratio,
  537. other.cache_tokens || 0,
  538. other.cache_ratio || 1.0,
  539. other.cache_creation_tokens || 0,
  540. other.cache_creation_ratio || 1.0,
  541. other.cache_creation_tokens_5m || 0,
  542. other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
  543. other.cache_creation_tokens_1h || 0,
  544. other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
  545. false,
  546. 1.0,
  547. other?.is_system_prompt_overwritten,
  548. 'claude',
  549. )
  550. : renderModelPriceSimple(
  551. other.model_ratio,
  552. other.model_price,
  553. other.group_ratio,
  554. other?.user_group_ratio,
  555. other.cache_tokens || 0,
  556. other.cache_ratio || 1.0,
  557. 0,
  558. 1.0,
  559. 0,
  560. 1.0,
  561. 0,
  562. 1.0,
  563. false,
  564. 1.0,
  565. other?.is_system_prompt_overwritten,
  566. 'openai',
  567. );
  568. return (
  569. <Typography.Paragraph
  570. ellipsis={{
  571. rows: 3,
  572. }}
  573. style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
  574. >
  575. {content}
  576. </Typography.Paragraph>
  577. );
  578. },
  579. },
  580. ];
  581. };