From 4b65341513ca5087750cbb1c71dcbc3aa453b16a Mon Sep 17 00:00:00 2001 From: Junhao Liao Date: Mon, 2 Sep 2024 12:37:38 -0400 Subject: [PATCH 1/2] new-log-viewer: Add support for loading files through open dialog or drag-and-drop; Display file name in the UI. (#56) --- new-log-viewer/public/index.html | 3 + .../components/DropFileContainer/index.css | 31 ++++++ .../components/DropFileContainer/index.tsx | 96 +++++++++++++++++++ new-log-viewer/src/components/Layout.tsx | 25 ++++- .../src/contexts/StateContextProvider.tsx | 18 +++- new-log-viewer/src/index.css | 20 ++++ new-log-viewer/src/services/LogFileManager.ts | 10 +- new-log-viewer/src/services/MainWorker.ts | 8 +- new-log-viewer/src/typings/file.ts | 3 + new-log-viewer/src/typings/worker.ts | 15 +-- new-log-viewer/src/utils/file.ts | 25 +++++ 11 files changed, 234 insertions(+), 20 deletions(-) create mode 100644 new-log-viewer/src/components/DropFileContainer/index.css create mode 100644 new-log-viewer/src/components/DropFileContainer/index.tsx create mode 100644 new-log-viewer/src/typings/file.ts create mode 100644 new-log-viewer/src/utils/file.ts diff --git a/new-log-viewer/public/index.html b/new-log-viewer/public/index.html index fd19449b..799ad411 100644 --- a/new-log-viewer/public/index.html +++ b/new-log-viewer/public/index.html @@ -7,6 +7,9 @@ + + +
diff --git a/new-log-viewer/src/components/DropFileContainer/index.css b/new-log-viewer/src/components/DropFileContainer/index.css new file mode 100644 index 00000000..2bf46acd --- /dev/null +++ b/new-log-viewer/src/components/DropFileContainer/index.css @@ -0,0 +1,31 @@ +.drop-file-container { + width: 100%; + height: 100%; + position: relative; +} + +.drop-file-children { + width: 100%; + height: 100%; +} + +.hover-mask { + position: absolute; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(2, 88, 168, 0.2); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--ylv-drop-file-container-hover-mask-z-index); +} + +.hover-message { + padding: 8px; + color: #616161; + font-size: 0.875rem; + font-family: var(--ylv-ui-font-family), sans-serif; + background-color: #f3f3f3; + z-index: var(--ylv-drop-file-container-hover-message-z-index); +} diff --git a/new-log-viewer/src/components/DropFileContainer/index.tsx b/new-log-viewer/src/components/DropFileContainer/index.tsx new file mode 100644 index 00000000..a355c9e8 --- /dev/null +++ b/new-log-viewer/src/components/DropFileContainer/index.tsx @@ -0,0 +1,96 @@ +import React, { + useContext, + useState, +} from "react"; + +import {StateContext} from "../../contexts/StateContextProvider"; +import {CURSOR_CODE} from "../../typings/worker"; + +import "./index.css"; + + +interface DropFileContextProviderProps { + children: React.ReactNode; +} + +/** + * A container element to add drag & drop functionality to the child elements. + * + * @param props + * @param props.children + * @return + */ +const DropFileContainer = ({children}: DropFileContextProviderProps) => { + const {loadFile} = useContext(StateContext); + const [isFileHovering, setIsFileHovering] = useState(false); + + const handleDrag = (ev: React.DragEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + if ("dragenter" === ev.type) { + setIsFileHovering(true); + } else if ("dragleave" === ev.type) { + // Only stop the hover effect if the pointer leaves the bounding rectangle of the + // DropFileContainer. + // + // NOTE: "dragleave" could get fired when the wrapped `children` receive focus. Setting + // `pointer-events: none` on the children is viable but could cause the children to be + // unresponsive. So instead, we use the solution below. + const {bottom, left, right, top} = ev.currentTarget.getBoundingClientRect(); + if (ev.clientX >= left && ev.clientX <= right && + ev.clientY >= top && ev.clientY <= bottom) { + return; + } + + setIsFileHovering(false); + } + }; + + const handleDrop = (ev: React.DragEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + setIsFileHovering(false); + + const [file] = ev.dataTransfer.files; + if ("undefined" === typeof file) { + console.warn("No file dropped."); + + return; + } + loadFile(file, {code: CURSOR_CODE.LAST_EVENT, args: null}); + }; + + return ( +
+
+ {children} + {isFileHovering && ( +
+
+ Drop file to view +
+
+ )} +
+
+ ); +}; + +export default DropFileContainer; diff --git a/new-log-viewer/src/components/Layout.tsx b/new-log-viewer/src/components/Layout.tsx index 6ee937f3..149e23e8 100644 --- a/new-log-viewer/src/components/Layout.tsx +++ b/new-log-viewer/src/components/Layout.tsx @@ -19,15 +19,18 @@ import { LOCAL_STORAGE_KEY, THEME_NAME, } from "../typings/config"; +import {CURSOR_CODE} from "../typings/worker"; import {ACTION_NAME} from "../utils/actions"; import { getConfig, setConfig, } from "../utils/config"; +import {openFile} from "../utils/file"; import { getFirstItemNumInNextChunk, getLastItemNumInPrevChunk, } from "../utils/math"; +import DropFileContainer from "./DropFileContainer"; import Editor from "./Editor"; import {goToPositionAndCenter} from "./Editor/MonacoInstance/utils"; @@ -164,8 +167,10 @@ const handleLogEventNumInputChange = (ev: React.ChangeEvent) = */ const Layout = () => { const { - pageNum, + fileName, + loadFile, numEvents, + pageNum, } = useContext(StateContext); const {logEventNum} = useContext(UrlContext); @@ -176,6 +181,12 @@ const Layout = () => { copyPermalinkToClipboard({}, {logEventNum: numEvents}); }; + const handleOpenFileButtonClick = () => { + openFile((file) => { + loadFile(file, {code: CURSOR_CODE.LAST_EVENT, args: null}); + }); + }; + /** * Handles custom actions in the editor. * @@ -251,15 +262,25 @@ const Layout = () => { PageNum - {" "} {pageNum} + {" "} + | FileName - + {" "} + {fileName} + +
- + + +
diff --git a/new-log-viewer/src/contexts/StateContextProvider.tsx b/new-log-viewer/src/contexts/StateContextProvider.tsx index 32c0b993..e84e46bf 100644 --- a/new-log-viewer/src/contexts/StateContextProvider.tsx +++ b/new-log-viewer/src/contexts/StateContextProvider.tsx @@ -9,6 +9,7 @@ import React, { import {Nullable} from "../typings/common"; import {CONFIG_KEY} from "../typings/config"; +import {SEARCH_PARAM_NAMES} from "../typings/url"; import { BeginLineNumToLogEventNumMap, CURSOR_CODE, @@ -26,6 +27,7 @@ import { } from "../utils/math"; import { updateWindowUrlHashParams, + updateWindowUrlSearchParams, URL_HASH_PARAMS_DEFAULT, URL_SEARCH_PARAMS_DEFAULT, UrlContext, @@ -34,6 +36,7 @@ import { interface StateContextType { beginLineNumToLogEventNum: BeginLineNumToLogEventNumMap, + fileName: string, loadFile: (fileSrc: FileSrcType, cursor: CursorType) => void, logData: string, numEvents: number, @@ -45,8 +48,9 @@ const StateContext = createContext({} as StateContextType); /** * Default values of the state object. */ -const STATE_DEFAULT = Object.freeze({ +const STATE_DEFAULT: Readonly = Object.freeze({ beginLineNumToLogEventNum: new Map(), + fileName: "", loadFile: () => null, logData: "Loading...", numEvents: 0, @@ -106,6 +110,7 @@ const getLastLogEventNum = (beginLineNumToLogEventNum: BeginLineNumToLogEventNum const StateContextProvider = ({children}: StateContextProviderProps) => { const {filePath, logEventNum} = useContext(UrlContext); + const [fileName, setFileName] = useState(STATE_DEFAULT.fileName); const [logData, setLogData] = useState(STATE_DEFAULT.logData); const [numEvents, setNumEvents] = useState(STATE_DEFAULT.numEvents); const beginLineNumToLogEventNumRef = @@ -127,6 +132,10 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { const {code, args} = ev.data; console.log(`[MainWorker -> Renderer] code=${code}`); switch (code) { + case WORKER_RESP_CODE.LOG_FILE_INFO: + setFileName(args.fileName); + setNumEvents(args.numEvents); + break; case WORKER_RESP_CODE.PAGE_DATA: { setLogData(args.logs); beginLineNumToLogEventNumRef.current = args.beginLineNumToLogEventNum; @@ -134,9 +143,6 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { updateLogEventNumInUrl(lastLogEventNum, logEventNumRef.current); break; } - case WORKER_RESP_CODE.NUM_EVENTS: - setNumEvents(args.numEvents); - break; case WORKER_RESP_CODE.NOTIFICATION: // TODO: notifications should be shown in the UI when the NotificationProvider // is added @@ -149,6 +155,9 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { }, []); const loadFile = useCallback((fileSrc: FileSrcType, cursor: CursorType) => { + if ("string" !== typeof fileSrc) { + updateWindowUrlSearchParams({[SEARCH_PARAM_NAMES.FILE_PATH]: null}); + } if (null !== mainWorkerRef.current) { mainWorkerRef.current.terminate(); } @@ -244,6 +253,7 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { null); } else { - // TODO: support file loading via Open / Drag-n-drop - throw new Error("Read from file not yet supported"); + fileName = fileSrc.name; + fileData = new Uint8Array(await fileSrc.arrayBuffer()); } return { @@ -83,6 +83,10 @@ class LogFileManager { console.log(`Found ${this.#numEvents} log events.`); } + get fileName () { + return this.#fileName; + } + get numEvents () { return this.#numEvents; } diff --git a/new-log-viewer/src/services/MainWorker.ts b/new-log-viewer/src/services/MainWorker.ts index 25034326..87a1bd89 100644 --- a/new-log-viewer/src/services/MainWorker.ts +++ b/new-log-viewer/src/services/MainWorker.ts @@ -48,10 +48,10 @@ onmessage = async (ev: MessageEvent) => { args.decoderOptions ); - postResp( - WORKER_RESP_CODE.NUM_EVENTS, - {numEvents: LOG_FILE_MANAGER.numEvents} - ); + postResp(WORKER_RESP_CODE.LOG_FILE_INFO, { + fileName: LOG_FILE_MANAGER.fileName, + numEvents: LOG_FILE_MANAGER.numEvents, + }); postResp( WORKER_RESP_CODE.PAGE_DATA, LOG_FILE_MANAGER.loadPage(args.cursor) diff --git a/new-log-viewer/src/typings/file.ts b/new-log-viewer/src/typings/file.ts new file mode 100644 index 00000000..8b6d4af3 --- /dev/null +++ b/new-log-viewer/src/typings/file.ts @@ -0,0 +1,3 @@ +type OnFileOpenCallback = (file: File) => void; + +export type {OnFileOpenCallback}; diff --git a/new-log-viewer/src/typings/worker.ts b/new-log-viewer/src/typings/worker.ts index d9450567..0c88beb6 100644 --- a/new-log-viewer/src/typings/worker.ts +++ b/new-log-viewer/src/typings/worker.ts @@ -43,9 +43,9 @@ enum WORKER_REQ_CODE { } enum WORKER_RESP_CODE { + LOG_FILE_INFO = "fileInfo", PAGE_DATA = "pageData", - NUM_EVENTS = "numEvents", - NOTIFICATION = "notification" + NOTIFICATION = "notification", } type WorkerReqMap = { @@ -62,18 +62,19 @@ type WorkerReqMap = { }; type WorkerRespMap = { + [WORKER_RESP_CODE.LOG_FILE_INFO]: { + fileName: string, + numEvents: number, + }, [WORKER_RESP_CODE.PAGE_DATA]: { logs: string, beginLineNumToLogEventNum: BeginLineNumToLogEventNumMap, cursorLineNum: number - }; - [WORKER_RESP_CODE.NUM_EVENTS]: { - numEvents: number - }; + }, [WORKER_RESP_CODE.NOTIFICATION]: { logLevel: LOG_LEVEL, message: string - }; + }, }; type WorkerReq = T extends keyof WorkerReqMap ? diff --git a/new-log-viewer/src/utils/file.ts b/new-log-viewer/src/utils/file.ts new file mode 100644 index 00000000..36980915 --- /dev/null +++ b/new-log-viewer/src/utils/file.ts @@ -0,0 +1,25 @@ +import type {OnFileOpenCallback} from "../typings/file"; + + +/** + * Opens a file and invokes the provided callback on the file. + * + * @param onOpen + */ +const openFile = (onOpen: OnFileOpenCallback) => { + const input = document.createElement("input"); + input.type = "file"; + input.onchange = (ev: Event) => { + const target = ev.target as HTMLInputElement; + const [file] = target.files as FileList; + if ("undefined" === typeof file) { + console.error("No file selected"); + + return; + } + onOpen(file); + }; + input.click(); +}; + +export {openFile}; From 867b72e4dbc5b73ef483b9d1102121bcf22ab347 Mon Sep 17 00:00:00 2001 From: Junhao Liao Date: Mon, 2 Sep 2024 12:50:21 -0400 Subject: [PATCH 2/2] gh-actions: Add lint workflow for new-log-viewer. (#61) --- .github/workflows/lint.yaml | 44 +++++++++++++++++++ new-log-viewer/package.json | 4 ++ .../components/Editor/MonacoInstance/utils.ts | 1 + .../src/contexts/StateContextProvider.tsx | 33 +++++++++----- .../src/services/decoders/JsonlDecoder.ts | 1 + .../services/formatters/LogbackFormatter.ts | 1 + 6 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/lint.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 00000000..73b7542e --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,44 @@ +name: "lint" + +on: + pull_request: + types: ["opened", "reopened"] + push: + schedule: + # Run at midnight UTC every day with 15 minutes delay added to avoid high load periods + - cron: "15 0 * * *" + workflow_dispatch: + +permissions: + # So the workflow can cancel in-progress jobs + actions: "write" + +concurrency: + group: "${{github.workflow}}-${{github.ref}}" + # Cancel in-progress jobs for efficiency + cancel-in-progress: true + +jobs: + lint-check: + runs-on: "ubuntu-latest" + permissions: + # So `eslint-annotate-action` can create / update status checks + checks: "write" + # So `eslint-annotate-action` can get pull request files + contents: "read" + steps: + - uses: "actions/checkout@v4" + with: + submodules: "recursive" + - uses: "actions/setup-node@v4" + with: + node-version: 22 + - run: "npm --prefix new-log-viewer/ clean-install" + - run: "npm --prefix new-log-viewer/ run lint:ci" + continue-on-error: true + - uses: "ataylorme/eslint-annotate-action@v3" + with: + fail-on-error: true + fail-on-warning: true + only-pr-files: true + report-json: "./new-log-viewer/eslint-report.json" diff --git a/new-log-viewer/package.json b/new-log-viewer/package.json index 607f0756..ee1cfae1 100644 --- a/new-log-viewer/package.json +++ b/new-log-viewer/package.json @@ -5,6 +5,10 @@ "main": "src/index.tsx", "scripts": { "build": "webpack --config webpack.prod.js", + "lint": "npm run lint:check", + "lint:ci": "npm run lint:check -- --output-file eslint-report.json --format json", + "lint:check": "eslint src webpack.*.js", + "lint:fix": "npm run lint:check -- --fix", "start": "webpack serve --open --config webpack.dev.js" }, "repository": { diff --git a/new-log-viewer/src/components/Editor/MonacoInstance/utils.ts b/new-log-viewer/src/components/Editor/MonacoInstance/utils.ts index 45d8e24e..16b8e42c 100644 --- a/new-log-viewer/src/components/Editor/MonacoInstance/utils.ts +++ b/new-log-viewer/src/components/Editor/MonacoInstance/utils.ts @@ -41,6 +41,7 @@ const createMonacoEditor = ( const editor = monaco.editor.create( editorContainer, { + // eslint-disable-next-line no-warning-comments // TODO: Add custom observer to debounce automatic layout automaticLayout: true, maxTokenizationLineLength: 30_000, diff --git a/new-log-viewer/src/contexts/StateContextProvider.tsx b/new-log-viewer/src/contexts/StateContextProvider.tsx index e84e46bf..bf3d4ec0 100644 --- a/new-log-viewer/src/contexts/StateContextProvider.tsx +++ b/new-log-viewer/src/contexts/StateContextProvider.tsx @@ -99,6 +99,23 @@ const getLastLogEventNum = (beginLineNumToLogEventNum: BeginLineNumToLogEventNum return lastLogEventNum; }; +/** + * Sends a post message to a worker with the given code and arguments. This wrapper around + * `worker.postMessage()` ensures type safety for both the request code and its corresponding + * arguments. + * + * @param worker + * @param code + * @param args + */ +const workerPostReq = ( + worker: Worker, + code: T, + args: WorkerReq +) => { + worker.postMessage({code, args}); +}; + /** * Provides state management for the application. This provider must be wrapped by * UrlContextProvider to function correctly. @@ -121,13 +138,6 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { const mainWorkerRef = useRef(null); - const mainWorkerPostReq = useCallback(( - code: T, - args: WorkerReq - ) => { - mainWorkerRef.current?.postMessage({code, args}); - }, []); - const handleMainWorkerResp = useCallback((ev: MessageEvent) => { const {code, args} = ev.data; console.log(`[MainWorker -> Renderer] code=${code}`); @@ -144,6 +154,7 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { 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); @@ -165,7 +176,7 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { new URL("../services/MainWorker.ts", import.meta.url) ); mainWorkerRef.current.onmessage = handleMainWorkerResp; - mainWorkerPostReq(WORKER_REQ_CODE.LOAD_FILE, { + workerPostReq(mainWorkerRef.current, WORKER_REQ_CODE.LOAD_FILE, { fileSrc: fileSrc, pageSize: getConfig(CONFIG_KEY.PAGE_SIZE), cursor: cursor, @@ -173,7 +184,6 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { }); }, [ handleMainWorkerResp, - mainWorkerPostReq, ]); // Synchronize `logEventNumRef` with `logEventNum`. @@ -192,7 +202,7 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { // On `logEventNum` update, clamp it then switch page if necessary or simply update the URL. useEffect(() => { - if (URL_HASH_PARAMS_DEFAULT.logEventNum === logEventNum) { + if (null === mainWorkerRef.current || URL_HASH_PARAMS_DEFAULT.logEventNum === logEventNum) { return; } @@ -211,7 +221,7 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { // NOTE: We don't need to call `updateLogEventNumInUrl()` since it's called when // handling the `WORKER_RESP_CODE.PAGE_DATA` response (the response to // `WORKER_REQ_CODE.LOAD_PAGE` requests) . - mainWorkerPostReq(WORKER_REQ_CODE.LOAD_PAGE, { + workerPostReq(mainWorkerRef.current, WORKER_REQ_CODE.LOAD_PAGE, { cursor: {code: CURSOR_CODE.PAGE_NUM, args: {pageNum: newPageNum}}, decoderOptions: getConfig(CONFIG_KEY.DECODER_OPTIONS), }); @@ -222,7 +232,6 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { }, [ numEvents, logEventNum, - mainWorkerPostReq, ]); // On `filePath` update, load file. diff --git a/new-log-viewer/src/services/decoders/JsonlDecoder.ts b/new-log-viewer/src/services/decoders/JsonlDecoder.ts index d1ca1469..62a5b21e 100644 --- a/new-log-viewer/src/services/decoders/JsonlDecoder.ts +++ b/new-log-viewer/src/services/decoders/JsonlDecoder.ts @@ -82,6 +82,7 @@ class JsonlDecoder implements Decoder { return null; } + // eslint-disable-next-line no-warning-comments // TODO We could probably optimize this to avoid checking `#invalidLogEventIdxToRawLine` on // every iteration. const results: DecodeResultType[] = []; diff --git a/new-log-viewer/src/services/formatters/LogbackFormatter.ts b/new-log-viewer/src/services/formatters/LogbackFormatter.ts index 6f827c23..5d310df1 100644 --- a/new-log-viewer/src/services/formatters/LogbackFormatter.ts +++ b/new-log-viewer/src/services/formatters/LogbackFormatter.ts @@ -154,6 +154,7 @@ class LogbackFormatter implements Formatter { * @return The formatted string. */ #formatVariables (formatString: string, logEvent: JsonObject): string { + // eslint-disable-next-line no-warning-comments // TODO These don't handle the case where a variable value may contain a '%' itself for (const key of this.#keys) { if (false === (key in logEvent)) {