UsageLogsColumnDefs.jsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735
  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. Button,
  19. Space,
  20. Tag,
  21. Tooltip,
  22. Popover,
  23. Typography,
  24. } from '@douyinfe/semi-ui';
  25. import {
  26. timestamp2string,
  27. renderGroup,
  28. renderQuota,
  29. stringToColor,
  30. getLogOther,
  31. renderModelTag,
  32. renderClaudeLogContent,
  33. renderLogContent,
  34. renderModelPriceSimple,
  35. renderAudioModelPrice,
  36. renderClaudeModelPrice,
  37. renderModelPrice,
  38. } from '../../../helpers';
  39. import { IconHelpCircle, IconStarStroked } 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. function formatRatio(ratio) {
  59. if (ratio === undefined || ratio === null) {
  60. return '-';
  61. }
  62. if (typeof ratio === 'number') {
  63. return ratio.toFixed(4);
  64. }
  65. return String(ratio);
  66. }
  67. function buildChannelAffinityTooltip(affinity, t) {
  68. if (!affinity) {
  69. return null;
  70. }
  71. const keySource = affinity.key_source || '-';
  72. const keyPath = affinity.key_path || affinity.key_key || '-';
  73. const keyHint = affinity.key_hint || '';
  74. const keyFp = affinity.key_fp ? `#${affinity.key_fp}` : '';
  75. const keyText = `${keySource}:${keyPath}${keyFp}`;
  76. const lines = [
  77. t('渠道亲和性'),
  78. `${t('规则')}:${affinity.rule_name || '-'}`,
  79. `${t('分组')}:${affinity.selected_group || '-'}`,
  80. `${t('Key')}:${keyText}`,
  81. ...(keyHint ? [`${t('Key 摘要')}:${keyHint}`] : []),
  82. ];
  83. return (
  84. <div style={{ lineHeight: 1.6, display: 'flex', flexDirection: 'column' }}>
  85. {lines.map((line, i) => (
  86. <div key={i}>{line}</div>
  87. ))}
  88. </div>
  89. );
  90. }
  91. // Render functions
  92. function renderType(type, t) {
  93. switch (type) {
  94. case 1:
  95. return (
  96. <Tag color='cyan' shape='circle'>
  97. {t('充值')}
  98. </Tag>
  99. );
  100. case 2:
  101. return (
  102. <Tag color='lime' shape='circle'>
  103. {t('消费')}
  104. </Tag>
  105. );
  106. case 3:
  107. return (
  108. <Tag color='orange' shape='circle'>
  109. {t('管理')}
  110. </Tag>
  111. );
  112. case 4:
  113. return (
  114. <Tag color='purple' shape='circle'>
  115. {t('系统')}
  116. </Tag>
  117. );
  118. case 5:
  119. return (
  120. <Tag color='red' shape='circle'>
  121. {t('错误')}
  122. </Tag>
  123. );
  124. default:
  125. return (
  126. <Tag color='grey' shape='circle'>
  127. {t('未知')}
  128. </Tag>
  129. );
  130. }
  131. }
  132. function renderIsStream(bool, t) {
  133. if (bool) {
  134. return (
  135. <Tag color='blue' shape='circle'>
  136. {t('流')}
  137. </Tag>
  138. );
  139. } else {
  140. return (
  141. <Tag color='purple' shape='circle'>
  142. {t('非流')}
  143. </Tag>
  144. );
  145. }
  146. }
  147. function renderUseTime(type, t) {
  148. const time = parseInt(type);
  149. if (time < 101) {
  150. return (
  151. <Tag color='green' shape='circle'>
  152. {' '}
  153. {time} s{' '}
  154. </Tag>
  155. );
  156. } else if (time < 300) {
  157. return (
  158. <Tag color='orange' shape='circle'>
  159. {' '}
  160. {time} s{' '}
  161. </Tag>
  162. );
  163. } else {
  164. return (
  165. <Tag color='red' shape='circle'>
  166. {' '}
  167. {time} s{' '}
  168. </Tag>
  169. );
  170. }
  171. }
  172. function renderFirstUseTime(type, t) {
  173. let time = parseFloat(type) / 1000.0;
  174. time = time.toFixed(1);
  175. if (time < 3) {
  176. return (
  177. <Tag color='green' shape='circle'>
  178. {' '}
  179. {time} s{' '}
  180. </Tag>
  181. );
  182. } else if (time < 10) {
  183. return (
  184. <Tag color='orange' shape='circle'>
  185. {' '}
  186. {time} s{' '}
  187. </Tag>
  188. );
  189. } else {
  190. return (
  191. <Tag color='red' shape='circle'>
  192. {' '}
  193. {time} s{' '}
  194. </Tag>
  195. );
  196. }
  197. }
  198. function renderBillingTag(record, t) {
  199. const other = getLogOther(record.other);
  200. if (other?.billing_source === 'subscription') {
  201. return (
  202. <Tag color='green' shape='circle'>
  203. {t('订阅抵扣')}
  204. </Tag>
  205. );
  206. }
  207. return null;
  208. }
  209. function renderModelName(record, copyText, t) {
  210. let other = getLogOther(record.other);
  211. let modelMapped =
  212. other?.is_model_mapped &&
  213. other?.upstream_model_name &&
  214. other?.upstream_model_name !== '';
  215. if (!modelMapped) {
  216. return renderModelTag(record.model_name, {
  217. onClick: (event) => {
  218. copyText(event, record.model_name).then((r) => {});
  219. },
  220. });
  221. } else {
  222. return (
  223. <>
  224. <Space vertical align={'start'}>
  225. <Popover
  226. content={
  227. <div style={{ padding: 10 }}>
  228. <Space vertical align={'start'}>
  229. <div className='flex items-center'>
  230. <Typography.Text strong style={{ marginRight: 8 }}>
  231. {t('请求并计费模型')}:
  232. </Typography.Text>
  233. {renderModelTag(record.model_name, {
  234. onClick: (event) => {
  235. copyText(event, record.model_name).then((r) => {});
  236. },
  237. })}
  238. </div>
  239. <div className='flex items-center'>
  240. <Typography.Text strong style={{ marginRight: 8 }}>
  241. {t('实际模型')}:
  242. </Typography.Text>
  243. {renderModelTag(other.upstream_model_name, {
  244. onClick: (event) => {
  245. copyText(event, other.upstream_model_name).then(
  246. (r) => {},
  247. );
  248. },
  249. })}
  250. </div>
  251. </Space>
  252. </div>
  253. }
  254. >
  255. {renderModelTag(record.model_name, {
  256. onClick: (event) => {
  257. copyText(event, record.model_name).then((r) => {});
  258. },
  259. suffixIcon: (
  260. <Route
  261. style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
  262. />
  263. ),
  264. })}
  265. </Popover>
  266. </Space>
  267. </>
  268. );
  269. }
  270. }
  271. export const getLogsColumns = ({
  272. t,
  273. COLUMN_KEYS,
  274. copyText,
  275. showUserInfoFunc,
  276. openChannelAffinityUsageCacheModal,
  277. isAdminUser,
  278. }) => {
  279. return [
  280. {
  281. key: COLUMN_KEYS.TIME,
  282. title: t('时间'),
  283. dataIndex: 'timestamp2string',
  284. },
  285. {
  286. key: COLUMN_KEYS.CHANNEL,
  287. title: t('渠道'),
  288. dataIndex: 'channel',
  289. render: (text, record, index) => {
  290. let isMultiKey = false;
  291. let multiKeyIndex = -1;
  292. let other = getLogOther(record.other);
  293. if (other?.admin_info) {
  294. let adminInfo = other.admin_info;
  295. if (adminInfo?.is_multi_key) {
  296. isMultiKey = true;
  297. multiKeyIndex = adminInfo.multi_key_index;
  298. }
  299. }
  300. return isAdminUser &&
  301. (record.type === 0 || record.type === 2 || record.type === 5) ? (
  302. <Space>
  303. <Tooltip content={record.channel_name || t('未知渠道')}>
  304. <span>
  305. <Tag
  306. color={colors[parseInt(text) % colors.length]}
  307. shape='circle'
  308. >
  309. {text}
  310. </Tag>
  311. </span>
  312. </Tooltip>
  313. {isMultiKey && (
  314. <Tag color='white' shape='circle'>
  315. {multiKeyIndex}
  316. </Tag>
  317. )}
  318. </Space>
  319. ) : null;
  320. },
  321. },
  322. {
  323. key: COLUMN_KEYS.USERNAME,
  324. title: t('用户'),
  325. dataIndex: 'username',
  326. render: (text, record, index) => {
  327. return isAdminUser ? (
  328. <div>
  329. <Avatar
  330. size='extra-small'
  331. color={stringToColor(text)}
  332. style={{ marginRight: 4 }}
  333. onClick={(event) => {
  334. event.stopPropagation();
  335. showUserInfoFunc(record.user_id);
  336. }}
  337. >
  338. {typeof text === 'string' && text.slice(0, 1)}
  339. </Avatar>
  340. {text}
  341. </div>
  342. ) : (
  343. <></>
  344. );
  345. },
  346. },
  347. {
  348. key: COLUMN_KEYS.TOKEN,
  349. title: t('令牌'),
  350. dataIndex: 'token_name',
  351. render: (text, record, index) => {
  352. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  353. <div>
  354. <Tag
  355. color='grey'
  356. shape='circle'
  357. onClick={(event) => {
  358. copyText(event, text);
  359. }}
  360. >
  361. {' '}
  362. {t(text)}{' '}
  363. </Tag>
  364. </div>
  365. ) : (
  366. <></>
  367. );
  368. },
  369. },
  370. {
  371. key: COLUMN_KEYS.GROUP,
  372. title: t('分组'),
  373. dataIndex: 'group',
  374. render: (text, record, index) => {
  375. if (record.type === 0 || record.type === 2 || record.type === 5) {
  376. if (record.group) {
  377. return <>{renderGroup(record.group)}</>;
  378. } else {
  379. let other = null;
  380. try {
  381. other = JSON.parse(record.other);
  382. } catch (e) {
  383. console.error(
  384. `Failed to parse record.other: "${record.other}".`,
  385. e,
  386. );
  387. }
  388. if (other === null) {
  389. return <></>;
  390. }
  391. if (other.group !== undefined) {
  392. return <>{renderGroup(other.group)}</>;
  393. } else {
  394. return <></>;
  395. }
  396. }
  397. } else {
  398. return <></>;
  399. }
  400. },
  401. },
  402. {
  403. key: COLUMN_KEYS.TYPE,
  404. title: t('类型'),
  405. dataIndex: 'type',
  406. render: (text, record, index) => {
  407. return <>{renderType(text, t)}</>;
  408. },
  409. },
  410. {
  411. key: COLUMN_KEYS.MODEL,
  412. title: t('模型'),
  413. dataIndex: 'model_name',
  414. render: (text, record, index) => {
  415. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  416. <>{renderModelName(record, copyText, t)}</>
  417. ) : (
  418. <></>
  419. );
  420. },
  421. },
  422. {
  423. key: COLUMN_KEYS.USE_TIME,
  424. title: t('用时/首字'),
  425. dataIndex: 'use_time',
  426. render: (text, record, index) => {
  427. if (!(record.type === 2 || record.type === 5)) {
  428. return <></>;
  429. }
  430. if (record.is_stream) {
  431. let other = getLogOther(record.other);
  432. return (
  433. <>
  434. <Space>
  435. {renderUseTime(text, t)}
  436. {renderFirstUseTime(other?.frt, t)}
  437. {renderIsStream(record.is_stream, t)}
  438. </Space>
  439. </>
  440. );
  441. } else {
  442. return (
  443. <>
  444. <Space>
  445. {renderUseTime(text, t)}
  446. {renderIsStream(record.is_stream, t)}
  447. </Space>
  448. </>
  449. );
  450. }
  451. },
  452. },
  453. {
  454. key: COLUMN_KEYS.PROMPT,
  455. title: t('输入'),
  456. dataIndex: 'prompt_tokens',
  457. render: (text, record, index) => {
  458. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  459. <>{<span> {text} </span>}</>
  460. ) : (
  461. <></>
  462. );
  463. },
  464. },
  465. {
  466. key: COLUMN_KEYS.COMPLETION,
  467. title: t('输出'),
  468. dataIndex: 'completion_tokens',
  469. render: (text, record, index) => {
  470. return parseInt(text) > 0 &&
  471. (record.type === 0 || record.type === 2 || record.type === 5) ? (
  472. <>{<span> {text} </span>}</>
  473. ) : (
  474. <></>
  475. );
  476. },
  477. },
  478. {
  479. key: COLUMN_KEYS.COST,
  480. title: t('花费'),
  481. dataIndex: 'quota',
  482. render: (text, record, index) => {
  483. if (!(record.type === 0 || record.type === 2 || record.type === 5)) {
  484. return <></>;
  485. }
  486. const other = getLogOther(record.other);
  487. const isSubscription = other?.billing_source === 'subscription';
  488. if (isSubscription) {
  489. // Subscription billed: show only tag (no $0), but keep tooltip for equivalent cost.
  490. return (
  491. <Tooltip content={`${t('由订阅抵扣')}:${renderQuota(text, 6)}`}>
  492. <span>{renderBillingTag(record, t)}</span>
  493. </Tooltip>
  494. );
  495. }
  496. return <>{renderQuota(text, 6)}</>;
  497. },
  498. },
  499. {
  500. key: COLUMN_KEYS.IP,
  501. title: (
  502. <div className='flex items-center gap-1'>
  503. {t('IP')}
  504. <Tooltip
  505. content={t(
  506. '只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录',
  507. )}
  508. >
  509. <IconHelpCircle className='text-gray-400 cursor-help' />
  510. </Tooltip>
  511. </div>
  512. ),
  513. dataIndex: 'ip',
  514. render: (text, record, index) => {
  515. return (record.type === 2 || record.type === 5) && text ? (
  516. <Tooltip content={text}>
  517. <span>
  518. <Tag
  519. color='orange'
  520. shape='circle'
  521. onClick={(event) => {
  522. copyText(event, text);
  523. }}
  524. >
  525. {text}
  526. </Tag>
  527. </span>
  528. </Tooltip>
  529. ) : (
  530. <></>
  531. );
  532. },
  533. },
  534. {
  535. key: COLUMN_KEYS.RETRY,
  536. title: t('重试'),
  537. dataIndex: 'retry',
  538. render: (text, record, index) => {
  539. if (!(record.type === 2 || record.type === 5)) {
  540. return <></>;
  541. }
  542. let content = t('渠道') + `:${record.channel}`;
  543. let affinity = null;
  544. if (record.other !== '') {
  545. let other = JSON.parse(record.other);
  546. if (other === null) {
  547. return <></>;
  548. }
  549. if (other.admin_info !== undefined) {
  550. if (
  551. other.admin_info.use_channel !== null &&
  552. other.admin_info.use_channel !== undefined &&
  553. other.admin_info.use_channel !== ''
  554. ) {
  555. let useChannel = other.admin_info.use_channel;
  556. let useChannelStr = useChannel.join('->');
  557. content = t('渠道') + `:${useChannelStr}`;
  558. }
  559. if (other.admin_info.channel_affinity) {
  560. affinity = other.admin_info.channel_affinity;
  561. }
  562. }
  563. }
  564. return isAdminUser ? (
  565. <Space>
  566. <div>{content}</div>
  567. {affinity ? (
  568. <Tooltip
  569. content={
  570. <div>
  571. {buildChannelAffinityTooltip(affinity, t)}
  572. <div style={{ marginTop: 6 }}>
  573. <Button
  574. theme='borderless'
  575. size='small'
  576. onClick={(e) => {
  577. e.stopPropagation();
  578. openChannelAffinityUsageCacheModal?.(affinity);
  579. }}
  580. >
  581. {t('查看详情')}
  582. </Button>
  583. </div>
  584. </div>
  585. }
  586. >
  587. <span>
  588. <Tag
  589. className='channel-affinity-tag'
  590. color='cyan'
  591. shape='circle'
  592. >
  593. <span className='channel-affinity-tag-content'>
  594. <IconStarStroked style={{ fontSize: 13 }} />
  595. {t('优选')}
  596. </span>
  597. </Tag>
  598. </span>
  599. </Tooltip>
  600. ) : null}
  601. </Space>
  602. ) : (
  603. <></>
  604. );
  605. },
  606. },
  607. {
  608. key: COLUMN_KEYS.DETAILS,
  609. title: t('详情'),
  610. dataIndex: 'content',
  611. fixed: 'right',
  612. render: (text, record, index) => {
  613. let other = getLogOther(record.other);
  614. if (other == null || record.type !== 2) {
  615. return (
  616. <Typography.Paragraph
  617. ellipsis={{
  618. rows: 2,
  619. showTooltip: {
  620. type: 'popover',
  621. opts: { style: { width: 240 } },
  622. },
  623. }}
  624. style={{ maxWidth: 240 }}
  625. >
  626. {text}
  627. </Typography.Paragraph>
  628. );
  629. }
  630. if (
  631. other?.violation_fee === true ||
  632. Boolean(other?.violation_fee_code) ||
  633. Boolean(other?.violation_fee_marker)
  634. ) {
  635. const feeQuota = other?.fee_quota ?? record?.quota;
  636. const ratioText = formatRatio(other?.group_ratio);
  637. const summary = [
  638. t('违规扣费'),
  639. `${t('分组倍率')}:${ratioText}`,
  640. `${t('扣费')}:${renderQuota(feeQuota, 6)}`,
  641. text ? `${t('详情')}:${text}` : null,
  642. ]
  643. .filter(Boolean)
  644. .join('\n');
  645. return (
  646. <Typography.Paragraph
  647. ellipsis={{
  648. rows: 2,
  649. showTooltip: {
  650. type: 'popover',
  651. opts: { style: { width: 240 } },
  652. },
  653. }}
  654. style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
  655. >
  656. {summary}
  657. </Typography.Paragraph>
  658. );
  659. }
  660. let content = other?.claude
  661. ? renderModelPriceSimple(
  662. other.model_ratio,
  663. other.model_price,
  664. other.group_ratio,
  665. other?.user_group_ratio,
  666. other.cache_tokens || 0,
  667. other.cache_ratio || 1.0,
  668. other.cache_creation_tokens || 0,
  669. other.cache_creation_ratio || 1.0,
  670. other.cache_creation_tokens_5m || 0,
  671. other.cache_creation_ratio_5m ||
  672. other.cache_creation_ratio ||
  673. 1.0,
  674. other.cache_creation_tokens_1h || 0,
  675. other.cache_creation_ratio_1h ||
  676. other.cache_creation_ratio ||
  677. 1.0,
  678. false,
  679. 1.0,
  680. other?.is_system_prompt_overwritten,
  681. 'claude',
  682. )
  683. : renderModelPriceSimple(
  684. other.model_ratio,
  685. other.model_price,
  686. other.group_ratio,
  687. other?.user_group_ratio,
  688. other.cache_tokens || 0,
  689. other.cache_ratio || 1.0,
  690. 0,
  691. 1.0,
  692. 0,
  693. 1.0,
  694. 0,
  695. 1.0,
  696. false,
  697. 1.0,
  698. other?.is_system_prompt_overwritten,
  699. 'openai',
  700. );
  701. return (
  702. <Typography.Paragraph
  703. ellipsis={{
  704. rows: 3,
  705. }}
  706. style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
  707. >
  708. {content}
  709. </Typography.Paragraph>
  710. );
  711. },
  712. },
  713. ];
  714. };