UsageLogsColumnDefs.jsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  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 && (record.type === 0 || record.type === 2 || record.type === 5) ? (
  255. <Space>
  256. <Tooltip content={record.channel_name || t('未知渠道')}>
  257. <span>
  258. <Tag
  259. color={colors[parseInt(text) % colors.length]}
  260. shape='circle'
  261. >
  262. {text}
  263. </Tag>
  264. </span>
  265. </Tooltip>
  266. {isMultiKey && (
  267. <Tag color='white' shape='circle'>
  268. {multiKeyIndex}
  269. </Tag>
  270. )}
  271. </Space>
  272. ) : null;
  273. },
  274. },
  275. {
  276. key: COLUMN_KEYS.USERNAME,
  277. title: t('用户'),
  278. dataIndex: 'username',
  279. render: (text, record, index) => {
  280. return isAdminUser ? (
  281. <div>
  282. <Avatar
  283. size='extra-small'
  284. color={stringToColor(text)}
  285. style={{ marginRight: 4 }}
  286. onClick={(event) => {
  287. event.stopPropagation();
  288. showUserInfoFunc(record.user_id);
  289. }}
  290. >
  291. {typeof text === 'string' && text.slice(0, 1)}
  292. </Avatar>
  293. {text}
  294. </div>
  295. ) : (
  296. <></>
  297. );
  298. },
  299. },
  300. {
  301. key: COLUMN_KEYS.TOKEN,
  302. title: t('令牌'),
  303. dataIndex: 'token_name',
  304. render: (text, record, index) => {
  305. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  306. <div>
  307. <Tag
  308. color='grey'
  309. shape='circle'
  310. onClick={(event) => {
  311. copyText(event, text);
  312. }}
  313. >
  314. {' '}
  315. {t(text)}{' '}
  316. </Tag>
  317. </div>
  318. ) : (
  319. <></>
  320. );
  321. },
  322. },
  323. {
  324. key: COLUMN_KEYS.GROUP,
  325. title: t('分组'),
  326. dataIndex: 'group',
  327. render: (text, record, index) => {
  328. if (record.type === 0 || record.type === 2 || record.type === 5) {
  329. if (record.group) {
  330. return <>{renderGroup(record.group)}</>;
  331. } else {
  332. let other = null;
  333. try {
  334. other = JSON.parse(record.other);
  335. } catch (e) {
  336. console.error(
  337. `Failed to parse record.other: "${record.other}".`,
  338. e,
  339. );
  340. }
  341. if (other === null) {
  342. return <></>;
  343. }
  344. if (other.group !== undefined) {
  345. return <>{renderGroup(other.group)}</>;
  346. } else {
  347. return <></>;
  348. }
  349. }
  350. } else {
  351. return <></>;
  352. }
  353. },
  354. },
  355. {
  356. key: COLUMN_KEYS.TYPE,
  357. title: t('类型'),
  358. dataIndex: 'type',
  359. render: (text, record, index) => {
  360. return <>{renderType(text, t)}</>;
  361. },
  362. },
  363. {
  364. key: COLUMN_KEYS.MODEL,
  365. title: t('模型'),
  366. dataIndex: 'model_name',
  367. render: (text, record, index) => {
  368. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  369. <>{renderModelName(record, copyText, t)}</>
  370. ) : (
  371. <></>
  372. );
  373. },
  374. },
  375. {
  376. key: COLUMN_KEYS.USE_TIME,
  377. title: t('用时/首字'),
  378. dataIndex: 'use_time',
  379. render: (text, record, index) => {
  380. if (!(record.type === 2 || record.type === 5)) {
  381. return <></>;
  382. }
  383. if (record.is_stream) {
  384. let other = getLogOther(record.other);
  385. return (
  386. <>
  387. <Space>
  388. {renderUseTime(text, t)}
  389. {renderFirstUseTime(other?.frt, t)}
  390. {renderIsStream(record.is_stream, t)}
  391. </Space>
  392. </>
  393. );
  394. } else {
  395. return (
  396. <>
  397. <Space>
  398. {renderUseTime(text, t)}
  399. {renderIsStream(record.is_stream, t)}
  400. </Space>
  401. </>
  402. );
  403. }
  404. },
  405. },
  406. {
  407. key: COLUMN_KEYS.PROMPT,
  408. title: t('提示'),
  409. dataIndex: 'prompt_tokens',
  410. render: (text, record, index) => {
  411. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  412. <>{<span> {text} </span>}</>
  413. ) : (
  414. <></>
  415. );
  416. },
  417. },
  418. {
  419. key: COLUMN_KEYS.COMPLETION,
  420. title: t('补全'),
  421. dataIndex: 'completion_tokens',
  422. render: (text, record, index) => {
  423. return parseInt(text) > 0 &&
  424. (record.type === 0 || record.type === 2 || record.type === 5) ? (
  425. <>{<span> {text} </span>}</>
  426. ) : (
  427. <></>
  428. );
  429. },
  430. },
  431. {
  432. key: COLUMN_KEYS.COST,
  433. title: t('花费'),
  434. dataIndex: 'quota',
  435. render: (text, record, index) => {
  436. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  437. <>{renderQuota(text, 6)}</>
  438. ) : (
  439. <></>
  440. );
  441. },
  442. },
  443. {
  444. key: COLUMN_KEYS.IP,
  445. title: (
  446. <div className="flex items-center gap-1">
  447. {t('IP')}
  448. <Tooltip content={t('只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录')}>
  449. <IconHelpCircle className="text-gray-400 cursor-help" />
  450. </Tooltip>
  451. </div>
  452. ),
  453. dataIndex: 'ip',
  454. render: (text, record, index) => {
  455. return (record.type === 2 || record.type === 5) && text ? (
  456. <Tooltip content={text}>
  457. <span>
  458. <Tag
  459. color='orange'
  460. shape='circle'
  461. onClick={(event) => {
  462. copyText(event, text);
  463. }}
  464. >
  465. {text}
  466. </Tag>
  467. </span>
  468. </Tooltip>
  469. ) : (
  470. <></>
  471. );
  472. },
  473. },
  474. {
  475. key: COLUMN_KEYS.RETRY,
  476. title: t('重试'),
  477. dataIndex: 'retry',
  478. render: (text, record, index) => {
  479. if (!(record.type === 2 || record.type === 5)) {
  480. return <></>;
  481. }
  482. let content = t('渠道') + `:${record.channel}`;
  483. if (record.other !== '') {
  484. let other = JSON.parse(record.other);
  485. if (other === null) {
  486. return <></>;
  487. }
  488. if (other.admin_info !== undefined) {
  489. if (
  490. other.admin_info.use_channel !== null &&
  491. other.admin_info.use_channel !== undefined &&
  492. other.admin_info.use_channel !== ''
  493. ) {
  494. let useChannel = other.admin_info.use_channel;
  495. let useChannelStr = useChannel.join('->');
  496. content = t('渠道') + `:${useChannelStr}`;
  497. }
  498. }
  499. }
  500. return isAdminUser ? <div>{content}</div> : <></>;
  501. },
  502. },
  503. {
  504. key: COLUMN_KEYS.DETAILS,
  505. title: t('详情'),
  506. dataIndex: 'content',
  507. fixed: 'right',
  508. render: (text, record, index) => {
  509. let other = getLogOther(record.other);
  510. if (other == null || record.type !== 2) {
  511. return (
  512. <Typography.Paragraph
  513. ellipsis={{
  514. rows: 2,
  515. showTooltip: {
  516. type: 'popover',
  517. opts: { style: { width: 240 } },
  518. },
  519. }}
  520. style={{ maxWidth: 240 }}
  521. >
  522. {text}
  523. </Typography.Paragraph>
  524. );
  525. }
  526. let content = other?.claude
  527. ? renderModelPriceSimple(
  528. other.model_ratio,
  529. other.model_price,
  530. other.group_ratio,
  531. other?.user_group_ratio,
  532. other.cache_tokens || 0,
  533. other.cache_ratio || 1.0,
  534. other.cache_creation_tokens || 0,
  535. other.cache_creation_ratio || 1.0,
  536. false,
  537. 1.0,
  538. other?.is_system_prompt_overwritten,
  539. 'claude'
  540. )
  541. : renderModelPriceSimple(
  542. other.model_ratio,
  543. other.model_price,
  544. other.group_ratio,
  545. other?.user_group_ratio,
  546. other.cache_tokens || 0,
  547. other.cache_ratio || 1.0,
  548. 0,
  549. 1.0,
  550. false,
  551. 1.0,
  552. other?.is_system_prompt_overwritten,
  553. 'openai'
  554. );
  555. return (
  556. <Typography.Paragraph
  557. ellipsis={{
  558. rows: 3,
  559. }}
  560. style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
  561. >
  562. {content}
  563. </Typography.Paragraph>
  564. );
  565. },
  566. },
  567. ];
  568. };