From 0de0426f722f03c7566bfff0f4eaa9f757d41033 Mon Sep 17 00:00:00 2001 From: jialin Date: Sun, 12 Jan 2025 17:03:18 +0800 Subject: [PATCH] fix: logs page by the data after parsing --- package.json | 1 + pnpm-lock.yaml | 11 + src/components/logs-viewer/logs-list.tsx | 6 +- .../logs-viewer/parse-worker-copy.ts | 164 ++++++++ src/components/logs-viewer/parse-worker.ts | 230 ++++++----- .../logs-viewer/styles/logs-list.less | 16 + .../logs-viewer/styles/xterm-viewer.less | 50 +++ src/components/logs-viewer/virtual-inner.tsx | 74 ++-- .../logs-viewer/virtual-log-list-copy.tsx | 377 ++++++++++++++++++ .../logs-viewer/virtual-log-list.tsx | 241 ++++------- src/components/logs-viewer/xterm-viewer.tsx | 181 +++++++++ .../llmodels/components/view-logs-modal.tsx | 24 +- 12 files changed, 1063 insertions(+), 312 deletions(-) create mode 100644 src/components/logs-viewer/parse-worker-copy.ts create mode 100644 src/components/logs-viewer/styles/xterm-viewer.less create mode 100644 src/components/logs-viewer/virtual-log-list-copy.tsx create mode 100644 src/components/logs-viewer/xterm-viewer.tsx diff --git a/package.json b/package.json index 0304a2b7..89dc8079 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/lodash": "^4.17.4", "@umijs/max": "^4.2.11", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "ansi-to-html": "^0.7.2", "antd": "^5.21.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ae04fee..c90364ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ dependencies: '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/addon-webgl': + specifier: ^0.18.0 + version: 0.18.0(@xterm/xterm@5.5.0) '@xterm/xterm': specifier: ^5.5.0 version: 5.5.0 @@ -7214,6 +7217,14 @@ packages: '@xterm/xterm': 5.5.0 dev: false + /@xterm/addon-webgl@0.18.0(@xterm/xterm@5.5.0): + resolution: {integrity: sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==, tarball: https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz} + peerDependencies: + '@xterm/xterm': ^5.0.0 + dependencies: + '@xterm/xterm': 5.5.0 + dev: false + /@xterm/xterm@5.5.0: resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==, tarball: https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz} dev: false diff --git a/src/components/logs-viewer/logs-list.tsx b/src/components/logs-viewer/logs-list.tsx index 94cd9042..1d419a42 100644 --- a/src/components/logs-viewer/logs-list.tsx +++ b/src/components/logs-viewer/logs-list.tsx @@ -16,10 +16,11 @@ interface LogsListProps { height?: number; onScroll?: (data: { isTop: boolean; isBottom: boolean }) => void; diffHeight?: number; + showNum?: boolean; ref?: any; } const LogsList: React.FC = forwardRef((props, ref) => { - const { dataList, height, onScroll, diffHeight = 96 } = props; + const { dataList, height, showNum, onScroll, diffHeight = 96 } = props; const { initialize, updateScrollerPosition, @@ -83,7 +84,6 @@ const LogsList: React.FC = forwardRef((props, ref) => { isBottom: false }); } - // debounceResetStopScroll(); }, [debounceResetStopScroll, scrollEventElement] ); @@ -136,7 +136,7 @@ const LogsList: React.FC = forwardRef((props, ref) => {
{_.map(dataList, (item: any, index: number) => { return ( -
+
{item.content}
); diff --git a/src/components/logs-viewer/parse-worker-copy.ts b/src/components/logs-viewer/parse-worker-copy.ts new file mode 100644 index 00000000..e179653f --- /dev/null +++ b/src/components/logs-viewer/parse-worker-copy.ts @@ -0,0 +1,164 @@ +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 rawDataRows = 1; + + // 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') { + rawDataRows++; + 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]; + + // 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 { + data: result, + lines: rawDataRows + }; +}; + +self.onmessage = function (event) { + const { inputStr } = event.data; + + const { data: parsedData, lines } = parseAnsi(inputStr, setId); + + const result = parsedData.map((item) => ({ + content: item.content, + uid: item.uid + })); + self.postMessage({ + result, + lines + }); +}; + +self.onerror = function (event) { + console.error('parse logs error===', event); +}; diff --git a/src/components/logs-viewer/parse-worker.ts b/src/components/logs-viewer/parse-worker.ts index e179653f..365d84d2 100644 --- a/src/components/logs-viewer/parse-worker.ts +++ b/src/components/logs-viewer/parse-worker.ts @@ -1,162 +1,156 @@ 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; -}; +class AnsiParser { + private cursorRow: number = 0; + private cursorCol: number = 0; + private screen: string[][] = [['']]; + private rawDataRows: number = 0; + private uid: number = 0; + private isProcessing: boolean = false; + private taskQueue: string[] = []; -const parseAnsi = (input: string, setId: () => number) => { - let cursorRow = 0; - let cursorCol = 0; - let screen = [['']]; - let lastIndex = 0; - let rawDataRows = 1; + constructor() { + this.reset(); + } - // handle the \r and \n characters in the text - const handleText = (text: string) => { - let processed = ''; - for (let char of text) { + public reset() { + this.cursorRow = 0; + this.cursorCol = 0; + this.screen = [['']]; + this.rawDataRows = 0; + this.uid = 0; + } + + private setId() { + this.uid += 1; + return this.uid; + } + + private handleText(text: string) { + for (const char of text) { if (char === '\r') { - cursorCol = 0; // move to the beginning of the line + this.cursorCol = 0; // move to the beginning of the line } else if (char === '\n') { - rawDataRows++; - 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 + this.rawDataRows++; + this.cursorRow++; + this.cursorCol = 0; // back to the beginning of the line + if (!this.screen[this.cursorRow]) { + this.screen[this.cursorRow] = ['']; + } } else { - // add the character to the screen content array - screen[cursorRow][cursorCol] = char; - cursorCol++; + const currentLine = this.screen[this.cursorRow]; + currentLine[this.cursorCol] = char; + this.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; + private handleAnsiSequence(match: RegExpExecArray, isEnd: boolean) { + const n = parseInt(match[1] || '1', 10); + const m = parseInt(match[2] || '1', 10); const command = match[3]; - // handle ANSI control characters switch (command) { - case 'A': // up - cursorRow = Math.max(0, cursorRow - n); - if (cursorRow === 0) { - // screen = [['']]; - // cursorCol = 0; - } + case 'A': + this.cursorRow = Math.max(0, this.cursorRow - n); break; - case 'B': // down - cursorRow += n; + case 'B': + this.cursorRow += n; break; - case 'C': // right - cursorCol += n; + case 'C': // move the cursor to the right + this.cursorCol += n; break; - case 'D': // left - cursorCol = Math.max(0, cursorCol - n); + case 'D': // move the cursor to the left + this.cursorCol = Math.max(0, this.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); + this.cursorRow = Math.max(0, n - 1); + this.cursorCol = Math.max(0, m - 1); break; case 'J': // clear the screen if (n === 2) { - console.log('clear===='); - screen = [['']]; - cursorRow = 0; - cursorCol = 0; + this.reset(); } break; - case 'm': - // if (match[1] === '0') { - // currentStyle = ''; - // } else if (colorMap[match[1]]) { - // currentStyle = `color: ${colorMap[match[1]]};`; - // } + case 'm': // style: do not handle now break; } - // check if the row and column are within the screen content array - while (screen.length <= cursorRow) { - screen.push(['']); + while (this.screen.length <= this.cursorRow && !isEnd) { + this.screen.push(['']); } - while (screen[cursorRow].length <= cursorCol) { - screen[cursorRow].push(''); + while (this.screen[this.cursorRow].length <= this.cursorCol && !isEnd) { + this.screen[this.cursorRow].push(''); } } - // handle the remaining text - output += handleText(input.slice(lastIndex)); + private processInput(input: string) { + let match: RegExpExecArray | null; + let lastIndex = 0; - let result = []; - for (let row = 0; row < screen.length; row++) { - let rowContent = screen[row].join(''); - result.push({ - content: removeBrackets(rowContent), - uid: setId() - }); + while ((match = controlSeqRegex.exec(input)) !== null) { + const textBeforeControl = input.slice(lastIndex, match.index); + this.handleText(textBeforeControl); + + lastIndex = controlSeqRegex.lastIndex; + + this.handleAnsiSequence(match, lastIndex === input.length - 1); + } + + const remainingText = input.slice(lastIndex); + this.handleText(remainingText); + + const result = this.screen.map((row) => ({ + content: removeBrackets(row.join('')), + uid: this.setId() + })); + + return { + data: result, + lines: this.rawDataRows + }; } - result.push({ - content: output, - uid: setId() - }); - return { - data: result, - lines: rawDataRows - }; -}; + private async processQueue(): Promise { + if (this.isProcessing) { + return; + } + + this.isProcessing = true; + + while (this.taskQueue.length > 0) { + const input = this.taskQueue.join(''); + this.taskQueue = []; + const result = this.processInput(input); + + self.postMessage({ + result: result.data, + lines: result.lines + }); + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + + this.isProcessing = false; + } + + public enqueueData(input: string): void { + this.taskQueue.push(input); + this.processQueue(); + } +} +const parser = new AnsiParser(); self.onmessage = function (event) { const { inputStr } = event.data; - const { data: parsedData, lines } = parseAnsi(inputStr, setId); - - const result = parsedData.map((item) => ({ - content: item.content, - uid: item.uid - })); - self.postMessage({ - result, - lines - }); + parser.enqueueData(inputStr); }; self.onerror = function (event) { diff --git a/src/components/logs-viewer/styles/logs-list.less b/src/components/logs-viewer/styles/logs-list.less index f20bc1e5..e1c71004 100644 --- a/src/components/logs-viewer/styles/logs-list.less +++ b/src/components/logs-viewer/styles/logs-list.less @@ -15,6 +15,22 @@ .text { min-height: 22px; + + &.numable { + position: relative; + padding-left: 45px; + + .line-num { + display: flex; + align-items: center; + width: 40px; + justify-content: center; + position: absolute; + left: 0; + top: 0; + background-color: rgba(71, 71, 71, 50%); + } + } } color: var(--color-logs-text); diff --git a/src/components/logs-viewer/styles/xterm-viewer.less b/src/components/logs-viewer/styles/xterm-viewer.less new file mode 100644 index 00000000..058e2b9d --- /dev/null +++ b/src/components/logs-viewer/styles/xterm-viewer.less @@ -0,0 +1,50 @@ +.logs-viewer-wrap-w2 { + .wrap { + padding: 5px 0 5px 10px; + background-color: var(--color-logs-bg); + border-radius: var(--border-radius-mini); + overflow: hidden; + + .content { + word-wrap: break-word; + height: 100%; + + &.line-break { + word-wrap: break-word; + } + + .text { + height: 100%; + } + + color: var(--color-logs-text); + font-size: var(--font-size-small); + line-height: 22px; + white-space: pre-wrap; + background-color: var(--color-logs-bg); + } + } + + .xterm { + // height: 100% !important; + + .xterm-viewport { + overflow-y: auto !important; + + &::-webkit-scrollbar { + width: var(--scrollbar-size); + height: var(--scrollbar-size); + } + + &::-webkit-scrollbar-thumb { + background-color: var(--color-scrollbar-thumb); + border-radius: 4px; + } + + &::-webkit-scrollbar-track { + background-color: var(--color-scrollbar-track); + border-radius: 4px; + } + } + } +} diff --git a/src/components/logs-viewer/virtual-inner.tsx b/src/components/logs-viewer/virtual-inner.tsx index 6e799eca..727879f1 100644 --- a/src/components/logs-viewer/virtual-inner.tsx +++ b/src/components/logs-viewer/virtual-inner.tsx @@ -1,15 +1,24 @@ import _, { throttle } from 'lodash'; import List from 'rc-virtual-list'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react'; +import './styles/logs-list.less'; interface LogsInnerProps { + ref?: any; data: { content: string; uid: number }[]; - onScroll?: (e: any) => void; + onScroll?: (data: { isTop: boolean; isBottom: boolean }) => void; diffHeight?: number; } -const LogsInner: React.FC = (props) => { - const { data, diffHeight = 96 } = props; +const LogsInner: React.FC = forwardRef((props, ref) => { + const { data, diffHeight = 96, onScroll } = props; const viewportHeight = window.innerHeight; const viewHeight = viewportHeight - diffHeight; const [innerHieght, setInnerHeight] = useState(viewHeight); @@ -31,13 +40,21 @@ const LogsInner: React.FC = (props) => { [data, scroller.current, stopScroll.current] ); - const debounceResetStopScroll = _.debounce(() => { - stopScroll.current = false; - }, 30000); + const updataPositionToTop = useCallback( + throttle(() => { + if (!stopScroll.current && data.length > 0) { + scroller.current?.scrollTo?.({ + index: 0, + align: 'bottom' + }); + } + }, 200), + [data, scroller.current, stopScroll.current] + ); const updatePositionToTop = useCallback( - _.throttle((isTop: boolean) => { - props.onScroll?.(isTop); + _.throttle((data: { isTop: boolean; isBottom: boolean }) => { + props.onScroll?.(data); }, 200), [props.onScroll] ); @@ -61,20 +78,28 @@ const LogsInner: React.FC = (props) => { return virtualList.scrollTop <= 0; }, []); - const handleOnScroll = useCallback( - (e: any) => { - const isBottom = isScrollBottom(logsWrapper.current); - const isTop = isScrollTop(logsWrapper.current); - if (isBottom) { - stopScroll.current = false; - } else { - stopScroll.current = true; - } - debounceResetStopScroll(); - updatePositionToTop(isTop); + const handleOnScroll = useCallback((e: any) => { + const isBottom = isScrollBottom(logsWrapper.current); + const isTop = isScrollTop(logsWrapper.current); + if (isBottom) { + stopScroll.current = false; + } else { + stopScroll.current = true; + } + updatePositionToTop({ + isTop, + isBottom + }); + }, []); + + useImperativeHandle(ref, () => ({ + scrollToBottom() { + updataPositionToBottom(); }, - [debounceResetStopScroll] - ); + scrollToTop() { + updataPositionToTop(); + } + })); useEffect(() => { updataPositionToBottom(); @@ -92,7 +117,7 @@ const LogsInner: React.FC = (props) => { }; }, [diffHeight]); return ( -
+
= (props) => { itemHeight={22} height={innerHieght} itemKey="uid" + className="content" styles={{ verticalScrollBar: { width: 'var(--scrollbar-size)' @@ -118,6 +144,6 @@ const LogsInner: React.FC = (props) => {
); -}; +}); export default React.memo(LogsInner); diff --git a/src/components/logs-viewer/virtual-log-list-copy.tsx b/src/components/logs-viewer/virtual-log-list-copy.tsx new file mode 100644 index 00000000..6cd7ce41 --- /dev/null +++ b/src/components/logs-viewer/virtual-log-list-copy.tsx @@ -0,0 +1,377 @@ +import useSetChunkFetch from '@/hooks/use-chunk-fetch'; +import { Spin } from 'antd'; +import classNames from 'classnames'; +import _ from 'lodash'; +import React, { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react'; +import { controlSeqRegex, replaceLineRegex } from './config'; +import LogsList from './logs-list'; +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; + ref?: any; + tail?: number; + enableScorllLoad?: boolean; + diffHeight?: number; +} +const LogsViewer: React.FC = forwardRef((props, ref) => { + const { diffHeight, url, tail: defaultTail, enableScorllLoad = true } = props; + const { pageSize, page, setPage, setTotalPage, totalPage } = + useLogsPagination(); + const { setChunkFetch } = useSetChunkFetch(); + const chunkRequedtRef = useRef(null); + const cacheDataRef = useRef(''); + const [logs, setLogs] = useState([]); + const logParseWorker = useRef(null); + const tail = useRef(defaultTail); + const [isLoadend, setIsLoadend] = useState(false); + const [loading, setLoading] = useState(false); + const [isAtTop, setIsAtTop] = useState(false); + const [scrollPos, setScrollPos] = useState([]); + const logListRef = useRef(null); + const loadMoreDone = useRef(false); + const pageRef = useRef(page); + const totalPageRef = useRef(totalPage); + const isLoadingMoreRef = useRef(false); + const scrollPosRef = useRef({ + pos: 'bottom', + page: 1 + }); + const dataLengthRef = useRef(0); + const lineCountRef = useRef(0); + + useImperativeHandle(ref, () => ({ + abort() { + chunkRequedtRef.current?.current?.abort?.(); + logParseWorker.current?.terminate?.(); + } + })); + + useEffect(() => { + logParseWorker.current?.terminate?.(); + + logParseWorker.current = new Worker( + // @ts-ignore + new URL('./parse-worker.ts', import.meta.url), + { + type: 'module' + } + ); + + logParseWorker.current.onmessage = (event: any) => { + const { result, lines } = event.data; + lineCountRef.current = lines; + setLogs(result); + }; + + return () => { + if (logParseWorker.current) { + logParseWorker.current.terminate(); + } + }; + }, []); + + const debounceLoading = _.debounce(() => { + setLoading(false); + isLoadingMoreRef.current = false; + }, 200); + + 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 = (data: string) => { + const list = _.split(data.trim(), '\n'); + let result = ''; + + const totalPage = Math.ceil(list.length / pageSize); + + pageRef.current = totalPage; + totalPageRef.current = totalPage; + const lastPageLogs = list.slice(-pageSize).join('\n'); + + result = lastPageLogs; + + setPage(totalPage); + setTotalPage(totalPage); + setScrollPos(['bottom', totalPage]); + scrollPosRef.current = { + pos: 'bottom', + page: totalPage + }; + + return result; + }; + + const getCurrentPage = () => { + const list = _.split(cacheDataRef.current.trim(), '\n'); + const totalPage = Math.ceil(list.length / pageSize); + let newPage = pageRef.current; + if (newPage < 1) { + newPage = 1; + } + if (isLoadingMoreRef.current) { + setLoading(true); + } + const start = (newPage - 1) * pageSize; + const end = newPage * pageSize; + const currentPage = list.slice(start, end).join('\n'); + setPage(newPage); + setTotalPage(totalPage); + if ( + pageRef.current === totalPageRef.current && + scrollPosRef.current.pos === 'bottom' + ) { + setScrollPos(['bottom', newPage]); + scrollPosRef.current = { + pos: 'bottom', + page: newPage + }; + } + debounceLoading(); + pageRef.current = newPage; + logParseWorker.current.postMessage({ + inputStr: currentPage + }); + }; + + const getPrePage = useCallback(() => { + const list = _.split(cacheDataRef.current.trim(), '\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); + setScrollPos(['bottom', newPage]); + scrollPosRef.current = { + pos: 'bottom', + page: newPage + }; + pageRef.current = newPage; + logParseWorker.current.postMessage({ + inputStr: prePage + }); + }, [page, pageSize]); + + const getNextPage = useCallback(() => { + const list = _.split(cacheDataRef.current.trim(), '\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); + setScrollPos(['top', newPage]); + scrollPosRef.current = { + pos: 'top', + page: newPage + }; + pageRef.current = newPage; + logParseWorker.current.postMessage({ + inputStr: nextPage + }); + }, [totalPage, page, pageSize]); + + const handleonBackend = useCallback(() => { + const list = _.split(cacheDataRef.current.trim(), '\n'); + let newPage = totalPage; + const start = (newPage - 1) * pageSize; + const end = newPage * pageSize; + const nextPage = list.slice(start, end).join('\n'); + setPage(() => newPage); + setScrollPos(['bottom', newPage]); + scrollPosRef.current = { + pos: 'bottom', + page: newPage + }; + pageRef.current = totalPage; + totalPageRef.current = totalPage; + logParseWorker.current.postMessage({ + inputStr: nextPage + }); + }, [totalPage, page, pageSize]); + + const updateContent = (inputStr: string) => { + const data = inputStr.replace(replaceLineRegex, '\n'); + if (isClean(data)) { + cacheDataRef.current = data; + } else { + cacheDataRef.current += data; + } + if ( + pageRef.current === totalPageRef.current && + scrollPosRef.current.pos === 'bottom' + ) { + logParseWorker.current.postMessage({ + inputStr: getLastPage(cacheDataRef.current) + }); + } else { + getCurrentPage(); + } + }; + + 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( + async (data: { isTop: boolean; isBottom: boolean }) => { + const { isTop, isBottom } = data; + setIsAtTop(isTop); + console.log('scroll========', { + isTop, + isBottom, + loadMoreDone: loadMoreDone.current, + loading: loading, + lineCount: lineCountRef.current, + dataLength: dataLengthRef.current + }); + if (isBottom) { + scrollPosRef.current = { + pos: 'bottom', + page: page + }; + } else if (isTop) { + scrollPosRef.current = { + pos: 'top', + page: page + }; + } else { + scrollPosRef.current = { + pos: 'middle', + page: page + }; + } + if ( + loading || + (logs.length > 0 && + lineCountRef.current < pageSize && + !loadMoreDone.current) || + !enableScorllLoad + ) { + return; + } + + if (isTop && !loadMoreDone.current) { + tail.current = undefined; + createChunkConnection(); + loadMoreDone.current = true; + isLoadingMoreRef.current = true; + } else if (isTop && page <= totalPage && page > 1) { + // getPrePage(); + } else if (isBottom && page < totalPage) { + // getNextPage(); + } + }, + [ + loading, + logs.length, + pageSize, + enableScorllLoad, + page, + totalPage, + setScrollPos, + createChunkConnection + ] + ); + + const debouncedScroll = useCallback( + _.debounce(() => { + console.log('scrollPos+++++++++++=', scrollPos); + if (scrollPos[0] === 'top' && scrollPosRef.current.pos === 'top') { + logListRef.current?.scrollToTop(); + } + if (scrollPos[0] === 'bottom' && scrollPosRef.current.pos === 'bottom') { + logListRef.current?.scrollToBottom(); + } + }, 150), + [scrollPos] + ); + + useEffect(() => { + createChunkConnection(); + return () => { + chunkRequedtRef.current?.current?.abort?.(); + }; + }, [url, props.params]); + + useEffect(() => { + debouncedScroll(); + }, [scrollPos]); + + return ( +
+
+
+ +
+ + {totalPage > 1 && ( +
+
+ +
+
+ )} +
+
+ ); +}); + +export default memo(LogsViewer); diff --git a/src/components/logs-viewer/virtual-log-list.tsx b/src/components/logs-viewer/virtual-log-list.tsx index 6cd7ce41..8ffa9583 100644 --- a/src/components/logs-viewer/virtual-log-list.tsx +++ b/src/components/logs-viewer/virtual-log-list.tsx @@ -11,7 +11,7 @@ import React, { useRef, useState } from 'react'; -import { controlSeqRegex, replaceLineRegex } from './config'; +import { replaceLineRegex } from './config'; import LogsList from './logs-list'; import LogsPagination from './logs-pagination'; import './styles/index.less'; @@ -36,8 +36,7 @@ const LogsViewer: React.FC = forwardRef((props, ref) => { const cacheDataRef = useRef(''); const [logs, setLogs] = useState([]); const logParseWorker = useRef(null); - const tail = useRef(defaultTail); - const [isLoadend, setIsLoadend] = useState(false); + const tail = useRef(pageSize - 1); const [loading, setLoading] = useState(false); const [isAtTop, setIsAtTop] = useState(false); const [scrollPos, setScrollPos] = useState([]); @@ -46,6 +45,7 @@ const LogsViewer: React.FC = forwardRef((props, ref) => { const pageRef = useRef(page); const totalPageRef = useRef(totalPage); const isLoadingMoreRef = useRef(false); + const [currentData, setCurrentData] = useState([]); const scrollPosRef = useRef({ pos: 'bottom', page: 1 @@ -60,181 +60,70 @@ const LogsViewer: React.FC = forwardRef((props, ref) => { } })); - useEffect(() => { - logParseWorker.current?.terminate?.(); - - logParseWorker.current = new Worker( - // @ts-ignore - new URL('./parse-worker.ts', import.meta.url), - { - type: 'module' - } - ); - - logParseWorker.current.onmessage = (event: any) => { - const { result, lines } = event.data; - lineCountRef.current = lines; - setLogs(result); - }; - - return () => { - if (logParseWorker.current) { - logParseWorker.current.terminate(); - } - }; - }, []); - const debounceLoading = _.debounce(() => { setLoading(false); isLoadingMoreRef.current = false; }, 200); - 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 = (data: string) => { - const list = _.split(data.trim(), '\n'); - let result = ''; - - const totalPage = Math.ceil(list.length / pageSize); - - pageRef.current = totalPage; - totalPageRef.current = totalPage; - const lastPageLogs = list.slice(-pageSize).join('\n'); - - result = lastPageLogs; - - setPage(totalPage); - setTotalPage(totalPage); - setScrollPos(['bottom', totalPage]); - scrollPosRef.current = { - pos: 'bottom', - page: totalPage - }; - - return result; - }; - - const getCurrentPage = () => { - const list = _.split(cacheDataRef.current.trim(), '\n'); - const totalPage = Math.ceil(list.length / pageSize); - let newPage = pageRef.current; - if (newPage < 1) { - newPage = 1; + const getCurrent = useCallback(() => { + if (pageRef.current < 1) { + pageRef.current = 1; } - if (isLoadingMoreRef.current) { - setLoading(true); - } - const start = (newPage - 1) * pageSize; - const end = newPage * pageSize; - const currentPage = list.slice(start, end).join('\n'); - setPage(newPage); - setTotalPage(totalPage); - if ( - pageRef.current === totalPageRef.current && - scrollPosRef.current.pos === 'bottom' - ) { - setScrollPos(['bottom', newPage]); - scrollPosRef.current = { - pos: 'bottom', - page: newPage - }; - } - debounceLoading(); - pageRef.current = newPage; - logParseWorker.current.postMessage({ - inputStr: currentPage - }); - }; + const start = (pageRef.current - 1) * pageSize; + const end = pageRef.current * pageSize; + const currentLogs = logs.slice(start, end); + setPage(pageRef.current); + setCurrentData(currentLogs); + }, [logs, pageSize]); const getPrePage = useCallback(() => { - const list = _.split(cacheDataRef.current.trim(), '\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'); + pageRef.current = pageRef.current - 1; - setPage(() => newPage); - setScrollPos(['bottom', newPage]); + getCurrent(); + + setScrollPos(['bottom', pageRef.current]); scrollPosRef.current = { pos: 'bottom', - page: newPage + page: pageRef.current }; - pageRef.current = newPage; - logParseWorker.current.postMessage({ - inputStr: prePage - }); - }, [page, pageSize]); + }, [getCurrent]); const getNextPage = useCallback(() => { - const list = _.split(cacheDataRef.current.trim(), '\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'); + pageRef.current = pageRef.current + 1; - setPage(() => newPage); - setScrollPos(['top', newPage]); + getCurrent(); + + setScrollPos(['top', pageRef.current]); scrollPosRef.current = { pos: 'top', - page: newPage + page: pageRef.current }; - pageRef.current = newPage; - logParseWorker.current.postMessage({ - inputStr: nextPage - }); - }, [totalPage, page, pageSize]); + }, [getCurrent]); const handleonBackend = useCallback(() => { - const list = _.split(cacheDataRef.current.trim(), '\n'); - let newPage = totalPage; - const start = (newPage - 1) * pageSize; - const end = newPage * pageSize; - const nextPage = list.slice(start, end).join('\n'); - setPage(() => newPage); - setScrollPos(['bottom', newPage]); + pageRef.current = totalPageRef.current; + getCurrent(); + setPage(pageRef.current); + console.log('pageRef.current', pageRef.current); + setScrollPos(['bottom', pageRef.current]); scrollPosRef.current = { pos: 'bottom', - page: newPage + page: pageRef.current }; - pageRef.current = totalPage; - totalPageRef.current = totalPage; - logParseWorker.current.postMessage({ - inputStr: nextPage - }); - }, [totalPage, page, pageSize]); + }, [getCurrent]); const updateContent = (inputStr: string) => { const data = inputStr.replace(replaceLineRegex, '\n'); - if (isClean(data)) { - cacheDataRef.current = data; - } else { - cacheDataRef.current += data; - } - if ( - pageRef.current === totalPageRef.current && - scrollPosRef.current.pos === 'bottom' - ) { - logParseWorker.current.postMessage({ - inputStr: getLastPage(cacheDataRef.current) - }); - } else { - getCurrentPage(); + cacheDataRef.current = data; + if (isLoadingMoreRef.current) { + setLoading(true); } + logParseWorker.current.postMessage({ + inputStr: data + }); }; const createChunkConnection = async () => { - cacheDataRef.current = ''; chunkRequedtRef.current?.current?.abort?.(); chunkRequedtRef.current = setChunkFetch({ @@ -258,8 +147,7 @@ const LogsViewer: React.FC = forwardRef((props, ref) => { isBottom, loadMoreDone: loadMoreDone.current, loading: loading, - lineCount: lineCountRef.current, - dataLength: dataLengthRef.current + lineCount: lineCountRef.current }); if (isBottom) { scrollPosRef.current = { @@ -280,7 +168,7 @@ const LogsViewer: React.FC = forwardRef((props, ref) => { if ( loading || (logs.length > 0 && - lineCountRef.current < pageSize && + lineCountRef.current < pageSize - 1 && !loadMoreDone.current) || !enableScorllLoad ) { @@ -312,11 +200,10 @@ const LogsViewer: React.FC = forwardRef((props, ref) => { const debouncedScroll = useCallback( _.debounce(() => { - console.log('scrollPos+++++++++++=', scrollPos); if (scrollPos[0] === 'top' && scrollPosRef.current.pos === 'top') { logListRef.current?.scrollToTop(); } - if (scrollPos[0] === 'bottom' && scrollPosRef.current.pos === 'bottom') { + if (scrollPosRef.current.pos === 'bottom') { logListRef.current?.scrollToBottom(); } }, 150), @@ -334,21 +221,63 @@ const LogsViewer: React.FC = forwardRef((props, ref) => { debouncedScroll(); }, [scrollPos]); + useEffect(() => { + logParseWorker.current?.terminate?.(); + + logParseWorker.current = new Worker( + // @ts-ignore + new URL('./parse-worker.ts', import.meta.url), + { + type: 'module' + } + ); + + logParseWorker.current.onmessage = (event: any) => { + const { result, lines } = event.data; + lineCountRef.current = lines; + + if (pageRef.current < 1) { + pageRef.current = 1; + } + const start = (pageRef.current - 1) * pageSize; + const end = pageRef.current * pageSize; + const currentLogs = result.slice(start, end); + totalPageRef.current = Math.ceil(result.length / pageSize); + + console.log( + 'lineCountRef.current+++++++++++', + lineCountRef.current, + result.length + ); + setLogs(result); + setTotalPage(totalPageRef.current); + setPage(pageRef.current); + setCurrentData(currentLogs); + debounceLoading(); + }; + + return () => { + if (logParseWorker.current) { + logParseWorker.current.terminate(); + } + }; + }, []); + return (
{totalPage > 1 && ( diff --git a/src/components/logs-viewer/xterm-viewer.tsx b/src/components/logs-viewer/xterm-viewer.tsx new file mode 100644 index 00000000..75cc9adc --- /dev/null +++ b/src/components/logs-viewer/xterm-viewer.tsx @@ -0,0 +1,181 @@ +import useSetChunkRequest from '@/hooks/use-chunk-request'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebglAddon } from '@xterm/addon-webgl'; +import { Terminal } from '@xterm/xterm'; +import '@xterm/xterm/css/xterm.css'; +import classNames from 'classnames'; +import _ from 'lodash'; +import { + forwardRef, + memo, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react'; +import { replaceLineRegex } from './config'; +import './styles/xterm-viewer.less'; +import useSize from './use-size'; + +interface LogsViewerProps { + height: number; + content?: string; + url: string; + ref?: any; + params?: object; +} +const LogsViewer: React.FC = forwardRef((props, ref) => { + const { height, content, url } = props; + const { setChunkRequest } = useSetChunkRequest(); + const chunkRequedtRef = useRef(null); + const scroller = useRef({}); + const termRef = useRef({}); + const termwrapRef = useRef({}); + const fitAddonRef = useRef({}); + const cacheDataRef = useRef(null); + const [logs, setLogs] = useState(''); + const [loading, setLoading] = useState(false); + const size = useSize(scroller); + const logParseWorker = useRef(null); + const lineCountRef = useRef(0); + + useImperativeHandle(ref, () => ({ + abort() { + chunkRequedtRef.current?.current?.abort?.(); + logParseWorker.current?.terminate?.(); + } + })); + + useEffect(() => { + logParseWorker.current?.terminate?.(); + + logParseWorker.current = new Worker( + // @ts-ignore + new URL('./parse-worker.ts', import.meta.url), + { + type: 'module' + } + ); + + logParseWorker.current.onmessage = (event: any) => { + const { result, lines } = event.data; + lineCountRef.current = lines; + // setLogs(result.join('\n')); + console.log('res+++++++++++', result); + const data = result.map((item: any) => item.content); + termRef.current?.write?.(data.join('\n')); + }; + + return () => { + if (logParseWorker.current) { + logParseWorker.current.terminate(); + } + }; + }, []); + + const throttleScroll = _.throttle(() => { + termRef.current?.scrollToBottom?.(); + }, 100); + + const debounceLoading = _.debounce(() => { + setLoading(false); + }, 200); + + const updateContent = (inputStr: string) => { + const data = inputStr.replace(replaceLineRegex, '\n'); + cacheDataRef.current = data; + setLoading(true); + logParseWorker.current.postMessage({ + inputStr: data + }); + debounceLoading(); + }; + + const fitTerm = () => { + fitAddonRef.current?.fit?.(); + }; + + const createChunkConnection = async () => { + chunkRequedtRef.current?.current?.cancel?.(); + chunkRequedtRef.current = setChunkRequest({ + url, + params: { + ...props.params, + watch: true + }, + contentType: 'text', + handler: updateContent + }); + }; + + const initTerm = () => { + termRef.current?.dispose?.(); + termRef.current = new Terminal({ + lineHeight: 1.2, + fontSize: 13, + fontFamily: + "monospace,Menlo,Courier,'Courier New',Consolas,Monaco, 'Liberation Mono'", + disableStdin: true, + convertEol: true, + theme: { + background: '#1e1e1e', + foreground: 'rgba(255,255,255,0.8)' + }, + cursorInactiveStyle: 'none', + smoothScrollDuration: 0 + }); + fitAddonRef.current = new FitAddon(); + termRef.current.loadAddon(fitAddonRef.current); + termRef.current.loadAddon(new WebglAddon()); + termRef.current.open(termwrapRef.current); + + // add event + // termRef.current.onLineFeed((e: any) => { + // if (cacheDataRef.current) { + // throttleScroll(); + // } + // }); + }; + + const handleResize = _.throttle(() => { + fitTerm(); + }, 100); + + useEffect(() => { + createChunkConnection(); + return () => { + chunkRequedtRef.current?.current?.cancel?.(); + }; + }, [url, props.params]); + + useEffect(() => { + if (termwrapRef.current) { + initTerm(); + } + return () => { + termRef.current?.dispose?.(); + }; + }, [termwrapRef.current]); + + useEffect(() => { + if (size) { + handleResize(); + } + }, [size]); + + useEffect(() => { + // throttleScroll(); + }, [logs]); + + return ( +
+
+
+
+
+
+
+ ); +}); + +export default memo(LogsViewer); diff --git a/src/pages/llmodels/components/view-logs-modal.tsx b/src/pages/llmodels/components/view-logs-modal.tsx index 4990311d..3801f286 100644 --- a/src/pages/llmodels/components/view-logs-modal.tsx +++ b/src/pages/llmodels/components/view-logs-modal.tsx @@ -85,17 +85,19 @@ const ViewCodeModal: React.FC = (props) => { width={modalSize.width} footer={null} > - +
+ +
); };