diff --git a/src/components/logs-viewer/index.less b/src/components/logs-viewer/index.less index 058e2b9d..cef8e68e 100644 --- a/src/components/logs-viewer/index.less +++ b/src/components/logs-viewer/index.less @@ -3,7 +3,8 @@ padding: 5px 0 5px 10px; background-color: var(--color-logs-bg); border-radius: var(--border-radius-mini); - overflow: hidden; + overflow: auto; + font-family: "monospace,Menlo,Courier,'Courier New',Consolas,Monaco, 'Liberation Mono'"; .content { word-wrap: break-word; @@ -14,7 +15,7 @@ } .text { - height: 100%; + min-height: 22px; } color: var(--color-logs-text); @@ -26,8 +27,6 @@ } .xterm { - // height: 100% !important; - .xterm-viewport { overflow-y: auto !important; diff --git a/src/components/logs-viewer/index.tsx b/src/components/logs-viewer/index.tsx index 517bbcb5..33e1f01d 100644 --- a/src/components/logs-viewer/index.tsx +++ b/src/components/logs-viewer/index.tsx @@ -1,19 +1,13 @@ -import useSetChunkRequest from '@/hooks/use-chunk-request'; +import useSetChunkFetch from '@/hooks/use-chunk-fetch'; +import useOverlayScroller from '@/hooks/use-overlay-scroller'; import { FitAddon } from '@xterm/addon-fit'; import { Terminal } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; import classNames from 'classnames'; import _ from 'lodash'; -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState -} from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import './index.less'; -import useSize from './use-size'; +import parseAnsi from './parse-ansi'; interface LogsViewerProps { height: number; @@ -22,39 +16,41 @@ interface LogsViewerProps { params?: object; } const LogsViewer: React.FC = (props) => { - const { height, content, url } = props; - const { setChunkRequest } = useSetChunkRequest(); + const { height, url } = props; + const { initialize, updateScrollerPosition } = useOverlayScroller({ + theme: 'os-theme-light' + }); + const { setChunkFetch } = useSetChunkFetch(); const chunkRequedtRef = useRef(null); const scroller = useRef({}); const termRef = useRef({}); const termwrapRef = useRef({}); const fitAddonRef = useRef({}); const cacheDataRef = useRef(''); - const [logs, setLogs] = useState(''); - const size = useSize(scroller); + const uidRef = useRef(0); + const [logs, setLogs] = useState([]); - const throttleScroll = _.throttle(() => { - termRef.current?.scrollToBottom?.(); - }, 100); + const setId = () => { + uidRef.current += 1; + return uidRef.current; + }; + + const clearScreen = () => { + cacheDataRef.current = ''; + }; const updateContent = useCallback( - _.throttle((data: string) => { - cacheDataRef.current = ''; - termRef.current?.clear?.(); - cacheDataRef.current = data; - termRef.current?.write?.(data); - setLogs(data); - }, 100), - [] + (data: string) => { + cacheDataRef.current += data; + const res = parseAnsi(cacheDataRef.current, setId, clearScreen); + setLogs(res); + }, + [setLogs, setId] ); - const fitTerm = () => { - fitAddonRef.current?.fit?.(); - }; - const createChunkConnection = async () => { - chunkRequedtRef.current?.current?.cancel?.(); - chunkRequedtRef.current = setChunkRequest({ + chunkRequedtRef.current?.current?.abort?.(); + chunkRequedtRef.current = setChunkFetch({ url, params: { ...props.params, @@ -65,7 +61,8 @@ const LogsViewer: React.FC = (props) => { }); }; - const initTerm = () => { + const initTerm = useCallback(() => { + termRef.current?.clear?.(); termRef.current?.dispose?.(); termRef.current = new Terminal({ lineHeight: 1.2, @@ -84,43 +81,38 @@ const LogsViewer: React.FC = (props) => { fitAddonRef.current = new FitAddon(); termRef.current.loadAddon(fitAddonRef.current); termRef.current.open(termwrapRef.current); - }; - - const handleResize = _.throttle(() => { - fitTerm(); - }, 100); + }, [termwrapRef.current]); useEffect(() => { createChunkConnection(); return () => { - chunkRequedtRef.current?.current?.cancel?.(); + chunkRequedtRef.current?.current?.abort?.(); }; }, [url, props.params]); useEffect(() => { - if (termwrapRef.current) { - initTerm(); + if (scroller.current) { + initialize(scroller.current); } - return () => { - termRef.current?.dispose?.(); - }; - }, [termwrapRef.current]); - - useLayoutEffect(() => { - if (size) { - handleResize(); - } - }, [size]); + }, [scroller.current, initialize]); useEffect(() => { - throttleScroll(); + if (logs) { + updateScrollerPosition(); + } }, [logs]); return (
-
+ {_.map(logs, (item: any, index: number) => { + return ( +
+ {item.content} +
+ ); + })}
diff --git a/src/components/logs-viewer/old.less b/src/components/logs-viewer/old.less deleted file mode 100644 index d54d3d72..00000000 --- a/src/components/logs-viewer/old.less +++ /dev/null @@ -1,22 +0,0 @@ -.logs-viewer-wrap-w2 { - .wrap { - padding: 5px 0 5px 10px; - overflow: auto; - background-color: var(--color-logs-bg); - border-radius: var(--border-radius-mini); - - .content { - word-wrap: break-word; - - &.line-break { - word-wrap: break-word; - } - - color: var(--color-logs-text); - font-size: var(--font-size-small); - line-height: 22px; - white-space: pre-wrap; - background-color: var(--color-logs-bg); - } - } -} diff --git a/src/components/logs-viewer/old.tsx b/src/components/logs-viewer/old.tsx deleted file mode 100644 index 76d10076..00000000 --- a/src/components/logs-viewer/old.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import useSetChunkFetch from '@/hooks/use-chunk-fetch'; -import useSetChunkRequest from '@/hooks/use-chunk-request'; -import useContainerScroll from '@/hooks/use-container-scorll'; -import Convert from 'ansi-to-html'; -import classNames from 'classnames'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; -import './old.less'; - -interface LogsViewerProps { - height: number; - content?: string; - url: string; - params?: object; -} -const LogsViewer: React.FC = (props) => { - const { height, content, url } = props; - const [nowrap, setNowrap] = useState(false); - const [logsContent, setLogsContent] = useState([]); - const { setChunkRequest } = useSetChunkRequest(); - const { setChunkFetch } = useSetChunkFetch(); - const chunkRequedtRef = useRef(null); - const scroller = useRef(null); - const cacheDataRef = useRef(''); - const { updateScrollerPosition, handleContentWheel } = useContainerScroll( - scroller, - { toBottom: true } - ); - - const convert = new Convert({ - newline: true, - escapeXML: true, - stream: false - }); - - useEffect(() => { - updateScrollerPosition(); - }, [logsContent]); - - const ansiEscapeRegex = /(\x1B\[A)+$/; - - const endsWithAnsiEscapeSequence = useCallback((str: string) => { - return ansiEscapeRegex.test(str); - }, []); - const getCursorUpLines = useCallback((str: string) => { - const matches = str.match(/(?:\x1B\[A)+$/); - - return matches ? matches[0].length / 3 : 0; - }, []); - - const removeDot = useCallback((str: string) => { - return str.replace(/^\(.*?\)/, ''); - }, []); - const replaceAnsiEscapeSequence = useCallback((str: string) => { - const res = str.replace(ansiEscapeRegex, ''); - return removeDot(res); - }, []); - - const handleRControl = useCallback((str: string) => { - if (str.includes('\r')) { - const parts = str.split('\r'); - const lastLine = parts[parts.length - 1]; - return lastLine; - } - return str; - }, []); - - const parseHtmlStr = useCallback((logStr: string) => { - cacheDataRef.current += logStr.replace('\r\n', '\n'); - console.log('data>>>>>>>>>>>', { - data: logStr, - cacheDataRef: cacheDataRef.current - }); - const result: string[] = []; - const lines = cacheDataRef.current - .split('\n') - .filter((line: string) => line.trim() !== ''); - // const lines = text; - lines.forEach((line: string, index: number) => { - const upCount = getCursorUpLines(line); - console.log('line=========1', { - line, - upCount, - result - }); - if (endsWithAnsiEscapeSequence(line)) { - const newLine = handleRControl(line); - const val = removeDot(newLine); - if (result.length < upCount) { - result.push(''); - } - if (upCount) { - console.log('line=========0', { - line, - upCount, - result - }); - const placeIndex = result.length - upCount; - result[placeIndex] = replaceAnsiEscapeSequence(val); - } else { - result.push(val); - } - } else { - const val = handleRControl(line); - result.push(val); - } - }); - return result.map((item) => { - return convert.toHtml(item); - }); - }, []); - - const updateContent = (newVal: string) => { - const list = parseHtmlStr(newVal); - setLogsContent(list); - }; - - const createChunkConnection = async () => { - chunkRequedtRef.current?.current?.abort?.(); - chunkRequedtRef.current = setChunkFetch({ - url, - params: { - ...props.params, - watch: true - }, - contentType: 'text', - handler: updateContent - }); - }; - - useEffect(() => { - createChunkConnection(); - return () => { - chunkRequedtRef.current?.current?.abort?.(); - }; - }, [url, props.params]); - - return ( -
-
-
-
- {logsContent.map((item, index) => { - return ( -
- ); - })} -
-
-
-
- ); -}; - -export default memo(LogsViewer); diff --git a/src/components/logs-viewer/parse-ansi.ts b/src/components/logs-viewer/parse-ansi.ts new file mode 100644 index 00000000..e8411e73 --- /dev/null +++ b/src/components/logs-viewer/parse-ansi.ts @@ -0,0 +1,130 @@ +const removeBrackets = (str: string) => { + return str?.replace?.(/^\(.*?\)/, ''); +}; +const parseAnsi = ( + inputStr: string, + setId: () => number, + clearScreen: () => void +) => { + let cursorRow = 0; // current row + let cursorCol = 0; // current column + // screen content array + let screen = [['']]; + // replace carriage return and newline characters in the text + let input = inputStr.replace(/\r\n/g, '\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; + }; + + // ANSI + const controlSeqRegex = /\x1b\[(\d*);?(\d*)?([A-DJKHfm])/g; + let output = ''; // output text + + let match; + let lastIndex = 0; + + // 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': // move the cursor up + cursorRow = Math.max(0, cursorRow - n); + break; + case 'B': // move the cursor down + cursorRow += n; + break; + case 'C': // move the cursor right + cursorCol += n; + break; + case 'D': // move the cursor 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) { + screen = [['']]; + cursorRow = 0; + cursorCol = 0; + clearScreen?.(); + } + break; + case 'm': // color + 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; +}; + +export default parseAnsi; diff --git a/src/components/logs-viewer/test.ts b/src/components/logs-viewer/test.ts deleted file mode 100644 index 22ebaa60..00000000 --- a/src/components/logs-viewer/test.ts +++ /dev/null @@ -1,186 +0,0 @@ -export default [ - '2024-09-11T20:09:42+08:00 - gpustack.worker.downloaders - INFO - Downloading model leafspark/Reflection-Llama-3.1-70B-GGUF/Reflection-Llama-3.1-70B.fp16*.gguf', - '\rFetching 4 files: 0%| | 0/4 [00:00 { const axiosToken = useRef(null); const requestConfig = useRef({}); + const completeData = useRef([]); + const chunkDataRef = useRef([]); + const conentLengthRef = useRef(0); + const receivedLengthRef = useRef(0); const readTextEventStreamData = async ( reader: any, @@ -19,16 +23,60 @@ const useSetChunkFetch = () => { callback: (data: any) => void ) => { const { done, value } = await reader.read(); - + console.log('chunkDataRef.current===1', { + data: chunkDataRef.current, + done + }); if (done) { return; } - let chunk = decoder.decode(value, { stream: true }); + const chunk = decoder.decode(value, { stream: true }); + callback(chunk); + // console.log('chunkDataRef.current===2', chunkDataRef.current); + await readTextEventStreamData(reader, decoder, callback); }; + const combineUint8Arrays = (arrays: Uint8Array[]) => { + // Calculate total length + const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); + + // Create a new Uint8Array to hold the combined data + const combined = new Uint8Array(totalLength); + + // Copy each array into the combined array + let offset = 0; + for (const arr of arrays) { + combined.set(arr, offset); + offset += arr.length; + } + + return combined; + }; + + const readUint8ArrayStreamData = async ( + reader: ReadableStreamDefaultReader, + callback: (data: Uint8Array) => void + ) => { + const { done, value } = await reader.read(); + while (true) { + if (done) { + callback(completeData.current); + break; + } + const tempData = new Uint8Array( + completeData.current.length + value.length + ); + tempData.set(completeData.current); + tempData.set(value, completeData.current.length); + completeData.current = tempData; + } + + // await readUint8ArrayStreamData(reader, callback); + }; + const fetchChunkRequest = async ({ url, handler, @@ -51,15 +99,21 @@ const useSetChunkFetch = () => { signal: axiosToken.current.signal } ); - console.log('response============', response); + if (!response.ok) { return; } + + chunkDataRef.current = ''; + conentLengthRef.current = response.headers.get('Content-Length'); + receivedLengthRef.current = 0; + console.log('conentLengthRef.current', conentLengthRef.current, response); const reader = response?.body?.getReader(); const decoder = new TextDecoder('utf-8'); await readTextEventStreamData(reader, decoder, handler); } catch (error) { // handle error + console.log('error============', error); } return axiosToken.current; diff --git a/src/hooks/use-overlay-scroller.ts b/src/hooks/use-overlay-scroller.ts index 5bf66877..7f00a918 100644 --- a/src/hooks/use-overlay-scroller.ts +++ b/src/hooks/use-overlay-scroller.ts @@ -14,6 +14,7 @@ export const overlaySollerOptions: UseOverlayScrollbarsParams = { x: 'hidden' }, scrollbars: { + theme: 'os-theme-light', autoHide: 'scroll', autoHideDelay: 600, clickScroll: 'instant' @@ -22,11 +23,25 @@ export const overlaySollerOptions: UseOverlayScrollbarsParams = { defer: true }; -export default function useOverlayScroller() { +export default function useOverlayScroller(options?: any) { const scrollEventElement = React.useRef(null); const instanceRef = React.useRef(null); const [initialize, instance] = useOverlayScrollbars({ - ...overlaySollerOptions + options: { + update: { + debounce: 0 + }, + overflow: { + x: 'hidden' + }, + scrollbars: { + theme: options?.theme || 'os-theme-dark', + autoHide: 'scroll', + autoHideDelay: 600, + clickScroll: 'instant' + } + }, + defer: true }); instanceRef.current = instance?.(); scrollEventElement.current =