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,
+};