From 8464e0ea3fbf1641d1c10e9faf76268c60990102 Mon Sep 17 00:00:00 2001 From: jialin Date: Thu, 27 Feb 2025 11:39:38 +0800 Subject: [PATCH] feat: add download instance log button --- src/components/logs-viewer/parse-worker.ts | 36 +++++++- src/hooks/use-chunk-fetch.ts | 62 ++++++++++--- src/hooks/use-download-stream.ts | 91 +++++++++++++++++++ src/locales/en-US/common.ts | 3 +- src/locales/zh-CN/common.ts | 3 +- .../llmodels/components/instance-item.tsx | 15 ++- src/pages/llmodels/components/table-list.tsx | 10 +- src/utils/fetch-chunk-data.ts | 16 ---- 8 files changed, 201 insertions(+), 35 deletions(-) create mode 100644 src/hooks/use-download-stream.ts diff --git a/src/components/logs-viewer/parse-worker.ts b/src/components/logs-viewer/parse-worker.ts index 9a51a0f4..db9caee8 100644 --- a/src/components/logs-viewer/parse-worker.ts +++ b/src/components/logs-viewer/parse-worker.ts @@ -17,6 +17,8 @@ class AnsiParser { private isProcessing: boolean = false; private taskQueue: string[] = []; private page: number = 1; + private isComplete: boolean = false; + private chunked: boolean = true; // true: send data in chunks, false: send all data at once private pageSize: number = 500; private colorMap = { '30': 'black', @@ -46,6 +48,14 @@ class AnsiParser { this.page = page; } + public setIsCompelete(isComplete: boolean) { + this.isComplete = isComplete; + } + + public setChunked(chunked: boolean) { + this.chunked = chunked ?? true; + } + private setId() { this.uid += 1; return this.uid; @@ -147,6 +157,13 @@ class AnsiParser { }; } + private getScreenText() { + const result = this.screen + .map((row) => removeBracketsFromLine(row.join(''))) + .join('\n'); + return result; + } + private async processQueue(): Promise { if (this.isProcessing) { return; @@ -160,7 +177,9 @@ class AnsiParser { if (input) { try { const result = this.processInput(input); - self.postMessage({ result: result.data, lines: result.lines }); + if (this.chunked) { + self.postMessage({ result: result.data, lines: result.lines }); + } } catch (error) { console.error('Error processing input:', error); } @@ -174,6 +193,9 @@ class AnsiParser { this.isProcessing = false; if (this.taskQueue.length > 0) { this.processQueue(); + } else if (this.isComplete && !this.chunked) { + self.postMessage({ result: this.getScreenText() }); + this.reset(); } } @@ -187,8 +209,18 @@ class AnsiParser { const parser = new AnsiParser(); self.onmessage = function (event) { - const { inputStr, reset, page } = event.data; + const { + inputStr, + reset, + page, + isComplete = false, + chunked = true + } = event.data; + parser.setPage(page); + parser.setIsCompelete(isComplete); + parser.setChunked(chunked); + if (reset) { parser.reset(); } diff --git a/src/hooks/use-chunk-fetch.ts b/src/hooks/use-chunk-fetch.ts index 7fcac4b4..2700c1f2 100644 --- a/src/hooks/use-chunk-fetch.ts +++ b/src/hooks/use-chunk-fetch.ts @@ -2,9 +2,17 @@ import { throttle } from 'lodash'; import qs from 'query-string'; import { useEffect, useRef } from 'react'; +export interface HandlerOptions { + isComplete?: boolean | null; + percent?: number; + progress?: number; + contentLength?: number | null; +} + +type HandlerFunction = (data: any, options?: HandlerOptions) => any; interface RequestConfig { url: string; - handler: (data: any) => any; + handler: HandlerFunction; beforeReconnect?: () => void; params?: object; watch?: boolean; @@ -16,23 +24,47 @@ const useSetChunkFetch = () => { const requestConfig = useRef({}); const chunkDataRef = useRef([]); const readTextEventStreamData = async ( - reader: ReadableStreamDefaultReader, - decoder: TextDecoder, - callback: (data: any) => void, + response: Response, + callback: HandlerFunction, delay = 200 ) => { class BufferManager { private buffer: any[] = []; + private contentLength: number | null = null; + private progress: number = 0; + private percent: number = 0; + + constructor(private options: { contentLength?: string | null }) { + this.contentLength = options.contentLength + ? parseInt(options.contentLength, 10) + : null; + } + + private updateProgress(data: any) { + if (this.contentLength) { + this.progress += new TextEncoder().encode(data).length; + this.percent = Math.floor((this.progress / this.contentLength) * 100); + } + } public add(data: any) { this.buffer.push(data); + this.updateProgress(data); } - public flush() { + public flush(done?: boolean) { if (this.buffer.length > 0) { const currentBuffer = [...this.buffer]; this.buffer = []; - currentBuffer.forEach((item) => callback(item)); + currentBuffer.forEach((item, i) => { + const isComplete = i === currentBuffer.length - 1 && done; + callback(item, { + isComplete, + percent: this.percent, + progress: this.progress, + contentLength: this.contentLength + }); + }); } } @@ -40,7 +72,15 @@ const useSetChunkFetch = () => { return this.buffer; } } - const bufferManager = new BufferManager(); + + const reader = + response?.body?.getReader() as ReadableStreamDefaultReader; + const decoder = new TextDecoder('utf-8'); + const contentLength = response.headers.get('content-length'); + + const bufferManager = new BufferManager({ + contentLength: contentLength + }); const throttledCallback = throttle(() => { bufferManager.flush(); @@ -53,7 +93,7 @@ const useSetChunkFetch = () => { if (done) { isReading = false; - bufferManager.flush(); + bufferManager.flush(done); break; } @@ -97,11 +137,7 @@ const useSetChunkFetch = () => { return; } - const reader = - response?.body?.getReader() as ReadableStreamDefaultReader; - const decoder = new TextDecoder('utf-8'); - - await readTextEventStreamData(reader, decoder, handler); + await readTextEventStreamData(response, handler); console.log('chunkDataRef.current===1', chunkDataRef.current); } catch (error) { diff --git a/src/hooks/use-download-stream.ts b/src/hooks/use-download-stream.ts new file mode 100644 index 00000000..f67919a5 --- /dev/null +++ b/src/hooks/use-download-stream.ts @@ -0,0 +1,91 @@ +import useSetChunkFetch, { HandlerOptions } from '@/hooks/use-chunk-fetch'; +import dayjs from 'dayjs'; +import { useEffect, useRef } from 'react'; + +export default function useDownloadStream() { + const chunkRequedtRef = useRef(null); + const logParseWorker = useRef(null); + const clearScreen = useRef(false); + const filename = useRef('log'); + const { setChunkFetch } = useSetChunkFetch(); + + const downloadFile = (content: string) => { + const timestamp = dayjs().format('YYYY-MM-DD_HH-mm-ss'); + const fileName = `${filename.current}_${timestamp}.txt`; + + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + URL.revokeObjectURL(url); + }; + + const updateContent = (data: string, options?: HandlerOptions) => { + const { isComplete } = options || {}; + logParseWorker.current?.postMessage({ + inputStr: data, + page: 1, + reset: clearScreen.current, + isComplete: isComplete, + chunked: false + }); + clearScreen.current = false; + }; + + const downloadStream = async (props: { + data?: any; + url: string; + params?: any; + signal?: AbortSignal; + method?: string; + headers?: any; + filename?: string; + }) => { + clearScreen.current = true; + filename.current = props.filename || 'log'; + const { params, url } = props; + + chunkRequedtRef.current?.current?.abort?.(); + + chunkRequedtRef.current = setChunkFetch({ + url, + params, + watch: false, + contentType: 'text', + handler: updateContent + }); + }; + + useEffect(() => { + logParseWorker.current?.terminate?.(); + + logParseWorker.current = new Worker( + // @ts-ignore + new URL('@/components/logs-viewer/parse-worker.ts', import.meta.url), + { + type: 'module' + } + ); + + logParseWorker.current.onmessage = (event: any) => { + const { result } = event.data; + downloadFile(result); + }; + + return () => { + if (logParseWorker.current) { + logParseWorker.current.terminate(); + } + }; + }, []); + + return { + downloadStream + }; +} diff --git a/src/locales/en-US/common.ts b/src/locales/en-US/common.ts index 617f1f96..7b07dff8 100644 --- a/src/locales/en-US/common.ts +++ b/src/locales/en-US/common.ts @@ -227,5 +227,6 @@ export default { 'common.options.all': 'All', 'common.options.none': 'None', 'common.options.auto': 'Auto', - 'common.search.empty': 'No matching results found.' + 'common.search.empty': 'No matching results found.', + 'common.button.downloadLog': 'Download Log' }; diff --git a/src/locales/zh-CN/common.ts b/src/locales/zh-CN/common.ts index fb61191e..0ee6396e 100644 --- a/src/locales/zh-CN/common.ts +++ b/src/locales/zh-CN/common.ts @@ -220,5 +220,6 @@ export default { 'common.options.all': '全部', 'common.options.none': '无', 'common.options.auto': '自动', - 'common.search.empty': '未找到匹配结果' + 'common.search.empty': '未找到匹配结果', + 'common.button.downloadLog': '下载日志' }; diff --git a/src/pages/llmodels/components/instance-item.tsx b/src/pages/llmodels/components/instance-item.tsx index 10b34946..764c4769 100644 --- a/src/pages/llmodels/components/instance-item.tsx +++ b/src/pages/llmodels/components/instance-item.tsx @@ -10,6 +10,7 @@ import { } from '@/pages/resources/config/types'; import { DeleteOutlined, + DownloadOutlined, HddFilled, InfoCircleOutlined, ThunderboltFilled @@ -48,6 +49,18 @@ const childActionList = [ ], icon: }, + { + label: 'common.button.downloadLog', + key: 'download', + status: [ + InstanceStatusMap.Initializing, + InstanceStatusMap.Running, + InstanceStatusMap.Error, + InstanceStatusMap.Starting, + InstanceStatusMap.Downloading + ], + icon: + }, { label: 'common.button.delrecreate', key: 'delete', @@ -60,7 +73,7 @@ const childActionList = [ const setChildActionList = (item: ModelInstanceListItem) => { return _.filter(childActionList, (action: any) => { - if (action.key === 'viewlog') { + if (action.key === 'viewlog' || action.key === 'download') { return action.status.includes(item.state); } return true; diff --git a/src/pages/llmodels/components/table-list.tsx b/src/pages/llmodels/components/table-list.tsx index a01a0235..930030d5 100644 --- a/src/pages/llmodels/components/table-list.tsx +++ b/src/pages/llmodels/components/table-list.tsx @@ -10,6 +10,7 @@ import { SealColumnProps } from '@/components/seal-table/types'; import { PageAction } from '@/config'; import HotKeys from '@/config/hotkeys'; import useBodyScroll from '@/hooks/use-body-scroll'; +import useDownloadStream from '@/hooks/use-download-stream'; import useExpandedRowKeys from '@/hooks/use-expanded-row-keys'; import useTableRowSelection from '@/hooks/use-table-row-selection'; import useTableSort from '@/hooks/use-table-sort'; @@ -181,6 +182,7 @@ const Models: React.FC = ({ loadend, total }) => { + const { downloadStream } = useDownloadStream(); const { getGPUList, generateFormValues, gpuDeviceList } = useGenerateFormEditInitialValues(); const { saveScrollHeight, restoreScrollHeight } = useBodyScroll(); @@ -440,7 +442,7 @@ const Models: React.FC = ({ restoreScrollHeight(); } catch (error) {} }, - [currentData] + [handleSearch] ); const handleModalCancel = useCallback(() => { @@ -652,6 +654,12 @@ const Models: React.FC = ({ if (val === 'viewlog') { handleViewLogs(row); } + if (val === 'download') { + downloadStream({ + url: `${MODEL_INSTANCE_API}/${row.id}/logs`, + filename: row.name + }); + } }, [handleViewLogs, handleDeleteInstace] ); diff --git a/src/utils/fetch-chunk-data.ts b/src/utils/fetch-chunk-data.ts index 8644d2b9..2bd17ee8 100644 --- a/src/utils/fetch-chunk-data.ts +++ b/src/utils/fetch-chunk-data.ts @@ -314,19 +314,3 @@ export const readLargeStreamData = async ( } } }; - -export const readTextEventStreamData = async ( - reader: any, - decoder: TextDecoder, - callback: (data: any) => void -) => { - const { done, value } = await reader.read(); - - if (done) { - return; - } - - let chunk = decoder.decode(value, { stream: true }); - callback(chunk); - await readTextEventStreamData(reader, decoder, callback); -};