diff --git a/new-log-viewer/src/components/MenuBar/ExportLogsButton.tsx b/new-log-viewer/src/components/MenuBar/ExportLogsButton.tsx new file mode 100644 index 00000000..5d998e8d --- /dev/null +++ b/new-log-viewer/src/components/MenuBar/ExportLogsButton.tsx @@ -0,0 +1,60 @@ +import {useContext} from "react"; + +import { + CircularProgress, + Typography, +} from "@mui/joy"; + +import DownloadIcon from "@mui/icons-material/Download"; + +import {StateContext} from "../../contexts/StateContextProvider"; +import { + EXPORT_LOG_PROGRESS_VALUE_MAX, + EXPORT_LOG_PROGRESS_VALUE_MIN, +} from "../../services/LogExportManager"; +import SmallIconButton from "./SmallIconButton"; + + +/** + * Represents a button for triggering log exports and displays the progress. + * + * @return + */ +const ExportLogsButton = () => { + const {exportLogs, exportProgress, fileName} = useContext(StateContext); + + return ( + + {null === exportProgress || EXPORT_LOG_PROGRESS_VALUE_MIN === exportProgress ? + : + + {EXPORT_LOG_PROGRESS_VALUE_MAX === exportProgress ? + : + + {Math.ceil(exportProgress * 100)} + } + } + + ); +}; + +export default ExportLogsButton; diff --git a/new-log-viewer/src/components/MenuBar/index.tsx b/new-log-viewer/src/components/MenuBar/index.tsx index e9955286..e2a21487 100644 --- a/new-log-viewer/src/components/MenuBar/index.tsx +++ b/new-log-viewer/src/components/MenuBar/index.tsx @@ -10,14 +10,15 @@ import { Typography, } from "@mui/joy"; -import Description from "@mui/icons-material/Description"; +import DescriptionIcon from "@mui/icons-material/Description"; import FileOpenIcon from "@mui/icons-material/FileOpen"; -import Settings from "@mui/icons-material/Settings"; +import SettingsIcon from "@mui/icons-material/Settings"; import {StateContext} from "../../contexts/StateContextProvider"; import {CURSOR_CODE} from "../../typings/worker"; import {openFile} from "../../utils/file"; import SettingsModal from "../modals/SettingsModal"; +import ExportLogsButton from "./ExportLogsButton"; import NavigationBar from "./NavigationBar"; import SmallIconButton from "./SmallIconButton"; @@ -58,7 +59,7 @@ const MenuBar = () => { flexGrow={1} gap={0.5} > - + {fileName} @@ -74,8 +75,9 @@ const MenuBar = () => { - + + void, - loadPage: (newPageNum: number) => void, + exportProgress: Nullable, logData: string, numEvents: number, numPages: number, - pageNum: Nullable + pageNum: Nullable, + + exportLogs: () => void, + loadFile: (fileSrc: FileSrcType, cursor: CursorType) => void, + loadPage: (newPageNum: number) => void, } const StateContext = createContext({} as StateContextType); @@ -51,13 +59,16 @@ const StateContext = createContext({} as StateContextType); */ const STATE_DEFAULT: Readonly = Object.freeze({ beginLineNumToLogEventNum: new Map(), + exportProgress: null, fileName: "", - loadFile: () => null, - loadPage: () => null, logData: "Loading...", numEvents: 0, numPages: 0, pageNum: 0, + + exportLogs: () => null, + loadFile: () => null, + loadPage: () => null, }); interface StateContextProviderProps { @@ -130,25 +141,42 @@ const workerPostReq = ( const StateContextProvider = ({children}: StateContextProviderProps) => { const {filePath, logEventNum} = useContext(UrlContext); + // States const [fileName, setFileName] = useState(STATE_DEFAULT.fileName); const [logData, setLogData] = useState(STATE_DEFAULT.logData); const [numEvents, setNumEvents] = useState(STATE_DEFAULT.numEvents); const beginLineNumToLogEventNumRef = useRef(STATE_DEFAULT.beginLineNumToLogEventNum); + const [exportProgress, setExportProgress] = + useState>(STATE_DEFAULT.exportProgress); + + // Refs const logEventNumRef = useRef(logEventNum); const numPagesRef = useRef(STATE_DEFAULT.numPages); const pageNumRef = useRef>(STATE_DEFAULT.pageNum); - + const logExportManagerRef = useRef(null); const mainWorkerRef = useRef(null); const handleMainWorkerResp = useCallback((ev: MessageEvent) => { const {code, args} = ev.data; console.log(`[MainWorker -> Renderer] code=${code}`); switch (code) { + case WORKER_RESP_CODE.CHUNK_DATA: + if (null !== logExportManagerRef.current) { + const progress = logExportManagerRef.current.appendChunk(args.logs); + setExportProgress(progress); + } + break; case WORKER_RESP_CODE.LOG_FILE_INFO: setFileName(args.fileName); setNumEvents(args.numEvents); break; + case WORKER_RESP_CODE.NOTIFICATION: + // eslint-disable-next-line no-warning-comments + // TODO: notifications should be shown in the UI when the NotificationProvider + // is added + console.error(args.logLevel, args.message); + break; case WORKER_RESP_CODE.PAGE_DATA: { setLogData(args.logs); beginLineNumToLogEventNumRef.current = args.beginLineNumToLogEventNum; @@ -156,18 +184,39 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { updateLogEventNumInUrl(lastLogEventNum, logEventNumRef.current); break; } - case WORKER_RESP_CODE.NOTIFICATION: - // eslint-disable-next-line no-warning-comments - // TODO: notifications should be shown in the UI when the NotificationProvider - // is added - console.error(args.logLevel, args.message); - break; default: console.error(`Unexpected ev.data: ${JSON.stringify(ev.data)}`); break; } }, []); + const exportLogs = useCallback(() => { + if (null === mainWorkerRef.current) { + console.error("Unexpected null mainWorkerRef.current"); + + return; + } + if (STATE_DEFAULT.numEvents === numEvents && STATE_DEFAULT.fileName === fileName) { + console.error("numEvents and fileName not initialized yet"); + + return; + } + + setExportProgress(EXPORT_LOG_PROGRESS_VALUE_MIN); + logExportManagerRef.current = new LogExportManager( + Math.ceil(numEvents / EXPORT_LOGS_CHUNK_SIZE), + fileName + ); + workerPostReq( + mainWorkerRef.current, + WORKER_REQ_CODE.EXPORT_LOG, + {decoderOptions: getConfig(CONFIG_KEY.DECODER_OPTIONS)} + ); + }, [ + numEvents, + fileName, + ]); + const loadFile = useCallback((fileSrc: FileSrcType, cursor: CursorType) => { if ("string" !== typeof fileSrc) { updateWindowUrlSearchParams({[SEARCH_PARAM_NAMES.FILE_PATH]: null}); @@ -185,6 +234,8 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { cursor: cursor, decoderOptions: getConfig(CONFIG_KEY.DECODER_OPTIONS), }); + + setExportProgress(STATE_DEFAULT.exportProgress); }, [ handleMainWorkerResp, ]); @@ -274,13 +325,16 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { {children} diff --git a/new-log-viewer/src/services/LogExportManager.ts b/new-log-viewer/src/services/LogExportManager.ts new file mode 100644 index 00000000..82a2afc2 --- /dev/null +++ b/new-log-viewer/src/services/LogExportManager.ts @@ -0,0 +1,69 @@ +import {downloadBlob} from "../utils/file"; + + +const EXPORT_LOG_PROGRESS_VALUE_MIN = 0; +const EXPORT_LOG_PROGRESS_VALUE_MAX = 1; + +/** + * Manager for exporting logs as a file. + */ +class LogExportManager { + /** + * Internal buffer which stores decoded chunks of log data. + */ + readonly #chunks: string[] = []; + + /** + * Total number of chunks to export. + */ + readonly #numChunks: number; + + /** + * Name of the file to export to. + */ + readonly #exportedFileName: string; + + constructor (numChunks: number, fileName: string) { + this.#numChunks = numChunks; + this.#exportedFileName = `exported-${new Date().toISOString() + .replace(/[:.]/g, "-")}-${fileName}.log`; + } + + /** + * Appends the provided chunk of logs into an internal buffer. If the number of chunks reaches + * the specified limit, triggers a download. + * + * @param chunk + * @return The current download progress as a float between 0 and 1. + */ + appendChunk (chunk: string): number { + if (0 === this.#numChunks) { + this.#download(); + + return EXPORT_LOG_PROGRESS_VALUE_MAX; + } + this.#chunks.push(chunk); + if (this.#chunks.length === this.#numChunks) { + this.#download(); + this.#chunks.length = 0; + + return EXPORT_LOG_PROGRESS_VALUE_MAX; + } + + return this.#chunks.length / this.#numChunks; + } + + /** + * Triggers a download of the accumulated chunks. + */ + #download () { + const blob = new Blob(this.#chunks, {type: "text/plain"}); + downloadBlob(blob, this.#exportedFileName); + } +} + +export default LogExportManager; +export { + EXPORT_LOG_PROGRESS_VALUE_MAX, + EXPORT_LOG_PROGRESS_VALUE_MIN, +}; diff --git a/new-log-viewer/src/services/LogFileManager.ts b/new-log-viewer/src/services/LogFileManager.ts index ea73d7fa..2ceded83 100644 --- a/new-log-viewer/src/services/LogFileManager.ts +++ b/new-log-viewer/src/services/LogFileManager.ts @@ -10,6 +10,7 @@ import { CursorType, FileSrcType, } from "../typings/worker"; +import {EXPORT_LOGS_CHUNK_SIZE} from "../utils/config"; import {getUint8ArrayFrom} from "../utils/http"; import {getChunkNum} from "../utils/math"; import {formatSizeInBytes} from "../utils/units"; @@ -152,6 +153,37 @@ class LogFileManager { this.#decoder.setDecoderOptions(options); } + /** + * Loads log events in the range + * [`beginLogEventIdx`, `beginLogEventIdx + EXPORT_LOGS_CHUNK_SIZE`), or all remaining log + * events if `EXPORT_LOGS_CHUNK_SIZE` log events aren't available. + * + * @param beginLogEventIdx + * @return An object containing the log events as a string. + * @throws {Error} if any error occurs when decoding the log events. + */ + loadChunk (beginLogEventIdx: number): { + logs: string, + } { + const endLogEventIdx = Math.min(beginLogEventIdx + EXPORT_LOGS_CHUNK_SIZE, this.#numEvents); + const results = this.#decoder.decode( + beginLogEventIdx, + endLogEventIdx + ); + + if (null === results) { + throw new Error( + `Failed to decode log events in range [${beginLogEventIdx}, ${endLogEventIdx})` + ); + } + + const messages = results.map(([msg]) => msg); + + return { + logs: messages.join(""), + }; + } + /** * Loads a page of log events based on the provided cursor. * diff --git a/new-log-viewer/src/services/MainWorker.ts b/new-log-viewer/src/services/MainWorker.ts index 87a1bd89..a04d134c 100644 --- a/new-log-viewer/src/services/MainWorker.ts +++ b/new-log-viewer/src/services/MainWorker.ts @@ -9,6 +9,7 @@ import { WORKER_RESP_CODE, WorkerResp, } from "../typings/worker"; +import {EXPORT_LOGS_CHUNK_SIZE} from "../utils/config"; import LogFileManager from "./LogFileManager"; @@ -35,12 +36,33 @@ const postResp = ( postMessage({code, args}); }; +// eslint-disable-next-line no-warning-comments +// TODO: Break this function up into smaller functions. +// eslint-disable-next-line max-lines-per-function,max-statements onmessage = async (ev: MessageEvent) => { const {code, args} = ev.data; console.log(`[Renderer -> MainWorker] code=${code}: args=${JSON.stringify(args)}`); try { switch (code) { + case WORKER_REQ_CODE.EXPORT_LOG: { + if (null === LOG_FILE_MANAGER) { + throw new Error("Log file manager hasn't been initialized"); + } + if ("undefined" !== typeof args.decoderOptions) { + LOG_FILE_MANAGER.setDecoderOptions(args.decoderOptions); + } + + let decodedEventIdx = 0; + while (decodedEventIdx < LOG_FILE_MANAGER.numEvents) { + postResp( + WORKER_RESP_CODE.CHUNK_DATA, + LOG_FILE_MANAGER.loadChunk(decodedEventIdx) + ); + decodedEventIdx += EXPORT_LOGS_CHUNK_SIZE; + } + break; + } case WORKER_REQ_CODE.LOAD_FILE: { LOG_FILE_MANAGER = await LogFileManager.create( args.fileSrc, diff --git a/new-log-viewer/src/typings/worker.ts b/new-log-viewer/src/typings/worker.ts index 0c88beb6..82b186c0 100644 --- a/new-log-viewer/src/typings/worker.ts +++ b/new-log-viewer/src/typings/worker.ts @@ -38,17 +38,22 @@ type BeginLineNumToLogEventNumMap = Map; * Enum of the protocol code for communications between the renderer and MainWorker. */ enum WORKER_REQ_CODE { + EXPORT_LOG = "exportLog", LOAD_FILE = "loadFile", LOAD_PAGE = "loadPage", } enum WORKER_RESP_CODE { + CHUNK_DATA = "chunkData", LOG_FILE_INFO = "fileInfo", PAGE_DATA = "pageData", NOTIFICATION = "notification", } type WorkerReqMap = { + [WORKER_REQ_CODE.EXPORT_LOG]: { + decoderOptions: DecoderOptionsType + } [WORKER_REQ_CODE.LOAD_FILE]: { fileSrc: FileSrcType, pageSize: number, @@ -62,6 +67,9 @@ type WorkerReqMap = { }; type WorkerRespMap = { + [WORKER_RESP_CODE.CHUNK_DATA]: { + logs: string + }, [WORKER_RESP_CODE.LOG_FILE_INFO]: { fileName: string, numEvents: number, diff --git a/new-log-viewer/src/utils/config.ts b/new-log-viewer/src/utils/config.ts index 1d78a364..3dd16001 100644 --- a/new-log-viewer/src/utils/config.ts +++ b/new-log-viewer/src/utils/config.ts @@ -10,6 +10,7 @@ import {DecoderOptionsType} from "../typings/decoders"; const MAX_PAGE_SIZE = 1_000_000; +const EXPORT_LOGS_CHUNK_SIZE = 10_000; /** * The default configuration values. @@ -151,6 +152,7 @@ const getConfig = (key: T): ConfigMap[T] => { export { CONFIG_DEFAULT, + EXPORT_LOGS_CHUNK_SIZE, getConfig, setConfig, testConfig, diff --git a/new-log-viewer/src/utils/file.ts b/new-log-viewer/src/utils/file.ts index 36980915..5e223bac 100644 --- a/new-log-viewer/src/utils/file.ts +++ b/new-log-viewer/src/utils/file.ts @@ -1,6 +1,21 @@ import type {OnFileOpenCallback} from "../typings/file"; +/** + * Triggers a download of the provided Blob object with the specified file name. + * + * @param blob The Blob object to download. + * @param fileName The name of the file to be downloaded. + */ +const downloadBlob = (blob: Blob, fileName: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +}; + /** * Opens a file and invokes the provided callback on the file. * @@ -22,4 +37,7 @@ const openFile = (onOpen: OnFileOpenCallback) => { input.click(); }; -export {openFile}; +export { + downloadBlob, + openFile, +};