feat: add download instance log button
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user