LogsTable.js 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490
  1. import React, { useEffect, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. API,
  5. copy,
  6. getTodayStartTimestamp,
  7. isAdmin,
  8. showError,
  9. showSuccess,
  10. timestamp2string,
  11. renderAudioModelPrice,
  12. renderClaudeLogContent,
  13. renderClaudeModelPrice,
  14. renderClaudeModelPriceSimple,
  15. renderGroup,
  16. renderLogContent,
  17. renderModelPrice,
  18. renderModelPriceSimple,
  19. renderNumber,
  20. renderQuota,
  21. stringToColor,
  22. getLogOther,
  23. renderModelTag
  24. } from '../../helpers';
  25. import {
  26. Avatar,
  27. Button,
  28. Descriptions,
  29. Empty,
  30. Modal,
  31. Popover,
  32. Space,
  33. Spin,
  34. Table,
  35. Tag,
  36. Tooltip,
  37. Checkbox,
  38. Card,
  39. Typography,
  40. Divider,
  41. Form,
  42. } from '@douyinfe/semi-ui';
  43. import {
  44. IllustrationNoResult,
  45. IllustrationNoResultDark,
  46. } from '@douyinfe/semi-illustrations';
  47. import { ITEMS_PER_PAGE } from '../../constants';
  48. import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
  49. import { IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
  50. import { Route } from 'lucide-react';
  51. import { useTableCompactMode } from '../../hooks/useTableCompactMode';
  52. const { Text } = Typography;
  53. const colors = [
  54. 'amber',
  55. 'blue',
  56. 'cyan',
  57. 'green',
  58. 'grey',
  59. 'indigo',
  60. 'light-blue',
  61. 'lime',
  62. 'orange',
  63. 'pink',
  64. 'purple',
  65. 'red',
  66. 'teal',
  67. 'violet',
  68. 'yellow',
  69. ];
  70. const LogsTable = () => {
  71. const { t } = useTranslation();
  72. function renderType(type) {
  73. switch (type) {
  74. case 1:
  75. return (
  76. <Tag color='cyan' shape='circle'>
  77. {t('充值')}
  78. </Tag>
  79. );
  80. case 2:
  81. return (
  82. <Tag color='lime' shape='circle'>
  83. {t('消费')}
  84. </Tag>
  85. );
  86. case 3:
  87. return (
  88. <Tag color='orange' shape='circle'>
  89. {t('管理')}
  90. </Tag>
  91. );
  92. case 4:
  93. return (
  94. <Tag color='purple' shape='circle'>
  95. {t('系统')}
  96. </Tag>
  97. );
  98. case 5:
  99. return (
  100. <Tag color='red' shape='circle'>
  101. {t('错误')}
  102. </Tag>
  103. );
  104. default:
  105. return (
  106. <Tag color='grey' shape='circle'>
  107. {t('未知')}
  108. </Tag>
  109. );
  110. }
  111. }
  112. function renderIsStream(bool) {
  113. if (bool) {
  114. return (
  115. <Tag color='blue' shape='circle'>
  116. {t('流')}
  117. </Tag>
  118. );
  119. } else {
  120. return (
  121. <Tag color='purple' shape='circle'>
  122. {t('非流')}
  123. </Tag>
  124. );
  125. }
  126. }
  127. function renderUseTime(type) {
  128. const time = parseInt(type);
  129. if (time < 101) {
  130. return (
  131. <Tag color='green' shape='circle'>
  132. {' '}
  133. {time} s{' '}
  134. </Tag>
  135. );
  136. } else if (time < 300) {
  137. return (
  138. <Tag color='orange' shape='circle'>
  139. {' '}
  140. {time} s{' '}
  141. </Tag>
  142. );
  143. } else {
  144. return (
  145. <Tag color='red' shape='circle'>
  146. {' '}
  147. {time} s{' '}
  148. </Tag>
  149. );
  150. }
  151. }
  152. function renderFirstUseTime(type) {
  153. let time = parseFloat(type) / 1000.0;
  154. time = time.toFixed(1);
  155. if (time < 3) {
  156. return (
  157. <Tag color='green' shape='circle'>
  158. {' '}
  159. {time} s{' '}
  160. </Tag>
  161. );
  162. } else if (time < 10) {
  163. return (
  164. <Tag color='orange' shape='circle'>
  165. {' '}
  166. {time} s{' '}
  167. </Tag>
  168. );
  169. } else {
  170. return (
  171. <Tag color='red' shape='circle'>
  172. {' '}
  173. {time} s{' '}
  174. </Tag>
  175. );
  176. }
  177. }
  178. function renderModelName(record) {
  179. let other = getLogOther(record.other);
  180. let modelMapped =
  181. other?.is_model_mapped &&
  182. other?.upstream_model_name &&
  183. other?.upstream_model_name !== '';
  184. if (!modelMapped) {
  185. return renderModelTag(record.model_name, {
  186. onClick: (event) => {
  187. copyText(event, record.model_name).then((r) => { });
  188. },
  189. });
  190. } else {
  191. return (
  192. <>
  193. <Space vertical align={'start'}>
  194. <Popover
  195. content={
  196. <div style={{ padding: 10 }}>
  197. <Space vertical align={'start'}>
  198. <div className='flex items-center'>
  199. <Text strong style={{ marginRight: 8 }}>
  200. {t('请求并计费模型')}:
  201. </Text>
  202. {renderModelTag(record.model_name, {
  203. onClick: (event) => {
  204. copyText(event, record.model_name).then((r) => { });
  205. },
  206. })}
  207. </div>
  208. <div className='flex items-center'>
  209. <Text strong style={{ marginRight: 8 }}>
  210. {t('实际模型')}:
  211. </Text>
  212. {renderModelTag(other.upstream_model_name, {
  213. onClick: (event) => {
  214. copyText(event, other.upstream_model_name).then(
  215. (r) => { },
  216. );
  217. },
  218. })}
  219. </div>
  220. </Space>
  221. </div>
  222. }
  223. >
  224. {renderModelTag(record.model_name, {
  225. onClick: (event) => {
  226. copyText(event, record.model_name).then((r) => { });
  227. },
  228. suffixIcon: (
  229. <Route
  230. style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
  231. />
  232. ),
  233. })}
  234. </Popover>
  235. </Space>
  236. </>
  237. );
  238. }
  239. }
  240. // Define column keys for selection
  241. const COLUMN_KEYS = {
  242. TIME: 'time',
  243. CHANNEL: 'channel',
  244. USERNAME: 'username',
  245. TOKEN: 'token',
  246. GROUP: 'group',
  247. TYPE: 'type',
  248. MODEL: 'model',
  249. USE_TIME: 'use_time',
  250. PROMPT: 'prompt',
  251. COMPLETION: 'completion',
  252. COST: 'cost',
  253. RETRY: 'retry',
  254. IP: 'ip',
  255. DETAILS: 'details',
  256. };
  257. // State for column visibility
  258. const [visibleColumns, setVisibleColumns] = useState({});
  259. const [showColumnSelector, setShowColumnSelector] = useState(false);
  260. // Load saved column preferences from localStorage
  261. useEffect(() => {
  262. const savedColumns = localStorage.getItem('logs-table-columns');
  263. if (savedColumns) {
  264. try {
  265. const parsed = JSON.parse(savedColumns);
  266. // Make sure all columns are accounted for
  267. const defaults = getDefaultColumnVisibility();
  268. const merged = { ...defaults, ...parsed };
  269. setVisibleColumns(merged);
  270. } catch (e) {
  271. console.error('Failed to parse saved column preferences', e);
  272. initDefaultColumns();
  273. }
  274. } else {
  275. initDefaultColumns();
  276. }
  277. }, []);
  278. // Get default column visibility based on user role
  279. const getDefaultColumnVisibility = () => {
  280. return {
  281. [COLUMN_KEYS.TIME]: true,
  282. [COLUMN_KEYS.CHANNEL]: isAdminUser,
  283. [COLUMN_KEYS.USERNAME]: isAdminUser,
  284. [COLUMN_KEYS.TOKEN]: true,
  285. [COLUMN_KEYS.GROUP]: true,
  286. [COLUMN_KEYS.TYPE]: true,
  287. [COLUMN_KEYS.MODEL]: true,
  288. [COLUMN_KEYS.USE_TIME]: true,
  289. [COLUMN_KEYS.PROMPT]: true,
  290. [COLUMN_KEYS.COMPLETION]: true,
  291. [COLUMN_KEYS.COST]: true,
  292. [COLUMN_KEYS.RETRY]: isAdminUser,
  293. [COLUMN_KEYS.IP]: true,
  294. [COLUMN_KEYS.DETAILS]: true,
  295. };
  296. };
  297. // Initialize default column visibility
  298. const initDefaultColumns = () => {
  299. const defaults = getDefaultColumnVisibility();
  300. setVisibleColumns(defaults);
  301. localStorage.setItem('logs-table-columns', JSON.stringify(defaults));
  302. };
  303. // Handle column visibility change
  304. const handleColumnVisibilityChange = (columnKey, checked) => {
  305. const updatedColumns = { ...visibleColumns, [columnKey]: checked };
  306. setVisibleColumns(updatedColumns);
  307. };
  308. // Handle "Select All" checkbox
  309. const handleSelectAll = (checked) => {
  310. const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
  311. const updatedColumns = {};
  312. allKeys.forEach((key) => {
  313. // For admin-only columns, only enable them if user is admin
  314. if (
  315. (key === COLUMN_KEYS.CHANNEL ||
  316. key === COLUMN_KEYS.USERNAME ||
  317. key === COLUMN_KEYS.RETRY) &&
  318. !isAdminUser
  319. ) {
  320. updatedColumns[key] = false;
  321. } else {
  322. updatedColumns[key] = checked;
  323. }
  324. });
  325. setVisibleColumns(updatedColumns);
  326. };
  327. // Define all columns
  328. const allColumns = [
  329. {
  330. key: COLUMN_KEYS.TIME,
  331. title: t('时间'),
  332. dataIndex: 'timestamp2string',
  333. },
  334. {
  335. key: COLUMN_KEYS.CHANNEL,
  336. title: t('渠道'),
  337. dataIndex: 'channel',
  338. className: isAdmin() ? 'tableShow' : 'tableHiddle',
  339. render: (text, record, index) => {
  340. let isMultiKey = false
  341. let multiKeyIndex = -1;
  342. let other = getLogOther(record.other);
  343. if (other?.admin_info) {
  344. let adminInfo = other.admin_info;
  345. if (adminInfo?.is_multi_key) {
  346. isMultiKey = true;
  347. multiKeyIndex = adminInfo.multi_key_index;
  348. }
  349. }
  350. return isAdminUser ? (
  351. record.type === 0 || record.type === 2 || record.type === 5 ? (
  352. <>
  353. {
  354. <Tooltip content={record.channel_name || '[未知]'}>
  355. <Space>
  356. <Tag
  357. color={colors[parseInt(text) % colors.length]}
  358. shape='circle'
  359. >
  360. {text}
  361. </Tag>
  362. {
  363. isMultiKey && (
  364. <Tag
  365. color={'white'}
  366. shape='circle'
  367. >
  368. {multiKeyIndex}
  369. </Tag>
  370. )
  371. }
  372. </Space>
  373. </Tooltip>
  374. }
  375. </>
  376. ) : (
  377. <></>
  378. )
  379. ) : (
  380. <></>
  381. );
  382. },
  383. },
  384. {
  385. key: COLUMN_KEYS.USERNAME,
  386. title: t('用户'),
  387. dataIndex: 'username',
  388. className: isAdmin() ? 'tableShow' : 'tableHiddle',
  389. render: (text, record, index) => {
  390. return isAdminUser ? (
  391. <div>
  392. <Avatar
  393. size='extra-small'
  394. color={stringToColor(text)}
  395. style={{ marginRight: 4 }}
  396. onClick={(event) => {
  397. event.stopPropagation();
  398. showUserInfo(record.user_id);
  399. }}
  400. >
  401. {typeof text === 'string' && text.slice(0, 1)}
  402. </Avatar>
  403. {text}
  404. </div>
  405. ) : (
  406. <></>
  407. );
  408. },
  409. },
  410. {
  411. key: COLUMN_KEYS.TOKEN,
  412. title: t('令牌'),
  413. dataIndex: 'token_name',
  414. render: (text, record, index) => {
  415. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  416. <div>
  417. <Tag
  418. color='grey'
  419. shape='circle'
  420. onClick={(event) => {
  421. //cancel the row click event
  422. copyText(event, text);
  423. }}
  424. >
  425. {' '}
  426. {t(text)}{' '}
  427. </Tag>
  428. </div>
  429. ) : (
  430. <></>
  431. );
  432. },
  433. },
  434. {
  435. key: COLUMN_KEYS.GROUP,
  436. title: t('分组'),
  437. dataIndex: 'group',
  438. render: (text, record, index) => {
  439. if (record.type === 0 || record.type === 2 || record.type === 5) {
  440. if (record.group) {
  441. return <>{renderGroup(record.group)}</>;
  442. } else {
  443. let other = null;
  444. try {
  445. other = JSON.parse(record.other);
  446. } catch (e) {
  447. console.error(
  448. `Failed to parse record.other: "${record.other}".`,
  449. e,
  450. );
  451. }
  452. if (other === null) {
  453. return <></>;
  454. }
  455. if (other.group !== undefined) {
  456. return <>{renderGroup(other.group)}</>;
  457. } else {
  458. return <></>;
  459. }
  460. }
  461. } else {
  462. return <></>;
  463. }
  464. },
  465. },
  466. {
  467. key: COLUMN_KEYS.TYPE,
  468. title: t('类型'),
  469. dataIndex: 'type',
  470. render: (text, record, index) => {
  471. return <>{renderType(text)}</>;
  472. },
  473. },
  474. {
  475. key: COLUMN_KEYS.MODEL,
  476. title: t('模型'),
  477. dataIndex: 'model_name',
  478. render: (text, record, index) => {
  479. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  480. <>{renderModelName(record)}</>
  481. ) : (
  482. <></>
  483. );
  484. },
  485. },
  486. {
  487. key: COLUMN_KEYS.USE_TIME,
  488. title: t('用时/首字'),
  489. dataIndex: 'use_time',
  490. render: (text, record, index) => {
  491. if (!(record.type === 2 || record.type === 5)) {
  492. return <></>;
  493. }
  494. if (record.is_stream) {
  495. let other = getLogOther(record.other);
  496. return (
  497. <>
  498. <Space>
  499. {renderUseTime(text)}
  500. {renderFirstUseTime(other?.frt)}
  501. {renderIsStream(record.is_stream)}
  502. </Space>
  503. </>
  504. );
  505. } else {
  506. return (
  507. <>
  508. <Space>
  509. {renderUseTime(text)}
  510. {renderIsStream(record.is_stream)}
  511. </Space>
  512. </>
  513. );
  514. }
  515. },
  516. },
  517. {
  518. key: COLUMN_KEYS.PROMPT,
  519. title: t('提示'),
  520. dataIndex: 'prompt_tokens',
  521. render: (text, record, index) => {
  522. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  523. <>{<span> {text} </span>}</>
  524. ) : (
  525. <></>
  526. );
  527. },
  528. },
  529. {
  530. key: COLUMN_KEYS.COMPLETION,
  531. title: t('补全'),
  532. dataIndex: 'completion_tokens',
  533. render: (text, record, index) => {
  534. return parseInt(text) > 0 &&
  535. (record.type === 0 || record.type === 2 || record.type === 5) ? (
  536. <>{<span> {text} </span>}</>
  537. ) : (
  538. <></>
  539. );
  540. },
  541. },
  542. {
  543. key: COLUMN_KEYS.COST,
  544. title: t('花费'),
  545. dataIndex: 'quota',
  546. render: (text, record, index) => {
  547. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  548. <>{renderQuota(text, 6)}</>
  549. ) : (
  550. <></>
  551. );
  552. },
  553. },
  554. {
  555. key: COLUMN_KEYS.IP,
  556. title: (
  557. <div className="flex items-center gap-1">
  558. {t('IP')}
  559. <Tooltip content={t('只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录')}>
  560. <IconHelpCircle className="text-gray-400 cursor-help" />
  561. </Tooltip>
  562. </div>
  563. ),
  564. dataIndex: 'ip',
  565. render: (text, record, index) => {
  566. return (record.type === 2 || record.type === 5) && text ? (
  567. <Tooltip content={text}>
  568. <Tag
  569. color='orange'
  570. shape='circle'
  571. onClick={(event) => {
  572. copyText(event, text);
  573. }}
  574. >
  575. {text}
  576. </Tag>
  577. </Tooltip>
  578. ) : (
  579. <></>
  580. );
  581. },
  582. },
  583. {
  584. key: COLUMN_KEYS.RETRY,
  585. title: t('重试'),
  586. dataIndex: 'retry',
  587. className: isAdmin() ? 'tableShow' : 'tableHiddle',
  588. render: (text, record, index) => {
  589. if (!(record.type === 2 || record.type === 5)) {
  590. return <></>;
  591. }
  592. let content = t('渠道') + `:${record.channel}`;
  593. if (record.other !== '') {
  594. let other = JSON.parse(record.other);
  595. if (other === null) {
  596. return <></>;
  597. }
  598. if (other.admin_info !== undefined) {
  599. if (
  600. other.admin_info.use_channel !== null &&
  601. other.admin_info.use_channel !== undefined &&
  602. other.admin_info.use_channel !== ''
  603. ) {
  604. // channel id array
  605. let useChannel = other.admin_info.use_channel;
  606. let useChannelStr = useChannel.join('->');
  607. content = t('渠道') + `:${useChannelStr}`;
  608. }
  609. }
  610. }
  611. return isAdminUser ? <div>{content}</div> : <></>;
  612. },
  613. },
  614. {
  615. key: COLUMN_KEYS.DETAILS,
  616. title: t('详情'),
  617. dataIndex: 'content',
  618. fixed: 'right',
  619. render: (text, record, index) => {
  620. let other = getLogOther(record.other);
  621. if (other == null || record.type !== 2) {
  622. return (
  623. <Paragraph
  624. ellipsis={{
  625. rows: 2,
  626. showTooltip: {
  627. type: 'popover',
  628. opts: { style: { width: 240 } },
  629. },
  630. }}
  631. style={{ maxWidth: 240 }}
  632. >
  633. {text}
  634. </Paragraph>
  635. );
  636. }
  637. let content = other?.claude
  638. ? renderClaudeModelPriceSimple(
  639. other.model_ratio,
  640. other.model_price,
  641. other.group_ratio,
  642. other?.user_group_ratio,
  643. other.cache_tokens || 0,
  644. other.cache_ratio || 1.0,
  645. other.cache_creation_tokens || 0,
  646. other.cache_creation_ratio || 1.0,
  647. )
  648. : renderModelPriceSimple(
  649. other.model_ratio,
  650. other.model_price,
  651. other.group_ratio,
  652. other?.user_group_ratio,
  653. other.cache_tokens || 0,
  654. other.cache_ratio || 1.0,
  655. );
  656. return (
  657. <Paragraph
  658. ellipsis={{
  659. rows: 2,
  660. }}
  661. style={{ maxWidth: 240 }}
  662. >
  663. {content}
  664. </Paragraph>
  665. );
  666. },
  667. },
  668. ];
  669. // Update table when column visibility changes
  670. useEffect(() => {
  671. if (Object.keys(visibleColumns).length > 0) {
  672. // Save to localStorage
  673. localStorage.setItem(
  674. 'logs-table-columns',
  675. JSON.stringify(visibleColumns),
  676. );
  677. }
  678. }, [visibleColumns]);
  679. // Filter columns based on visibility settings
  680. const getVisibleColumns = () => {
  681. return allColumns.filter((column) => visibleColumns[column.key]);
  682. };
  683. // Column selector modal
  684. const renderColumnSelector = () => {
  685. return (
  686. <Modal
  687. title={t('列设置')}
  688. visible={showColumnSelector}
  689. onCancel={() => setShowColumnSelector(false)}
  690. footer={
  691. <div className='flex justify-end'>
  692. <Button
  693. theme='light'
  694. onClick={() => initDefaultColumns()}
  695. >
  696. {t('重置')}
  697. </Button>
  698. <Button
  699. theme='light'
  700. onClick={() => setShowColumnSelector(false)}
  701. >
  702. {t('取消')}
  703. </Button>
  704. <Button
  705. type='primary'
  706. onClick={() => setShowColumnSelector(false)}
  707. >
  708. {t('确定')}
  709. </Button>
  710. </div>
  711. }
  712. >
  713. <div style={{ marginBottom: 20 }}>
  714. <Checkbox
  715. checked={Object.values(visibleColumns).every((v) => v === true)}
  716. indeterminate={
  717. Object.values(visibleColumns).some((v) => v === true) &&
  718. !Object.values(visibleColumns).every((v) => v === true)
  719. }
  720. onChange={(e) => handleSelectAll(e.target.checked)}
  721. >
  722. {t('全选')}
  723. </Checkbox>
  724. </div>
  725. <div
  726. className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
  727. style={{ border: '1px solid var(--semi-color-border)' }}
  728. >
  729. {allColumns.map((column) => {
  730. // Skip admin-only columns for non-admin users
  731. if (
  732. !isAdminUser &&
  733. (column.key === COLUMN_KEYS.CHANNEL ||
  734. column.key === COLUMN_KEYS.USERNAME ||
  735. column.key === COLUMN_KEYS.RETRY)
  736. ) {
  737. return null;
  738. }
  739. return (
  740. <div key={column.key} className='w-1/2 mb-4 pr-2'>
  741. <Checkbox
  742. checked={!!visibleColumns[column.key]}
  743. onChange={(e) =>
  744. handleColumnVisibilityChange(column.key, e.target.checked)
  745. }
  746. >
  747. {column.title}
  748. </Checkbox>
  749. </div>
  750. );
  751. })}
  752. </div>
  753. </Modal>
  754. );
  755. };
  756. const [logs, setLogs] = useState([]);
  757. const [expandData, setExpandData] = useState({});
  758. const [showStat, setShowStat] = useState(false);
  759. const [loading, setLoading] = useState(false);
  760. const [loadingStat, setLoadingStat] = useState(false);
  761. const [activePage, setActivePage] = useState(1);
  762. const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
  763. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  764. const [logType, setLogType] = useState(0);
  765. const isAdminUser = isAdmin();
  766. let now = new Date();
  767. // Form 初始值
  768. const formInitValues = {
  769. username: '',
  770. token_name: '',
  771. model_name: '',
  772. channel: '',
  773. group: '',
  774. dateRange: [
  775. timestamp2string(getTodayStartTimestamp()),
  776. timestamp2string(now.getTime() / 1000 + 3600),
  777. ],
  778. logType: '0',
  779. };
  780. const [stat, setStat] = useState({
  781. quota: 0,
  782. token: 0,
  783. });
  784. // Form API 引用
  785. const [formApi, setFormApi] = useState(null);
  786. // 获取表单值的辅助函数,确保所有值都是字符串
  787. const getFormValues = () => {
  788. const formValues = formApi ? formApi.getValues() : {};
  789. // 处理时间范围
  790. let start_timestamp = timestamp2string(getTodayStartTimestamp());
  791. let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
  792. if (
  793. formValues.dateRange &&
  794. Array.isArray(formValues.dateRange) &&
  795. formValues.dateRange.length === 2
  796. ) {
  797. start_timestamp = formValues.dateRange[0];
  798. end_timestamp = formValues.dateRange[1];
  799. }
  800. return {
  801. username: formValues.username || '',
  802. token_name: formValues.token_name || '',
  803. model_name: formValues.model_name || '',
  804. start_timestamp,
  805. end_timestamp,
  806. channel: formValues.channel || '',
  807. group: formValues.group || '',
  808. logType: formValues.logType ? parseInt(formValues.logType) : 0,
  809. };
  810. };
  811. const getLogSelfStat = async () => {
  812. const {
  813. token_name,
  814. model_name,
  815. start_timestamp,
  816. end_timestamp,
  817. group,
  818. logType: formLogType,
  819. } = getFormValues();
  820. const currentLogType = formLogType !== undefined ? formLogType : logType;
  821. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  822. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  823. let url = `/api/log/self/stat?type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
  824. url = encodeURI(url);
  825. let res = await API.get(url);
  826. const { success, message, data } = res.data;
  827. if (success) {
  828. setStat(data);
  829. } else {
  830. showError(message);
  831. }
  832. };
  833. const getLogStat = async () => {
  834. const {
  835. username,
  836. token_name,
  837. model_name,
  838. start_timestamp,
  839. end_timestamp,
  840. channel,
  841. group,
  842. logType: formLogType,
  843. } = getFormValues();
  844. const currentLogType = formLogType !== undefined ? formLogType : logType;
  845. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  846. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  847. 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}`;
  848. url = encodeURI(url);
  849. let res = await API.get(url);
  850. const { success, message, data } = res.data;
  851. if (success) {
  852. setStat(data);
  853. } else {
  854. showError(message);
  855. }
  856. };
  857. const handleEyeClick = async () => {
  858. if (loadingStat) {
  859. return;
  860. }
  861. setLoadingStat(true);
  862. if (isAdminUser) {
  863. await getLogStat();
  864. } else {
  865. await getLogSelfStat();
  866. }
  867. setShowStat(true);
  868. setLoadingStat(false);
  869. };
  870. const showUserInfo = async (userId) => {
  871. if (!isAdminUser) {
  872. return;
  873. }
  874. const res = await API.get(`/api/user/${userId}`);
  875. const { success, message, data } = res.data;
  876. if (success) {
  877. Modal.info({
  878. title: t('用户信息'),
  879. content: (
  880. <div style={{ padding: 12 }}>
  881. <p>
  882. {t('用户名')}: {data.username}
  883. </p>
  884. <p>
  885. {t('余额')}: {renderQuota(data.quota)}
  886. </p>
  887. <p>
  888. {t('已用额度')}:{renderQuota(data.used_quota)}
  889. </p>
  890. <p>
  891. {t('请求次数')}:{renderNumber(data.request_count)}
  892. </p>
  893. </div>
  894. ),
  895. centered: true,
  896. });
  897. } else {
  898. showError(message);
  899. }
  900. };
  901. const setLogsFormat = (logs) => {
  902. let expandDatesLocal = {};
  903. for (let i = 0; i < logs.length; i++) {
  904. logs[i].timestamp2string = timestamp2string(logs[i].created_at);
  905. logs[i].key = logs[i].id;
  906. let other = getLogOther(logs[i].other);
  907. let expandDataLocal = [];
  908. if (isAdmin()) {
  909. // let content = '渠道:' + logs[i].channel;
  910. // if (other.admin_info !== undefined) {
  911. // if (
  912. // other.admin_info.use_channel !== null &&
  913. // other.admin_info.use_channel !== undefined &&
  914. // other.admin_info.use_channel !== ''
  915. // ) {
  916. // // channel id array
  917. // let useChannel = other.admin_info.use_channel;
  918. // let useChannelStr = useChannel.join('->');
  919. // content = `渠道:${useChannelStr}`;
  920. // }
  921. // }
  922. // expandDataLocal.push({
  923. // key: '渠道重试',
  924. // value: content,
  925. // })
  926. }
  927. if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2)) {
  928. expandDataLocal.push({
  929. key: t('渠道信息'),
  930. value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
  931. });
  932. }
  933. if (other?.ws || other?.audio) {
  934. expandDataLocal.push({
  935. key: t('语音输入'),
  936. value: other.audio_input,
  937. });
  938. expandDataLocal.push({
  939. key: t('语音输出'),
  940. value: other.audio_output,
  941. });
  942. expandDataLocal.push({
  943. key: t('文字输入'),
  944. value: other.text_input,
  945. });
  946. expandDataLocal.push({
  947. key: t('文字输出'),
  948. value: other.text_output,
  949. });
  950. }
  951. if (other?.cache_tokens > 0) {
  952. expandDataLocal.push({
  953. key: t('缓存 Tokens'),
  954. value: other.cache_tokens,
  955. });
  956. }
  957. if (other?.cache_creation_tokens > 0) {
  958. expandDataLocal.push({
  959. key: t('缓存创建 Tokens'),
  960. value: other.cache_creation_tokens,
  961. });
  962. }
  963. if (logs[i].type === 2) {
  964. expandDataLocal.push({
  965. key: t('日志详情'),
  966. value: other?.claude
  967. ? renderClaudeLogContent(
  968. other?.model_ratio,
  969. other.completion_ratio,
  970. other.model_price,
  971. other.group_ratio,
  972. other?.user_group_ratio,
  973. other.cache_ratio || 1.0,
  974. other.cache_creation_ratio || 1.0,
  975. )
  976. : renderLogContent(
  977. other?.model_ratio,
  978. other.completion_ratio,
  979. other.model_price,
  980. other.group_ratio,
  981. other?.user_group_ratio,
  982. false,
  983. 1.0,
  984. other.web_search || false,
  985. other.web_search_call_count || 0,
  986. other.file_search || false,
  987. other.file_search_call_count || 0,
  988. ),
  989. });
  990. }
  991. if (logs[i].type === 2) {
  992. let modelMapped =
  993. other?.is_model_mapped &&
  994. other?.upstream_model_name &&
  995. other?.upstream_model_name !== '';
  996. if (modelMapped) {
  997. expandDataLocal.push({
  998. key: t('请求并计费模型'),
  999. value: logs[i].model_name,
  1000. });
  1001. expandDataLocal.push({
  1002. key: t('实际模型'),
  1003. value: other.upstream_model_name,
  1004. });
  1005. }
  1006. let content = '';
  1007. if (other?.ws || other?.audio) {
  1008. content = renderAudioModelPrice(
  1009. other?.text_input,
  1010. other?.text_output,
  1011. other?.model_ratio,
  1012. other?.model_price,
  1013. other?.completion_ratio,
  1014. other?.audio_input,
  1015. other?.audio_output,
  1016. other?.audio_ratio,
  1017. other?.audio_completion_ratio,
  1018. other?.group_ratio,
  1019. other?.user_group_ratio,
  1020. other?.cache_tokens || 0,
  1021. other?.cache_ratio || 1.0,
  1022. );
  1023. } else if (other?.claude) {
  1024. content = renderClaudeModelPrice(
  1025. logs[i].prompt_tokens,
  1026. logs[i].completion_tokens,
  1027. other.model_ratio,
  1028. other.model_price,
  1029. other.completion_ratio,
  1030. other.group_ratio,
  1031. other?.user_group_ratio,
  1032. other.cache_tokens || 0,
  1033. other.cache_ratio || 1.0,
  1034. other.cache_creation_tokens || 0,
  1035. other.cache_creation_ratio || 1.0,
  1036. );
  1037. } else {
  1038. content = renderModelPrice(
  1039. logs[i].prompt_tokens,
  1040. logs[i].completion_tokens,
  1041. other?.model_ratio,
  1042. other?.model_price,
  1043. other?.completion_ratio,
  1044. other?.group_ratio,
  1045. other?.user_group_ratio,
  1046. other?.cache_tokens || 0,
  1047. other?.cache_ratio || 1.0,
  1048. other?.image || false,
  1049. other?.image_ratio || 0,
  1050. other?.image_output || 0,
  1051. other?.web_search || false,
  1052. other?.web_search_call_count || 0,
  1053. other?.web_search_price || 0,
  1054. other?.file_search || false,
  1055. other?.file_search_call_count || 0,
  1056. other?.file_search_price || 0,
  1057. other?.audio_input_seperate_price || false,
  1058. other?.audio_input_token_count || 0,
  1059. other?.audio_input_price || 0,
  1060. );
  1061. }
  1062. expandDataLocal.push({
  1063. key: t('计费过程'),
  1064. value: content,
  1065. });
  1066. if (other?.reasoning_effort) {
  1067. expandDataLocal.push({
  1068. key: t('Reasoning Effort'),
  1069. value: other.reasoning_effort,
  1070. });
  1071. }
  1072. }
  1073. expandDatesLocal[logs[i].key] = expandDataLocal;
  1074. }
  1075. setExpandData(expandDatesLocal);
  1076. setLogs(logs);
  1077. };
  1078. const loadLogs = async (startIdx, pageSize, customLogType = null) => {
  1079. setLoading(true);
  1080. let url = '';
  1081. const {
  1082. username,
  1083. token_name,
  1084. model_name,
  1085. start_timestamp,
  1086. end_timestamp,
  1087. channel,
  1088. group,
  1089. logType: formLogType,
  1090. } = getFormValues();
  1091. // 使用传入的 logType 或者表单中的 logType 或者状态中的 logType
  1092. const currentLogType =
  1093. customLogType !== null
  1094. ? customLogType
  1095. : formLogType !== undefined
  1096. ? formLogType
  1097. : logType;
  1098. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  1099. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  1100. if (isAdminUser) {
  1101. 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}`;
  1102. } else {
  1103. 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}`;
  1104. }
  1105. url = encodeURI(url);
  1106. const res = await API.get(url);
  1107. const { success, message, data } = res.data;
  1108. if (success) {
  1109. const newPageData = data.items;
  1110. setActivePage(data.page);
  1111. setPageSize(data.page_size);
  1112. setLogCount(data.total);
  1113. setLogsFormat(newPageData);
  1114. } else {
  1115. showError(message);
  1116. }
  1117. setLoading(false);
  1118. };
  1119. const handlePageChange = (page) => {
  1120. setActivePage(page);
  1121. loadLogs(page, pageSize).then((r) => { }); // 不传入logType,让其从表单获取最新值
  1122. };
  1123. const handlePageSizeChange = async (size) => {
  1124. localStorage.setItem('page-size', size + '');
  1125. setPageSize(size);
  1126. setActivePage(1);
  1127. loadLogs(activePage, size)
  1128. .then()
  1129. .catch((reason) => {
  1130. showError(reason);
  1131. });
  1132. };
  1133. const refresh = async () => {
  1134. setActivePage(1);
  1135. handleEyeClick();
  1136. await loadLogs(1, pageSize); // 不传入logType,让其从表单获取最新值
  1137. };
  1138. const copyText = async (e, text) => {
  1139. e.stopPropagation();
  1140. if (await copy(text)) {
  1141. showSuccess('已复制:' + text);
  1142. } else {
  1143. Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
  1144. }
  1145. };
  1146. useEffect(() => {
  1147. const localPageSize =
  1148. parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
  1149. setPageSize(localPageSize);
  1150. loadLogs(activePage, localPageSize)
  1151. .then()
  1152. .catch((reason) => {
  1153. showError(reason);
  1154. });
  1155. }, []);
  1156. // 当 formApi 可用时,初始化统计
  1157. useEffect(() => {
  1158. if (formApi) {
  1159. handleEyeClick();
  1160. }
  1161. }, [formApi]);
  1162. const expandRowRender = (record, index) => {
  1163. return <Descriptions data={expandData[record.key]} />;
  1164. };
  1165. // 检查是否有任何记录有展开内容
  1166. const hasExpandableRows = () => {
  1167. return logs.some(
  1168. (log) => expandData[log.key] && expandData[log.key].length > 0,
  1169. );
  1170. };
  1171. const [compactMode, setCompactMode] = useTableCompactMode('logs');
  1172. return (
  1173. <>
  1174. {renderColumnSelector()}
  1175. <Card
  1176. className='!rounded-2xl mb-4'
  1177. title={
  1178. <div className='flex flex-col w-full'>
  1179. <Spin spinning={loadingStat}>
  1180. <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
  1181. <Space>
  1182. <Tag
  1183. color='blue'
  1184. style={{
  1185. fontWeight: 500,
  1186. boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
  1187. padding: 15,
  1188. }}
  1189. className='!rounded-lg'
  1190. >
  1191. {t('消耗额度')}: {renderQuota(stat.quota)}
  1192. </Tag>
  1193. <Tag
  1194. color='pink'
  1195. style={{
  1196. fontWeight: 500,
  1197. boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
  1198. padding: 15,
  1199. }}
  1200. className='!rounded-lg'
  1201. >
  1202. RPM: {stat.rpm}
  1203. </Tag>
  1204. <Tag
  1205. color='white'
  1206. style={{
  1207. border: 'none',
  1208. boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
  1209. fontWeight: 500,
  1210. padding: 15,
  1211. }}
  1212. className='!rounded-lg'
  1213. >
  1214. TPM: {stat.tpm}
  1215. </Tag>
  1216. </Space>
  1217. <Button
  1218. theme='light'
  1219. type='secondary'
  1220. className="w-full md:w-auto"
  1221. onClick={() => setCompactMode(!compactMode)}
  1222. size="small"
  1223. >
  1224. {compactMode ? t('自适应列表') : t('紧凑列表')}
  1225. </Button>
  1226. </div>
  1227. </Spin>
  1228. <Divider margin='12px' />
  1229. {/* 搜索表单区域 */}
  1230. <Form
  1231. initValues={formInitValues}
  1232. getFormApi={(api) => setFormApi(api)}
  1233. onSubmit={refresh}
  1234. allowEmpty={true}
  1235. autoComplete='off'
  1236. layout='vertical'
  1237. trigger='change'
  1238. stopValidateWithError={false}
  1239. >
  1240. <div className='flex flex-col gap-4'>
  1241. <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
  1242. {/* 时间选择器 */}
  1243. <div className='col-span-1 lg:col-span-2'>
  1244. <Form.DatePicker
  1245. field='dateRange'
  1246. className='w-full'
  1247. type='dateTimeRange'
  1248. placeholder={[t('开始时间'), t('结束时间')]}
  1249. showClear
  1250. pure
  1251. size="small"
  1252. />
  1253. </div>
  1254. {/* 其他搜索字段 */}
  1255. <Form.Input
  1256. field='token_name'
  1257. prefix={<IconSearch />}
  1258. placeholder={t('令牌名称')}
  1259. showClear
  1260. pure
  1261. size="small"
  1262. />
  1263. <Form.Input
  1264. field='model_name'
  1265. prefix={<IconSearch />}
  1266. placeholder={t('模型名称')}
  1267. showClear
  1268. pure
  1269. size="small"
  1270. />
  1271. <Form.Input
  1272. field='group'
  1273. prefix={<IconSearch />}
  1274. placeholder={t('分组')}
  1275. showClear
  1276. pure
  1277. size="small"
  1278. />
  1279. {isAdminUser && (
  1280. <>
  1281. <Form.Input
  1282. field='channel'
  1283. prefix={<IconSearch />}
  1284. placeholder={t('渠道 ID')}
  1285. showClear
  1286. pure
  1287. size="small"
  1288. />
  1289. <Form.Input
  1290. field='username'
  1291. prefix={<IconSearch />}
  1292. placeholder={t('用户名称')}
  1293. showClear
  1294. pure
  1295. size="small"
  1296. />
  1297. </>
  1298. )}
  1299. </div>
  1300. {/* 操作按钮区域 */}
  1301. <div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3'>
  1302. {/* 日志类型选择器 */}
  1303. <div className='w-full sm:w-auto'>
  1304. <Form.Select
  1305. field='logType'
  1306. placeholder={t('日志类型')}
  1307. className='w-full sm:w-auto min-w-[120px]'
  1308. showClear
  1309. pure
  1310. onChange={() => {
  1311. // 延迟执行搜索,让表单值先更新
  1312. setTimeout(() => {
  1313. refresh();
  1314. }, 0);
  1315. }}
  1316. size="small"
  1317. >
  1318. <Form.Select.Option value='0'>
  1319. {t('全部')}
  1320. </Form.Select.Option>
  1321. <Form.Select.Option value='1'>
  1322. {t('充值')}
  1323. </Form.Select.Option>
  1324. <Form.Select.Option value='2'>
  1325. {t('消费')}
  1326. </Form.Select.Option>
  1327. <Form.Select.Option value='3'>
  1328. {t('管理')}
  1329. </Form.Select.Option>
  1330. <Form.Select.Option value='4'>
  1331. {t('系统')}
  1332. </Form.Select.Option>
  1333. <Form.Select.Option value='5'>
  1334. {t('错误')}
  1335. </Form.Select.Option>
  1336. </Form.Select>
  1337. </div>
  1338. <div className='flex gap-2 w-full sm:w-auto justify-end'>
  1339. <Button
  1340. type='primary'
  1341. htmlType='submit'
  1342. loading={loading}
  1343. size="small"
  1344. >
  1345. {t('查询')}
  1346. </Button>
  1347. <Button
  1348. theme='light'
  1349. onClick={() => {
  1350. if (formApi) {
  1351. formApi.reset();
  1352. setLogType(0);
  1353. setTimeout(() => {
  1354. refresh();
  1355. }, 100);
  1356. }
  1357. }}
  1358. size="small"
  1359. >
  1360. {t('重置')}
  1361. </Button>
  1362. <Button
  1363. theme='light'
  1364. type='tertiary'
  1365. onClick={() => setShowColumnSelector(true)}
  1366. size="small"
  1367. >
  1368. {t('列设置')}
  1369. </Button>
  1370. </div>
  1371. </div>
  1372. </div>
  1373. </Form>
  1374. </div>
  1375. }
  1376. shadows='always'
  1377. bordered={false}
  1378. >
  1379. <Table
  1380. columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
  1381. {...(hasExpandableRows() && {
  1382. expandedRowRender: expandRowRender,
  1383. expandRowByClick: true,
  1384. rowExpandable: (record) =>
  1385. expandData[record.key] && expandData[record.key].length > 0,
  1386. })}
  1387. dataSource={logs}
  1388. rowKey='key'
  1389. loading={loading}
  1390. scroll={compactMode ? undefined : { x: 'max-content' }}
  1391. className='rounded-xl overflow-hidden'
  1392. size='middle'
  1393. empty={
  1394. <Empty
  1395. image={
  1396. <IllustrationNoResult style={{ width: 150, height: 150 }} />
  1397. }
  1398. darkModeImage={
  1399. <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
  1400. }
  1401. description={t('搜索无结果')}
  1402. style={{ padding: 30 }}
  1403. />
  1404. }
  1405. pagination={{
  1406. formatPageText: (page) =>
  1407. t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
  1408. start: page.currentStart,
  1409. end: page.currentEnd,
  1410. total: logCount,
  1411. }),
  1412. currentPage: activePage,
  1413. pageSize: pageSize,
  1414. total: logCount,
  1415. pageSizeOptions: [10, 20, 50, 100],
  1416. showSizeChanger: true,
  1417. onPageSizeChange: (size) => {
  1418. handlePageSizeChange(size);
  1419. },
  1420. onPageChange: handlePageChange,
  1421. }}
  1422. />
  1423. </Card>
  1424. </>
  1425. );
  1426. };
  1427. export default LogsTable;