From 5f2115fff659d66f1a365e8639be26c0d4a0bd24 Mon Sep 17 00:00:00 2001 From: jialin Date: Wed, 30 Oct 2024 17:03:00 +0800 Subject: [PATCH] fix: page got stuck when logs size too large --- package.json | 1 + pnpm-lock.yaml | 9 +- src/components/logs-viewer/config.ts | 2 + src/components/logs-viewer/index.tsx | 10 +- src/components/logs-viewer/logs-inner.tsx | 128 +++++++++++ .../logs-viewer/logs-pagination.tsx | 69 ++++++ src/components/logs-viewer/new-parse-ansi.ts | 95 +++++++++ src/components/logs-viewer/parse-ansi.ts | 47 ++-- src/components/logs-viewer/parse-worker.ts | 159 ++++++++++++++ .../logs-viewer/{ => styles}/index.less | 27 ++- .../logs-viewer/styles/pagination.less | 21 ++ .../logs-viewer/use-logs-pagination.ts | 40 ++++ .../logs-viewer/virtual-log-list.tsx | 200 ++++++++++++++++++ src/global.less | 2 +- src/locales/en-US/models.ts | 5 +- src/locales/zh-CN/models.ts | 4 +- .../dashboard/components/system-load.tsx | 4 +- .../llmodels/components/view-logs-modal.tsx | 16 +- tsconfig.json | 7 +- 19 files changed, 797 insertions(+), 49 deletions(-) create mode 100644 src/components/logs-viewer/config.ts create mode 100644 src/components/logs-viewer/logs-inner.tsx create mode 100644 src/components/logs-viewer/logs-pagination.tsx create mode 100644 src/components/logs-viewer/new-parse-ansi.ts create mode 100644 src/components/logs-viewer/parse-worker.ts rename src/components/logs-viewer/{ => styles}/index.less (76%) create mode 100644 src/components/logs-viewer/styles/pagination.less create mode 100644 src/components/logs-viewer/use-logs-pagination.ts create mode 100644 src/components/logs-viewer/virtual-log-list.tsx diff --git a/package.json b/package.json index ca9335af..18b69f2d 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "overlayscrollbars": "^2.10.0", "overlayscrollbars-react": "^0.5.6", "query-string": "^9.0.0", + "rc-resize-observer": "^1.4.0", "rc-virtual-list": "^3.14.8", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27737ef2..e6cf906b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ dependencies: query-string: specifier: ^9.0.0 version: 9.0.0 + rc-resize-observer: + specifier: ^1.4.0 + version: 1.4.0(react-dom@18.2.0)(react@18.2.0) rc-virtual-list: specifier: ^3.14.8 version: 3.14.8(react-dom@18.2.0)(react@18.2.0) @@ -15221,14 +15224,14 @@ packages: dev: false /rc-resize-observer@1.4.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-PnMVyRid9JLxFavTjeDXEXo65HCRqbmLBw9xX9gfC4BZiSzbLXKzW3jPz+J0P71pLbD5tBMTT+mkstV5gD0c9Q==} + resolution: {integrity: sha512-PnMVyRid9JLxFavTjeDXEXo65HCRqbmLBw9xX9gfC4BZiSzbLXKzW3jPz+J0P71pLbD5tBMTT+mkstV5gD0c9Q==, tarball: https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.0.tgz} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' dependencies: - '@babel/runtime': 7.24.5 + '@babel/runtime': 7.25.7 classnames: 2.5.1 - rc-util: 5.38.1(react-dom@18.2.0)(react@18.2.0) + rc-util: 5.43.0(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) resize-observer-polyfill: 1.5.1 diff --git a/src/components/logs-viewer/config.ts b/src/components/logs-viewer/config.ts new file mode 100644 index 00000000..b88588df --- /dev/null +++ b/src/components/logs-viewer/config.ts @@ -0,0 +1,2 @@ +export const controlSeqRegex = /\x1b\[(\d*);?(\d*)?([A-DJKHfm])/g; +export const replaceLineRegex = /\r\n/g; diff --git a/src/components/logs-viewer/index.tsx b/src/components/logs-viewer/index.tsx index 01ca3b51..70beec8b 100644 --- a/src/components/logs-viewer/index.tsx +++ b/src/components/logs-viewer/index.tsx @@ -4,8 +4,9 @@ import '@xterm/xterm/css/xterm.css'; import classNames from 'classnames'; import _ from 'lodash'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import './index.less'; +import { replaceLineRegex } from './config'; import useParseAnsi from './parse-ansi'; +import './styles/index.less'; interface LogsViewerProps { height: number; @@ -40,13 +41,15 @@ const LogsViewer: React.FC = (props) => { }; const updateContent = useCallback( - (data: string) => { + (inputStr: string) => { + const data = inputStr.replace(replaceLineRegex, '\n'); if (isClean(data)) { cacheDataRef.current = data; } else { cacheDataRef.current += data; } const res = parseAnsi(cacheDataRef.current, setId); + console.log('res===', res); setLogs(res); }, [setLogs, setId] @@ -126,9 +129,6 @@ const LogsViewer: React.FC = (props) => { return (
- {/* - - */}
void; + diffHeight?: number; +} + +const LogsInner: React.FC = (props) => { + const { data, diffHeight = 96 } = props; + const viewportHeight = window.innerHeight; + const viewHeight = viewportHeight - diffHeight; + const [innerHieght, setInnerHeight] = useState(viewHeight); + const scroller = useRef(null); + const stopScroll = useRef(false); + const logsWrapper = useRef(null); + + const RC_VIRTUAL_LIST_HOLDER_CLASS = '.rc-virtual-list-holder'; + + const updataPositionToBottom = useCallback( + throttle(() => { + if (!stopScroll.current && data.length > 0) { + scroller.current?.scrollTo?.({ + index: data.length - 1, + align: 'bottom' + }); + console.log('scrollToBottom', stopScroll.current, data.length); + } + }, 200), + [scroller.current, stopScroll.current, data] + ); + + const debounceResetStopScroll = _.debounce(() => { + stopScroll.current = false; + }, 30000); + + const updatePositionToTop = useCallback( + _.throttle((isTop: boolean) => { + props.onScroll?.(isTop); + }, 200), + [props.onScroll] + ); + + const isScrollBottom = useCallback((root: HTMLElement) => { + const virtualList = root.querySelector(RC_VIRTUAL_LIST_HOLDER_CLASS); + if (!virtualList) { + return false; + } + return ( + virtualList.scrollTop >= + virtualList.scrollHeight - virtualList.clientHeight + ); + }, []); + + const isScrollTop = useCallback((root: HTMLElement) => { + const virtualList = root.querySelector(RC_VIRTUAL_LIST_HOLDER_CLASS); + if (!virtualList) { + return false; + } + return virtualList.scrollTop <= 0; + }, []); + + const handleOnScroll = useCallback( + (e: any) => { + const isBottom = isScrollBottom(logsWrapper.current); + const isTop = isScrollTop(logsWrapper.current); + console.log('isBottom===', isBottom); + if (isBottom) { + stopScroll.current = false; + } else { + stopScroll.current = true; + } + debounceResetStopScroll(); + updatePositionToTop(isTop); + }, + [debounceResetStopScroll] + ); + + useEffect(() => { + if (!stopScroll.current && data.length > 0) { + updataPositionToBottom(); + console.log('updataPositionToBottom', stopScroll.current); + } + }, [updataPositionToBottom]); + + useEffect(() => { + const handleResize = throttle(() => { + const viewportHeight = window.innerHeight; + const viewHeight = viewportHeight - diffHeight; + setInnerHeight(viewHeight); + }, 100); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [diffHeight]); + return ( +
+ + {(item: any) => ( +
+ {item.content} +
+ )} +
+
+ ); +}; + +export default React.memo(LogsInner); diff --git a/src/components/logs-viewer/logs-pagination.tsx b/src/components/logs-viewer/logs-pagination.tsx new file mode 100644 index 00000000..77940825 --- /dev/null +++ b/src/components/logs-viewer/logs-pagination.tsx @@ -0,0 +1,69 @@ +import { DownOutlined, UpOutlined } from '@ant-design/icons'; +import { useIntl } from '@umijs/max'; +import { Button, Tooltip } from 'antd'; +import React from 'react'; +import './styles/pagination.less'; + +interface LogsPaginationProps { + page: number; + total: number; + pageSize?: number; + onPrev?: () => void; + onNext?: () => void; +} + +const LogsPagination: React.FC = (props) => { + const { page, total, pageSize, onNext, onPrev } = props; + const intl = useIntl(); + + const handleOnPrev = () => { + onPrev?.(); + }; + + const handleOnNext = () => { + onNext?.(); + }; + + return ( +
+ + + + + {page} /{' '} + {total} + + {page < total && ( + + + + )} +
+ ); +}; + +export default React.memo(LogsPagination); diff --git a/src/components/logs-viewer/new-parse-ansi.ts b/src/components/logs-viewer/new-parse-ansi.ts new file mode 100644 index 00000000..a443d240 --- /dev/null +++ b/src/components/logs-viewer/new-parse-ansi.ts @@ -0,0 +1,95 @@ +import { controlSeqRegex, replaceLineRegex } from './config'; + +const useParseAnsi = () => { + const removeBrackets = (str) => str?.replace?.(/^\(…\)/, ''); + + const getRowContent = (screen, row) => { + let rowContent = []; + let col = 0; + while (screen.has(`${row},${col}`)) { + rowContent.push(screen.get(`${row},${col}`)); + col++; + } + return rowContent.join(''); + }; + + const handleText = function* (text, screen, cursor) { + for (let char of text) { + if (char === '\r') { + cursor.col = 0; // Move to the beginning of the line + } else if (char === '\n') { + // Move to the next line + yield getRowContent(screen, cursor.row); + cursor.row++; + cursor.col = 0; + } else { + const position = `${cursor.row},${cursor.col}`; + screen.set(position, char); + cursor.col++; + } + } + }; + + const parseAnsi = function* (inputStr, setId) { + let cursor = { row: 0, col: 0 }; + const screen = new Map(); + let lastIndex = 0; + + // Replace \r\n with \n + const input = inputStr.replace(replaceLineRegex, '\n'); + let match; + + while ((match = controlSeqRegex.exec(input)) !== null) { + const textBeforeControl = input.slice(lastIndex, match.index); + yield* handleText(textBeforeControl, screen, cursor); + lastIndex = controlSeqRegex.lastIndex; + + const [_, n = '1', m = '1', command] = match; + const parsedN = parseInt(n, 10); + const parsedM = parseInt(m, 10); + + switch (command) { + case 'A': + cursor.row = Math.max(0, cursor.row - parsedN); + break; + case 'B': + cursor.row += parsedN; + break; + case 'C': + cursor.col += parsedN; + break; + case 'D': + cursor.col = Math.max(0, cursor.col - parsedN); + break; + case 'H': + cursor.row = Math.max(0, parsedN - 1); + cursor.col = Math.max(0, parsedM - 1); + break; + case 'J': + if (parsedN === 2) { + screen.clear(); + cursor.row = 0; + cursor.col = 0; + } + break; + case 'm': + // Skipping color handling in this basic example; can be expanded as needed + break; + } + } + + yield* handleText(input.slice(lastIndex), screen, cursor); + + // Yield remaining rows + for (let row = 0; row <= cursor.row; row++) { + yield { + content: removeBrackets(getRowContent(screen, row)), + uid: setId() + }; + } + }; + + return { parseAnsi }; +}; + +export default useParseAnsi; diff --git a/src/components/logs-viewer/parse-ansi.ts b/src/components/logs-viewer/parse-ansi.ts index 96ebeb52..7a581afc 100644 --- a/src/components/logs-viewer/parse-ansi.ts +++ b/src/components/logs-viewer/parse-ansi.ts @@ -1,31 +1,24 @@ -import { useCallback, useRef } from 'react'; - -const controlSeqRegex = /\x1b\[(\d*);?(\d*)?([A-DJKHfm])/g; +import { controlSeqRegex } from './config'; const useParseAnsi = () => { - const lastIndex = useRef(0); - - const removeBrackets = useCallback((str: string) => { + const removeBrackets = (str: string) => { return str?.replace?.(/^\(…\)/, ''); - }, []); + }; - const isClean = useCallback((ansiStr: string) => { - let input = ansiStr.replace(/\r\n/g, '\n'); + const isClean = (input: string) => { let match = controlSeqRegex.exec(input) || []; const command = match?.[3]; const n = parseInt(match?.[1], 10) || 1; return command === 'J' && n === 2; - }, []); + }; - const parseAnsi = useCallback((inputStr: string, setId: () => number) => { - let cursorRow = 0; // current row - let cursorCol = 0; // current column - // screen content array + const parseAnsi = (input: string, setId: () => number) => { + let cursorRow = 0; + let cursorCol = 0; let screen = [['']]; - // replace carriage return and newline characters in the text - let input = inputStr.replace(/\r\n/g, '\n'); + let lastIndex = 0; - lastIndex.current = 0; + // let input = inputStr.replace(replaceLineRegex, '\n'); // handle the \r and \n characters in the text const handleText = (text: string) => { @@ -67,9 +60,9 @@ const useParseAnsi = () => { // match ANSI control characters while ((match = controlSeqRegex.exec(input)) !== null) { // handle text before the control character - let textBeforeControl = input.slice(lastIndex.current, match.index); + let textBeforeControl = input.slice(lastIndex, match.index); output += handleText(textBeforeControl); // add the processed text to the output - lastIndex.current = controlSeqRegex.lastIndex; // update the last index + lastIndex = controlSeqRegex.lastIndex; // update the last index const n = parseInt(match[1], 10) || 1; const m = parseInt(match[2], 10) || 1; @@ -110,12 +103,12 @@ const useParseAnsi = () => { cursorCol = 0; } break; - case 'm': // color - if (match[1] === '0') { - currentStyle = ''; - } else if (colorMap[match[1]]) { - currentStyle = `color: ${colorMap[match[1]]};`; - } + case 'm': + // if (match[1] === '0') { + // currentStyle = ''; + // } else if (colorMap[match[1]]) { + // currentStyle = `color: ${colorMap[match[1]]};`; + // } break; } @@ -129,7 +122,7 @@ const useParseAnsi = () => { } // handle the remaining text - output += handleText(input.slice(lastIndex.current)); + output += handleText(input.slice(lastIndex)); let result = []; for (let row = 0; row < screen.length; row++) { @@ -145,7 +138,7 @@ const useParseAnsi = () => { }); return result; - }, []); + }; return { parseAnsi, isClean }; }; diff --git a/src/components/logs-viewer/parse-worker.ts b/src/components/logs-viewer/parse-worker.ts new file mode 100644 index 00000000..be5ce0b7 --- /dev/null +++ b/src/components/logs-viewer/parse-worker.ts @@ -0,0 +1,159 @@ +import { controlSeqRegex } from './config'; + +let uid = 0; + +const setId = () => { + uid += 1; + return uid; +}; + +const removeBrackets = (str: string) => { + return str?.replace?.(/^\(…\)/, ''); +}; + +const isClean = (input: string) => { + let match = controlSeqRegex.exec(input) || []; + const command = match?.[3]; + const n = parseInt(match?.[1], 10) || 1; + return command === 'J' && n === 2; +}; + +const parseAnsi = (input: string, setId: () => number) => { + let cursorRow = 0; + let cursorCol = 0; + let screen = [['']]; + let lastIndex = 0; + + // let input = inputStr.replace(replaceLineRegex, '\n'); + + // handle the \r and \n characters in the text + const handleText = (text: string) => { + let processed = ''; + for (let char of text) { + if (char === '\r') { + cursorCol = 0; // move to the beginning of the line + } else if (char === '\n') { + cursorRow++; // move to the next line + cursorCol = 0; // move to the beginning of the line + screen[cursorRow] = screen[cursorRow] || ['']; // create a new line if it does not exist + } else { + // add the character to the screen content array + screen[cursorRow][cursorCol] = char; + cursorCol++; + } + } + return processed; + }; + + let output = ''; // output text + + let match; + + // ANSI color map + const colorMap: Record = { + '30': 'black', + '31': 'red', + '32': 'green', + '33': 'yellow', + '34': 'blue', + '35': 'magenta', + '36': 'cyan', + '37': 'white' + }; + + let currentStyle = ''; // current text style + + // match ANSI control characters + while ((match = controlSeqRegex.exec(input)) !== null) { + // handle text before the control character + let textBeforeControl = input.slice(lastIndex, match.index); + output += handleText(textBeforeControl); // add the processed text to the output + lastIndex = controlSeqRegex.lastIndex; // update the last index + + const n = parseInt(match[1], 10) || 1; + const m = parseInt(match[2], 10) || 1; + const command = match[3]; + console.log('command', { + command, + cursorRow, + n + }); + + // handle ANSI control characters + switch (command) { + case 'A': // up + cursorRow = Math.max(0, cursorRow - n); + if (cursorRow === 0) { + // screen = [['']]; + // cursorCol = 0; + } + break; + case 'B': // down + cursorRow += n; + break; + case 'C': // right + cursorCol += n; + break; + case 'D': // left + cursorCol = Math.max(0, cursorCol - n); + break; + case 'H': // move the cursor to the specified position (n, m) + cursorRow = Math.max(0, n - 1); + cursorCol = Math.max(0, m - 1); + break; + case 'J': // clear the screen + if (n === 2) { + console.log('clear===='); + screen = [['']]; + cursorRow = 0; + cursorCol = 0; + } + break; + case 'm': + // if (match[1] === '0') { + // currentStyle = ''; + // } else if (colorMap[match[1]]) { + // currentStyle = `color: ${colorMap[match[1]]};`; + // } + break; + } + + // check if the row and column are within the screen content array + while (screen.length <= cursorRow) { + screen.push(['']); + } + while (screen[cursorRow].length <= cursorCol) { + screen[cursorRow].push(''); + } + } + + // handle the remaining text + output += handleText(input.slice(lastIndex)); + + let result = []; + for (let row = 0; row < screen.length; row++) { + let rowContent = screen[row].join(''); + result.push({ + content: removeBrackets(rowContent), + uid: setId() + }); + } + result.push({ + content: output, + uid: setId() + }); + + return result; +}; + +self.onmessage = function (event) { + const { inputStr } = event.data; + + const parsedData = Array.from(parseAnsi(inputStr, setId)); + + const result = parsedData.map((item) => ({ + content: item.content, + uid: item.uid + })); + self.postMessage(result); +}; diff --git a/src/components/logs-viewer/index.less b/src/components/logs-viewer/styles/index.less similarity index 76% rename from src/components/logs-viewer/index.less rename to src/components/logs-viewer/styles/index.less index e6f32236..78d1893b 100644 --- a/src/components/logs-viewer/index.less +++ b/src/components/logs-viewer/styles/index.less @@ -1,6 +1,29 @@ .logs-viewer-wrap-w2 { position: relative; + .pg { + position: absolute; + top: 16px; + right: 16px; + } + + .loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + padding-top: 100px; + display: flex; + justify-content: center; + background-color: rgba(255, 255, 255, 25%); + + .ant-spin-dot-holder { + color: rgba(255, 255, 255, 90%); + } + } + .copy { position: absolute; top: 10px; @@ -19,16 +42,16 @@ } .wrap { - padding: 5px 0 5px 10px; + padding: 5px 0 2px 10px; background-color: var(--color-logs-bg); border-radius: var(--border-radius-mini); - overflow: auto; font-family: monospace, Menlo, Courier, 'Courier New', Consolas, Monaco, 'Liberation Mono' !important; .content { word-wrap: break-word; height: 100%; + padding-right: 2px; &.line-break { word-wrap: break-word; diff --git a/src/components/logs-viewer/styles/pagination.less b/src/components/logs-viewer/styles/pagination.less new file mode 100644 index 00000000..25eed798 --- /dev/null +++ b/src/components/logs-viewer/styles/pagination.less @@ -0,0 +1,21 @@ +.pagination { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: rgba(255, 255, 255, 70%); + gap: 5px; + + .ant-btn:hover { + color: rgba(255, 255, 255, 90%) !important; + background-color: rgba(71, 71, 71, 100%) !important; + } + + .pages { + display: flex; + justify-content: center; + align-items: center; + height: 38px; + width: 38px; + } +} diff --git a/src/components/logs-viewer/use-logs-pagination.ts b/src/components/logs-viewer/use-logs-pagination.ts new file mode 100644 index 00000000..979573f2 --- /dev/null +++ b/src/components/logs-viewer/use-logs-pagination.ts @@ -0,0 +1,40 @@ +import { useState } from 'react'; + +const useLogsPagination = () => { + const [pageSize, setPageSize] = useState(1000); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(1); + + const nextPage = () => { + setPage(page + 1); + }; + + const prePage = () => { + let newPage = page - 1; + if (newPage < 1) { + newPage = 1; + } + setPage(newPage); + }; + + const resetPage = () => { + setPage(1); + }; + + const setTotalPage = (total: number) => { + setTotal(total); + }; + + return { + nextPage, + resetPage, + prePage, + setPage, + pageSize, + setTotalPage, + page, + totalPage: total + }; +}; + +export default useLogsPagination; diff --git a/src/components/logs-viewer/virtual-log-list.tsx b/src/components/logs-viewer/virtual-log-list.tsx new file mode 100644 index 00000000..786e5bbb --- /dev/null +++ b/src/components/logs-viewer/virtual-log-list.tsx @@ -0,0 +1,200 @@ +import useSetChunkFetch from '@/hooks/use-chunk-fetch'; +import useSetChunkRequest from '@/hooks/use-chunk-request'; +import { Spin } from 'antd'; +import classNames from 'classnames'; +import _ from 'lodash'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { controlSeqRegex, replaceLineRegex } from './config'; +import LogsInner from './logs-inner'; +import LogsPagination from './logs-pagination'; +import './styles/index.less'; +import useLogsPagination from './use-logs-pagination'; + +interface LogsViewerProps { + height: number; + content?: string; + url: string; + params?: object; + diffHeight?: number; +} +const LogsViewer: React.FC = (props) => { + const { diffHeight, url } = props; + const { pageSize, page, setPage, setTotalPage, totalPage } = + useLogsPagination(); + const { setChunkRequest } = useSetChunkRequest(); + const { setChunkFetch } = useSetChunkFetch(); + const chunkRequedtRef = useRef(null); + const cacheDataRef = useRef(''); + const [logs, setLogs] = useState([]); + const logParseWorker = useRef(null); + const tail = useRef(pageSize); + const isLoadend = useRef(false); + const [loading, setLoading] = useState(false); + const [isAtTop, setIsAtTop] = useState(false); + + useEffect(() => { + logParseWorker.current?.terminate?.(); + + logParseWorker.current = new Worker( + new URL('./parse-worker.ts', import.meta.url) + ); + + logParseWorker.current.onmessage = (event: any) => { + const res = event.data; + setLogs(res); + }; + + return () => { + if (logParseWorker.current) { + logParseWorker.current.terminate(); + } + }; + }, [setLogs, logParseWorker.current]); + + const debounceLoading = _.debounce(() => { + setLoading(false); + }, 100); + + const isClean = useCallback((input: string) => { + let match = controlSeqRegex.exec(input) || []; + const command = match?.[3]; + const n = parseInt(match?.[1], 10) || 1; + return command === 'J' && n === 2; + }, []); + + const getLastPage = useCallback( + (data: string) => { + const list = _.split(data, '\n'); + isLoadend.current = list.length < pageSize; + console.log('isLoadend.current===', isLoadend.current, list.length); + if (list.length <= pageSize) { + setTotalPage(1); + return data; + } + + setLoading(true); + const totalPage = Math.ceil(list.length / pageSize); + setTotalPage(totalPage); + setPage(totalPage); + const lastPage = list.slice(-pageSize).join('\n'); + debounceLoading(); + return lastPage; + }, + [pageSize, setTotalPage, setPage, debounceLoading] + ); + + const getPrePage = useCallback(() => { + const list = _.split(cacheDataRef.current, '\n'); + let newPage = page - 1; + if (newPage < 1) { + newPage = 1; + } + const start = (newPage - 1) * pageSize; + const end = newPage * pageSize; + const prePage = list.slice(start, end).join('\n'); + setPage(newPage); + logParseWorker.current.postMessage({ + inputStr: prePage + }); + }, [page, pageSize]); + + const getNextPage = useCallback(() => { + const list = _.split(cacheDataRef.current, '\n'); + let newPage = page + 1; + if (newPage > totalPage) { + newPage = totalPage; + } + const start = (newPage - 1) * pageSize; + const end = newPage * pageSize; + const nextPage = list.slice(start, end).join('\n'); + setPage(newPage); + logParseWorker.current.postMessage({ + inputStr: nextPage + }); + }, [totalPage, page, pageSize]); + + const updateContent = useCallback( + (inputStr: string) => { + console.log('data========', inputStr); + const data = inputStr.replace(replaceLineRegex, '\n'); + if (isClean(data)) { + cacheDataRef.current = data; + } else { + cacheDataRef.current += data; + } + + logParseWorker.current.postMessage({ + inputStr: getLastPage(cacheDataRef.current) + }); + }, + [getLastPage] + ); + + const createChunkConnection = async () => { + cacheDataRef.current = ''; + chunkRequedtRef.current?.current?.abort?.(); + chunkRequedtRef.current = setChunkFetch({ + url, + params: { + ...props.params, + tail: tail.current, + watch: true + }, + contentType: 'text', + handler: updateContent + }); + }; + + const handleOnScroll = useCallback( + (isTop: boolean) => { + setIsAtTop(isTop && isLoadend.current); + if (loading || isLoadend.current) { + return; + } + if (isTop && !isLoadend.current) { + tail.current = undefined; + createChunkConnection(); + } + }, + [loading, isLoadend.current] + ); + + useEffect(() => { + createChunkConnection(); + return () => { + chunkRequedtRef.current?.current?.abort?.(); + }; + }, [url, props.params]); + + return ( +
+
+
+ +
+ + {totalPage > 1 && isAtTop && ( +
+ +
+ )} +
+
+ ); +}; + +export default memo(LogsViewer); diff --git a/src/global.less b/src/global.less index 84f60e41..31e810a8 100644 --- a/src/global.less +++ b/src/global.less @@ -517,7 +517,7 @@ body { .ant-modal-centered.ant-modal-wrap { &::-webkit-scrollbar { - width: var(--scrollbar-size); + width: 0; } &::-webkit-scrollbar-thumb { diff --git a/src/locales/en-US/models.ts b/src/locales/en-US/models.ts index 0771f941..9a215a6c 100644 --- a/src/locales/en-US/models.ts +++ b/src/locales/en-US/models.ts @@ -65,5 +65,8 @@ export default { 'e.g., --ctx-size=8192', 'models.form.backend_parameters.vllm.placeholder': 'e.g., --max-model-len=8192', - 'models.form.backend_parameters.vllm.tips': 'More {backend} parameter details' + 'models.form.backend_parameters.vllm.tips': + 'More {backend} parameter details', + 'models.logs.pagination.prev': 'Previous {lines} Lines', + 'models.logs.pagination.next': 'Next {lines} Lines' }; diff --git a/src/locales/zh-CN/models.ts b/src/locales/zh-CN/models.ts index 6e105571..0e320cf2 100644 --- a/src/locales/zh-CN/models.ts +++ b/src/locales/zh-CN/models.ts @@ -64,5 +64,7 @@ export default { '例如,--ctx-size=8192', 'models.form.backend_parameters.vllm.placeholder': '例如,--max-model-len=8192', - 'models.form.backend_parameters.vllm.tips': '更多 {backend} 参数说明查看' + 'models.form.backend_parameters.vllm.tips': '更多 {backend} 参数说明查看', + 'models.logs.pagination.prev': '上一 {lines} 行', + 'models.logs.pagination.next': '下一 {lines} 行' }; diff --git a/src/pages/dashboard/components/system-load.tsx b/src/pages/dashboard/components/system-load.tsx index 5b8f7ddd..0225f605 100644 --- a/src/pages/dashboard/components/system-load.tsx +++ b/src/pages/dashboard/components/system-load.tsx @@ -11,13 +11,13 @@ import { DashboardContext } from '../config/dashboard-context'; import ResourceUtilization from './resource-utilization'; const strokeColorFunc = (percent: number) => { - if (percent <= 50) { + if (percent <= 50 || percent === undefined) { return 'rgb(84, 204, 152, 80%)'; } if (percent <= 80) { return 'rgba(250, 173, 20, 80%)'; } - return ' rgba(255, 77, 79, 80%)'; + return 'rgba(255, 77, 79, 80%)'; }; const SystemLoad = () => { diff --git a/src/pages/llmodels/components/view-logs-modal.tsx b/src/pages/llmodels/components/view-logs-modal.tsx index 9f42db79..9b332dcf 100644 --- a/src/pages/llmodels/components/view-logs-modal.tsx +++ b/src/pages/llmodels/components/view-logs-modal.tsx @@ -1,7 +1,7 @@ -import LogsViewer from '@/components/logs-viewer/index'; +import LogsViewer from '@/components/logs-viewer/virtual-log-list'; import { useIntl } from '@umijs/max'; import { Modal } from 'antd'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; type ViewModalProps = { open: boolean; @@ -18,22 +18,25 @@ const ViewCodeModal: React.FC = (props) => { }); const isFullScreenRef = React.useRef(false); const intl = useIntl(); - const handleFullscreenToggle = () => { + const viewportHeight = window.innerHeight; + const viewHeight = viewportHeight - 86; + + const handleFullscreenToggle = useCallback(() => { isFullScreenRef.current = !isFullScreenRef.current; setModalSize((size: any) => { return { width: size.width === 600 ? '100%' : 600, - height: size.height === 420 ? 'calc(100vh - 86px)' : 420 + height: size.height === 420 ? viewHeight : 420 }; }); - }; + }, []); useEffect(() => { if (open) { isFullScreenRef.current = false; setModalSize({ width: '100%', - height: 'calc(100vh - 86px)' + height: viewHeight }); } }, [open]); @@ -65,6 +68,7 @@ const ViewCodeModal: React.FC = (props) => { >