From 9b7d75629ae4bc1775e9981fefea275f45f3c040 Mon Sep 17 00:00:00 2001 From: Andrew Chang Date: Tue, 28 Jul 2026 21:55:34 +0800 Subject: [PATCH] Fix copying task logs dropping rows that scrolled out of view The task log viewer only renders the visible rows. Copying a selection uses the browser's default copy, which serializes only the rows currently in the DOM, so rows the viewer has unmounted are silently missing from the copied text. Rebuild the missing rows from log data, using the same text pipeline as the log download. The rebuild reads row text programmatically, which ignores the CSS that keeps the line-number links out of a normal copy, so strip those links too and leave single-row, fully-mounted, and multi-range selections to the browser. --- .../ui/src/components/renderStructuredLog.tsx | 1 + .../src/pages/TaskInstance/Logs/Logs.test.tsx | 113 +++++++++ .../TaskInstance/Logs/TaskLogContent.tsx | 37 ++- .../TaskInstance/Logs/logSelection.test.ts | 230 +++++++++++++++++- .../pages/TaskInstance/Logs/logSelection.ts | 135 ++++++++++ .../airflow/ui/src/queries/useLogs.test.ts | 58 +++++ .../src/airflow/ui/src/queries/useLogs.tsx | 52 +++- 7 files changed, 621 insertions(+), 5 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/queries/useLogs.test.ts diff --git a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx index a8389611f2429..bac81ef906d01 100644 --- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx +++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx @@ -352,6 +352,7 @@ const renderStructuredLogImpl = ({ return ( { }, 10_000); }); +const makeClipboardData = () => { + const store = new Map(); + + return { + getData: (type: string) => store.get(type) ?? "", + setData: (type: string, value: string) => store.set(type, value), + }; +}; + +const dispatchCopy = (clipboardData: ReturnType) => { + const copyEvent = new Event("copy", { bubbles: true, cancelable: true }); + + Object.defineProperty(copyEvent, "clipboardData", { value: clipboardData }); + document.dispatchEvent(copyEvent); + + return copyEvent; +}; + const findRow = (text: string) => { const container = screen.getByTestId("virtual-scroll-container"); @@ -456,6 +474,101 @@ const withFakeSelection = (selection: Selection, callback: () => T): T => { return result; }; +describe("Copy across virtualized rows", () => { + it("rebuilds a removed grouped row from log data and strips line numbers", async () => { + render( + , + ); + await waitForLogs(); + + fireEvent.click(screen.getByTestId("summary-Pre Execute")); + await waitFor(() => expect(screen.getByText(/DAG bundles loaded/iu)).toBeInTheDocument()); + + const firstRow = findRow("Task started"); + const middleRow = findRow("DAG bundles loaded"); + const lastRow = findRow("Done. Returned value was: None"); + + expect(firstRow).toBeDefined(); + expect(middleRow).toBeDefined(); + expect(lastRow).toBeDefined(); + + middleRow.remove(); + + const range = document.createRange(); + + range.setStart(firstRow, 0); + range.setEnd(lastRow, lastRow.childNodes.length); + + const selection = { getRangeAt: () => range, isCollapsed: false, rangeCount: 1 } as unknown as Selection; + const clipboardData = makeClipboardData(); + const copyEvent = withFakeSelection(selection, () => dispatchCopy(clipboardData)); + + expect(copyEvent.defaultPrevented).toBe(true); + + const lines = clipboardData.getData("text/plain").split("\n"); + + expect(lines[0]).not.toMatch(/^\d/u); + expect(lines[0]).toContain("Task started"); + expect(lines).toContainEqual( + expect.stringMatching(/^\[.+\] INFO - DAG bundles loaded: dags-folder, example_dags$/u), + ); + }); + + it("rebuilds a removed ungrouped row from log data", async () => { + render( + , + ); + await waitForLogs(); + + const firstRow = findRow("Log message source details"); + const middleRow = findRow("Task started"); + const lastRow = findRow("Done. Returned value was: None"); + + expect(firstRow).toBeDefined(); + expect(middleRow).toBeDefined(); + expect(lastRow).toBeDefined(); + + middleRow.remove(); + + const range = document.createRange(); + + range.setStart(firstRow, 0); + range.setEnd(lastRow, lastRow.childNodes.length); + + const selection = { getRangeAt: () => range, isCollapsed: false, rangeCount: 1 } as unknown as Selection; + const clipboardData = makeClipboardData(); + const copyEvent = withFakeSelection(selection, () => dispatchCopy(clipboardData)); + + expect(copyEvent.defaultPrevented).toBe(true); + expect(clipboardData.getData("text/plain").split("\n")).toContainEqual( + expect.stringMatching(/^\[.+\] INFO - Task started$/u), + ); + }); + + it("leaves single-row selections to native copy", async () => { + render( + , + ); + await waitForLogs(); + + const row = findRow("Task started"); + + expect(row).toBeDefined(); + + const range = document.createRange(); + + range.setStart(row, 0); + range.setEnd(row, row.childNodes.length); + + const selection = { getRangeAt: () => range, isCollapsed: false, rangeCount: 1 } as unknown as Selection; + const clipboardData = makeClipboardData(); + const copyEvent = withFakeSelection(selection, () => dispatchCopy(clipboardData)); + + expect(copyEvent.defaultPrevented).toBe(false); + expect(clipboardData.getData("text/plain")).toBe(""); + }); +}); + describe("Selection pinning across scrolling", () => { it("keeps the selection-anchor row mounted after scrolling it out of the render window", async () => { render( diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx index b720c081ed5ee..d68084e84b892 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx @@ -29,7 +29,12 @@ import type { ParsedLogEntry } from "src/queries/useLogs"; import { HighlightedText } from "./HighlightedText"; import { ScrollToButton } from "./ScrollToButton"; -import { getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection"; +import { + extractSelectedLogText, + getEntryText, + getSelectionPinnedRows, + mergePinnedIndexes, +} from "./logSelection"; import { useLogGroups } from "./useLogGroups"; import { getHighlightColor, isSelectionWithin, scrollToBottom, scrollToTop } from "./utils"; @@ -125,6 +130,36 @@ export const TaskLogContent = ({ return () => document.removeEventListener("selectionchange", handleSelectionChange); }, []); + useEffect(() => { + const handleCopy = (event: ClipboardEvent) => { + const container = parentRef.current; + const selection = document.getSelection(); + + if (!container || !selection || !event.clipboardData) { + return; + } + const text = extractSelectedLogText({ + container, + getRowText: (index) => { + const entry = visibleItems[index]?.entry; + + return entry ? getEntryText(entry) : ""; + }, + selection, + }); + + if (text === undefined) { + return; + } + event.preventDefault(); + event.clipboardData.setData("text/plain", text); + }; + + document.addEventListener("copy", handleCopy); + + return () => document.removeEventListener("copy", handleCopy); + }, [visibleItems]); + useLayoutEffect(() => { if (visibleItems.length === 0) { return; diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts index b359f1d1bd205..ceef008c2be6a 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts @@ -16,9 +16,16 @@ * specific language governing permissions and limitations * under the License. */ +import { createElement } from "react"; import { afterEach, describe, expect, it } from "vitest"; -import { getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection"; +import { + extractSelectedLogText, + getEntryText, + getSelectionPinnedRows, + getSelectionRowRange, + mergePinnedIndexes, +} from "./logSelection"; const buildLogContainer = (rows: Array<{ index: number; text: string }>): HTMLElement => { const container = document.createElement("div"); @@ -65,6 +72,72 @@ afterEach(() => { document.body.innerHTML = ""; }); +describe("getSelectionRowRange", () => { + it("returns the row range when both boundaries are inside log rows", () => { + const container = buildLogContainer([ + { index: 2, text: "line 2" }, + { index: 7, text: "line 7" }, + ]); + const selection = selectBetween({ + end: getRowTextNode(container, 7), + endOffset: 3, + start: getRowTextNode(container, 2), + startOffset: 1, + }); + + expect(getSelectionRowRange(selection, container)).toEqual({ end: 7, start: 2 }); + }); + + it("returns undefined for a collapsed selection", () => { + const container = buildLogContainer([{ index: 0, text: "line 0" }]); + const node = getRowTextNode(container, 0); + + expect( + getSelectionRowRange( + selectBetween({ end: node, endOffset: 2, start: node, startOffset: 2 }), + container, + ), + ).toBeUndefined(); + }); + + it("returns undefined when a boundary is outside the container", () => { + const container = buildLogContainer([{ index: 0, text: "line 0" }]); + const outside = document.createElement("div"); + + outside.setAttribute("data-index", "99"); + outside.textContent = "not a log line"; + document.body.append(outside); + + const selection = selectBetween({ + end: getRowTextNode(container, 0), + endOffset: 3, + start: outside.firstChild as Node, + startOffset: 0, + }); + + expect(getSelectionRowRange(selection, container)).toBeUndefined(); + }); + + it("returns undefined when a boundary degraded to the container itself", () => { + const container = buildLogContainer([ + { index: 0, text: "line 0" }, + { index: 1, text: "line 1" }, + ]); + const range = document.createRange(); + + range.setStart(container, 0); + range.setEnd(getRowTextNode(container, 1), 3); + + expect(getSelectionRowRange(makeSelection(range), container)).toBeUndefined(); + }); + + it("returns undefined for a null selection", () => { + const container = buildLogContainer([{ index: 0, text: "line 0" }]); + + expect(getSelectionRowRange(null, container)).toBeUndefined(); + }); +}); + describe("getSelectionPinnedRows", () => { it("pins both rows when both boundaries are inside log rows", () => { const container = buildLogContainer([ @@ -145,3 +218,158 @@ describe("mergePinnedIndexes", () => { expect(mergePinnedIndexes([5, 6], [-1, 2, 99], 10)).toEqual([2, 5, 6]); }); }); + +describe("extractSelectedLogText", () => { + it("returns undefined for a selection within a single row", () => { + const container = buildLogContainer([{ index: 0, text: "hello world" }]); + const node = getRowTextNode(container, 0); + + expect( + extractSelectedLogText({ + container, + getRowText: () => "", + selection: selectBetween({ end: node, endOffset: 5, start: node, startOffset: 0 }), + }), + ).toBeUndefined(); + }); + + it("returns undefined when every selected row is mounted", () => { + const container = buildLogContainer([ + { index: 0, text: "line 0" }, + { index: 1, text: "line 1" }, + { index: 2, text: "line 2" }, + ]); + + expect( + extractSelectedLogText({ + container, + getRowText: () => "", + selection: selectBetween({ + end: getRowTextNode(container, 2), + endOffset: 3, + start: getRowTextNode(container, 0), + startOffset: 0, + }), + }), + ).toBeUndefined(); + }); + + it("rebuilds unmounted middle rows from log data, keeping partial boundary rows", () => { + const container = buildLogContainer([ + { index: 10, text: "hello world" }, + { index: 11, text: "mounted middle" }, + { index: 40, text: "foo bar" }, + ]); + const selection = selectBetween({ + end: getRowTextNode(container, 40), + endOffset: 3, + start: getRowTextNode(container, 10), + startOffset: 6, + }); + + const text = extractSelectedLogText({ + container, + getRowText: (index) => `line ${index}`, + selection, + }); + + const middleLines = Array.from({ length: 29 }, (_, offset) => `line ${offset + 11}`); + + expect(text).toBe(["world", ...middleLines, "foo"].join("\n")); + }); + + it("excludes copy-excluded line-number elements from boundary rows", () => { + const container = document.createElement("div"); + const buildRow = (index: number, messageText: string) => { + const row = document.createElement("div"); + + row.setAttribute("data-index", String(index)); + + const lineNumberLink = document.createElement("a"); + + lineNumberLink.setAttribute("data-copy-exclude", ""); + lineNumberLink.textContent = String(index); + + const message = document.createElement("span"); + + message.textContent = messageText; + row.append(lineNumberLink, message); + container.append(row); + + return message; + }; + const firstMessage = buildRow(10, "hello world"); + + buildRow(11, "mounted middle"); + + const lastMessage = buildRow(40, "foo bar"); + + document.body.append(container); + + const selection = selectBetween({ + end: lastMessage.firstChild as Node, + endOffset: 3, + start: firstMessage.firstChild as Node, + startOffset: 6, + }); + + const text = extractSelectedLogText({ + container, + getRowText: (index) => `line ${index}`, + selection, + }); + + const middleLines = Array.from({ length: 29 }, (_, offset) => `line ${offset + 11}`); + + expect(text).toBe(["world", ...middleLines, "foo"].join("\n")); + }); + + it("returns undefined for multi-range selections", () => { + const container = buildLogContainer([ + { index: 0, text: "line 0" }, + { index: 5, text: "line 5" }, + ]); + const range = document.createRange(); + + range.setStart(getRowTextNode(container, 0), 0); + range.setEnd(getRowTextNode(container, 5), 3); + + const selection = { getRangeAt: () => range, isCollapsed: false, rangeCount: 2 } as unknown as Selection; + + expect(extractSelectedLogText({ container, getRowText: () => "", selection })).toBeUndefined(); + }); + + it("returns undefined when the selection cannot be mapped to rows", () => { + const container = buildLogContainer([ + { index: 0, text: "line 0" }, + { index: 5, text: "line 5" }, + ]); + const range = document.createRange(); + + range.setStart(container, 0); + range.setEnd(getRowTextNode(container, 5), 3); + + expect( + extractSelectedLogText({ container, getRowText: () => "", selection: makeSelection(range) }), + ).toBeUndefined(); + }); +}); + +describe("getEntryText", () => { + it("returns string elements directly (group headers)", () => { + expect(getEntryText({ element: "Pre Execute" })).toBe("Pre Execute"); + }); + + it("prefers getPlainText over innerText for rendered log lines", () => { + const entry = { + element: createElement("span", undefined, "jsx text"), + getPlainText: () => "canonical text", + }; + + expect(getEntryText(entry)).toBe("canonical text"); + }); + + it("falls back to innerText when getPlainText is absent", () => { + expect(getEntryText({ element: createElement("span", undefined, "jsx text") })).toBe("jsx text"); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts index 6b0c6afc689cb..b51175a7e56ce 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts @@ -16,6 +16,14 @@ * specific language governing permissions and limitations * under the License. */ +import innerText from "react-innertext"; + +import type { ParsedLogEntry } from "src/queries/useLogs"; + +type RowRange = { + end: number; + start: number; +}; /** * Map a DOM node inside the virtualized log list to the `data-index` of the @@ -33,6 +41,28 @@ export const getRowIndexForNode = (node: Node | null, container: HTMLElement): n return Number.isInteger(index) ? index : undefined; }; +/** + * Row-index range covered by the current text selection, if both selection + * boundaries sit inside log rows of `container`. + */ +export const getSelectionRowRange = ( + selection: Selection | null, + container: HTMLElement, +): RowRange | undefined => { + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return undefined; + } + const range = selection.getRangeAt(0); + const start = getRowIndexForNode(range.startContainer, container); + const end = getRowIndexForNode(range.endContainer, container); + + if (start === undefined || end === undefined) { + return undefined; + } + + return start <= end ? { end, start } : { end: start, start: end }; +}; + /** * Row indexes to pin so the virtualizer keeps selection-boundary rows * mounted. Boundaries map independently (the drag focus may sit off the @@ -71,3 +101,108 @@ export const mergePinnedIndexes = ( return [...new Set([...validPins, ...defaultIndexes])].sort((first, second) => first - second); }; + +/** + * Canonical plain text of a parsed log entry for clipboard rebuilding: + * group headers are plain strings, log lines render through the download + * text pipeline, and the innerText fallback covers synthetic entries such + * as the TI-context preamble. + */ +export const getEntryText = (entry: ParsedLogEntry): string => { + if (typeof entry.element === "string") { + return entry.element; + } + if (entry.getPlainText) { + return entry.getPlainText(); + } + + return entry.element ? innerText(entry.element) : ""; +}; + +/** + * Range text with copy-excluded elements (the line-number links) removed. + * Native copy drops them via `user-select: none`, but programmatic + * `Range.toString()` ignores CSS, so strip them explicitly. + */ +const getRangeText = (range: Range): string => { + const fragment = range.cloneContents(); + + for (const element of fragment.querySelectorAll("[data-copy-exclude]")) { + element.remove(); + } + + return fragment.textContent; +}; + +type ExtractSelectedLogTextOptions = { + container: HTMLElement; + getRowText: (index: number) => string; + selection: Selection; +}; + +/** + * Rebuild the text of a multi-row selection from log data. Native copy + * serializes the DOM, so it silently drops selected rows the virtualizer has + * unmounted. Returns undefined when native copy is already exact (single row + * or every selected row mounted) or when the selection cannot be mapped to + * log rows. + */ +export const extractSelectedLogText = ({ + container, + getRowText, + selection, +}: ExtractSelectedLogTextOptions): string | undefined => { + // Firefox multi-range selections: rebuilding only range 0 would clobber the rest. + if (selection.rangeCount !== 1) { + return undefined; + } + const rowRange = getSelectionRowRange(selection, container); + + if (!rowRange || rowRange.start === rowRange.end) { + return undefined; + } + + const mountedIndexes = new Set( + [...container.querySelectorAll("[data-index]")].map((row) => Number(row.getAttribute("data-index"))), + ); + + let hasUnmountedRow = false; + + for (let index = rowRange.start + 1; index < rowRange.end; index += 1) { + if (!mountedIndexes.has(index)) { + hasUnmountedRow = true; + break; + } + } + + if (!hasUnmountedRow) { + return undefined; + } + + const firstRow = container.querySelector(`[data-index="${rowRange.start}"]`); + const lastRow = container.querySelector(`[data-index="${rowRange.end}"]`); + + if (!firstRow || !lastRow) { + return undefined; + } + + const range = selection.getRangeAt(0); + const firstPartial = document.createRange(); + + firstPartial.selectNodeContents(firstRow); + firstPartial.setStart(range.startContainer, range.startOffset); + + const lastPartial = document.createRange(); + + lastPartial.selectNodeContents(lastRow); + lastPartial.setEnd(range.endContainer, range.endOffset); + + const lines = [getRangeText(firstPartial)]; + + for (let index = rowRange.start + 1; index < rowRange.end; index += 1) { + lines.push(getRowText(index)); + } + lines.push(getRangeText(lastPartial)); + + return lines.join("\n"); +}; diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.test.ts b/airflow-core/src/airflow/ui/src/queries/useLogs.test.ts new file mode 100644 index 0000000000000..2f56c48e60849 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/queries/useLogs.test.ts @@ -0,0 +1,58 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { TFunction } from "i18next"; +import { describe, expect, it } from "vitest"; + +import type { StructuredLogMessage } from "openapi/requests/types.gen"; + +import { getLogLineText } from "./useLogs"; + +const translate = ((key: string) => key) as unknown as TFunction; + +describe("getLogLineText", () => { + it("renders a structured line with timestamp and level, stripping ANSI codes", () => { + const logMessage = { + event: "\u001B[31mfailed\u001B[0m to run", + level: "error", + timestamp: "2026-01-01T00:00:00Z", + } as StructuredLogMessage; + + expect(getLogLineText({ logMessage, showSource: false, showTimestamp: true, translate })).toBe( + "[2026-01-01T00:00:00Z] ERROR - failed to run", + ); + }); + + it("omits the timestamp when showTimestamp is false", () => { + const logMessage = { + event: "task done", + level: "info", + timestamp: "2026-01-01T00:00:00Z", + } as StructuredLogMessage; + + expect(getLogLineText({ logMessage, showSource: false, showTimestamp: false, translate })).toBe( + "INFO - task done", + ); + }); + + it("strips ANSI codes from plain string lines", () => { + expect(getLogLineText({ logMessage: "plain \u001B[32mok\u001B[0m line", translate })).toBe( + "plain ok line", + ); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx index 52f54e9263a7f..84af1d47be42f 100644 --- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx +++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx @@ -17,6 +17,7 @@ * under the License. */ import type { UseQueryOptions } from "@tanstack/react-query"; +import Anser from "anser"; import dayjs from "dayjs"; import type { TFunction } from "i18next"; import type { JSX } from "react"; @@ -24,7 +25,11 @@ import { useTranslation } from "react-i18next"; import innerText from "react-innertext"; import { useTaskInstanceServiceGetLog } from "openapi/queries"; -import type { TaskInstanceResponse, TaskInstancesLogResponse } from "openapi/requests/types.gen"; +import type { + StructuredLogMessage, + TaskInstanceResponse, + TaskInstancesLogResponse, +} from "openapi/requests/types.gen"; import { extractTIContext, renderStructuredLog, @@ -36,10 +41,47 @@ import { parseStreamingLogContent } from "src/utils/logs"; export type ParsedLogEntry = { element: JSX.Element | string | undefined; + getPlainText?: () => string; group?: { id: number; level: number; parentId?: number; type: "header" | "line" }; lineNumber?: number; }; +type GetLogLineTextOptions = { + logLevelFilters?: Array; + logMessage: string | StructuredLogMessage; + showSource?: boolean; + showTimestamp?: boolean; + sourceFilters?: Array; + translate: TFunction; +}; + +/** + * Plain-text rendering of a single log line — same pipeline as the log + * download, with ANSI escape codes stripped. Used to rebuild copied text for + * selected rows the virtualizer has unmounted. + */ +export const getLogLineText = ({ + logLevelFilters, + logMessage, + showSource, + showTimestamp, + sourceFilters, + translate, +}: GetLogLineTextOptions): string => + Anser.ansiToText( + renderStructuredLog({ + index: 0, + logLevelFilters, + logLink: "", + logMessage, + renderingMode: "text", + showSource, + showTimestamp, + sourceFilters, + translate, + }), + ); + type Props = { accept?: "*/*" | "application/json" | "application/x-ndjson"; dagId: string; @@ -117,6 +159,7 @@ const parseLogs = ({ translate, }), lineNumber: lineNumbers[index], + logMessage: datum, }; }) .filter(({ element }) => element !== ""); @@ -136,8 +179,10 @@ const parseLogs = ({ const result: Array = []; let nextGroupId = 0; - parsedLines.forEach(({ element, lineNumber }) => { + parsedLines.forEach(({ element, lineNumber, logMessage }) => { const text = innerText(element); + const getPlainText = () => + getLogLineText({ logLevelFilters, logMessage, showSource, showTimestamp, sourceFilters, translate }); if (text.includes("::group::")) { const groupName = text.split("::group::")[1] as string; @@ -167,11 +212,12 @@ const parseLogs = ({ if (groupStack.length > 0 && currentGroup) { result.push({ element, + getPlainText, group: { id: currentGroup.id, level: currentGroup.level, type: "line" }, lineNumber, }); } else { - result.push({ element, lineNumber }); + result.push({ element, getPlainText, lineNumber }); } });