UsageLogsColumnDefs.js 14 KB

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