feat: add download instance log button

This commit is contained in:
jialin
2025-02-27 18:03:28 +08:00
parent 4b73457f7f
commit 8464e0ea3f
8 changed files with 201 additions and 35 deletions
+34 -2
View File
@@ -17,6 +17,8 @@ class AnsiParser {
private isProcessing: boolean = false; private isProcessing: boolean = false;
private taskQueue: string[] = []; private taskQueue: string[] = [];
private page: number = 1; 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 pageSize: number = 500;
private colorMap = { private colorMap = {
'30': 'black', '30': 'black',
@@ -46,6 +48,14 @@ class AnsiParser {
this.page = page; this.page = page;
} }
public setIsCompelete(isComplete: boolean) {
this.isComplete = isComplete;
}
public setChunked(chunked: boolean) {
this.chunked = chunked ?? true;
}
private setId() { private setId() {
this.uid += 1; this.uid += 1;
return this.uid; 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<void> { private async processQueue(): Promise<void> {
if (this.isProcessing) { if (this.isProcessing) {
return; return;
@@ -160,7 +177,9 @@ class AnsiParser {
if (input) { if (input) {
try { try {
const result = this.processInput(input); 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) { } catch (error) {
console.error('Error processing input:', error); console.error('Error processing input:', error);
} }
@@ -174,6 +193,9 @@ class AnsiParser {
this.isProcessing = false; this.isProcessing = false;
if (this.taskQueue.length > 0) { if (this.taskQueue.length > 0) {
this.processQueue(); 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(); const parser = new AnsiParser();
self.onmessage = function (event) { self.onmessage = function (event) {
const { inputStr, reset, page } = event.data; const {
inputStr,
reset,
page,
isComplete = false,
chunked = true
} = event.data;
parser.setPage(page); parser.setPage(page);
parser.setIsCompelete(isComplete);
parser.setChunked(chunked);
if (reset) { if (reset) {
parser.reset(); parser.reset();
} }
+49 -13
View File
@@ -2,9 +2,17 @@ import { throttle } from 'lodash';
import qs from 'query-string'; import qs from 'query-string';
import { useEffect, useRef } from 'react'; 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 { interface RequestConfig {
url: string; url: string;
handler: (data: any) => any; handler: HandlerFunction;
beforeReconnect?: () => void; beforeReconnect?: () => void;
params?: object; params?: object;
watch?: boolean; watch?: boolean;
@@ -16,23 +24,47 @@ const useSetChunkFetch = () => {
const requestConfig = useRef<any>({}); const requestConfig = useRef<any>({});
const chunkDataRef = useRef<any>([]); const chunkDataRef = useRef<any>([]);
const readTextEventStreamData = async ( const readTextEventStreamData = async (
reader: ReadableStreamDefaultReader<Uint8Array>, response: Response,
decoder: TextDecoder, callback: HandlerFunction,
callback: (data: any) => void,
delay = 200 delay = 200
) => { ) => {
class BufferManager { class BufferManager {
private buffer: any[] = []; 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) { public add(data: any) {
this.buffer.push(data); this.buffer.push(data);
this.updateProgress(data);
} }
public flush() { public flush(done?: boolean) {
if (this.buffer.length > 0) { if (this.buffer.length > 0) {
const currentBuffer = [...this.buffer]; const currentBuffer = [...this.buffer];
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; return this.buffer;
} }
} }
const bufferManager = new BufferManager();
const reader =
response?.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
const decoder = new TextDecoder('utf-8');
const contentLength = response.headers.get('content-length');
const bufferManager = new BufferManager({
contentLength: contentLength
});
const throttledCallback = throttle(() => { const throttledCallback = throttle(() => {
bufferManager.flush(); bufferManager.flush();
@@ -53,7 +93,7 @@ const useSetChunkFetch = () => {
if (done) { if (done) {
isReading = false; isReading = false;
bufferManager.flush(); bufferManager.flush(done);
break; break;
} }
@@ -97,11 +137,7 @@ const useSetChunkFetch = () => {
return; return;
} }
const reader = await readTextEventStreamData(response, handler);
response?.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
const decoder = new TextDecoder('utf-8');
await readTextEventStreamData(reader, decoder, handler);
console.log('chunkDataRef.current===1', chunkDataRef.current); console.log('chunkDataRef.current===1', chunkDataRef.current);
} catch (error) { } catch (error) {
+91
View File
@@ -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<any>(null);
const logParseWorker = useRef<any>(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
};
}
+2 -1
View File
@@ -227,5 +227,6 @@ export default {
'common.options.all': 'All', 'common.options.all': 'All',
'common.options.none': 'None', 'common.options.none': 'None',
'common.options.auto': 'Auto', 'common.options.auto': 'Auto',
'common.search.empty': 'No matching results found.' 'common.search.empty': 'No matching results found.',
'common.button.downloadLog': 'Download Log'
}; };
+2 -1
View File
@@ -220,5 +220,6 @@ export default {
'common.options.all': '全部', 'common.options.all': '全部',
'common.options.none': '无', 'common.options.none': '无',
'common.options.auto': '自动', 'common.options.auto': '自动',
'common.search.empty': '未找到匹配结果' 'common.search.empty': '未找到匹配结果',
'common.button.downloadLog': '下载日志'
}; };
@@ -10,6 +10,7 @@ import {
} from '@/pages/resources/config/types'; } from '@/pages/resources/config/types';
import { import {
DeleteOutlined, DeleteOutlined,
DownloadOutlined,
HddFilled, HddFilled,
InfoCircleOutlined, InfoCircleOutlined,
ThunderboltFilled ThunderboltFilled
@@ -48,6 +49,18 @@ const childActionList = [
], ],
icon: <IconFont type="icon-logs" /> icon: <IconFont type="icon-logs" />
}, },
{
label: 'common.button.downloadLog',
key: 'download',
status: [
InstanceStatusMap.Initializing,
InstanceStatusMap.Running,
InstanceStatusMap.Error,
InstanceStatusMap.Starting,
InstanceStatusMap.Downloading
],
icon: <DownloadOutlined />
},
{ {
label: 'common.button.delrecreate', label: 'common.button.delrecreate',
key: 'delete', key: 'delete',
@@ -60,7 +73,7 @@ const childActionList = [
const setChildActionList = (item: ModelInstanceListItem) => { const setChildActionList = (item: ModelInstanceListItem) => {
return _.filter(childActionList, (action: any) => { return _.filter(childActionList, (action: any) => {
if (action.key === 'viewlog') { if (action.key === 'viewlog' || action.key === 'download') {
return action.status.includes(item.state); return action.status.includes(item.state);
} }
return true; return true;
+9 -1
View File
@@ -10,6 +10,7 @@ import { SealColumnProps } from '@/components/seal-table/types';
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import HotKeys from '@/config/hotkeys'; import HotKeys from '@/config/hotkeys';
import useBodyScroll from '@/hooks/use-body-scroll'; import useBodyScroll from '@/hooks/use-body-scroll';
import useDownloadStream from '@/hooks/use-download-stream';
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys'; import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
import useTableRowSelection from '@/hooks/use-table-row-selection'; import useTableRowSelection from '@/hooks/use-table-row-selection';
import useTableSort from '@/hooks/use-table-sort'; import useTableSort from '@/hooks/use-table-sort';
@@ -181,6 +182,7 @@ const Models: React.FC<ModelsProps> = ({
loadend, loadend,
total total
}) => { }) => {
const { downloadStream } = useDownloadStream();
const { getGPUList, generateFormValues, gpuDeviceList } = const { getGPUList, generateFormValues, gpuDeviceList } =
useGenerateFormEditInitialValues(); useGenerateFormEditInitialValues();
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll(); const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
@@ -440,7 +442,7 @@ const Models: React.FC<ModelsProps> = ({
restoreScrollHeight(); restoreScrollHeight();
} catch (error) {} } catch (error) {}
}, },
[currentData] [handleSearch]
); );
const handleModalCancel = useCallback(() => { const handleModalCancel = useCallback(() => {
@@ -652,6 +654,12 @@ const Models: React.FC<ModelsProps> = ({
if (val === 'viewlog') { if (val === 'viewlog') {
handleViewLogs(row); handleViewLogs(row);
} }
if (val === 'download') {
downloadStream({
url: `${MODEL_INSTANCE_API}/${row.id}/logs`,
filename: row.name
});
}
}, },
[handleViewLogs, handleDeleteInstace] [handleViewLogs, handleDeleteInstace]
); );
-16
View File
@@ -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);
};