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 21583e19764a6..e74dd9b62de29 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 @@ -61,13 +61,15 @@ export const TaskLogContent = ({ const { expandedGroups, - originalToVisibleIndex, + lineNumberToVisibleIndex, toggleGroup, visibleCurrentMatchIndex, visibleItems, visibleSearchMatchIndices, } = useLogGroups({ currentMatchLineIndex, expanded, parsedLogs, searchMatchIndices }); + const hashVisibleIndex = hash === "" ? undefined : lineNumberToVisibleIndex.get(Number(hash)); + const isAtBottomRef = useRef(true); const prevVisibleCountRef = useRef(0); @@ -113,15 +115,11 @@ export const TaskLogContent = ({ }, [visibleItems.length, rowVirtualizer]); useLayoutEffect(() => { - if (location.hash && !isLoading) { - const hashVisibleIndex = originalToVisibleIndex.get(Number(hash) - 1); - - if (hashVisibleIndex !== undefined) { - rowVirtualizer.scrollToIndex(Math.min(hashVisibleIndex + 5, visibleItems.length - 1)); - } + if (location.hash && !isLoading && hashVisibleIndex !== undefined) { + rowVirtualizer.scrollToIndex(Math.min(hashVisibleIndex + 5, visibleItems.length - 1)); } // React Compiler auto-memoizes; safe to include in deps - }, [isLoading, rowVirtualizer, hash, visibleItems, originalToVisibleIndex]); + }, [isLoading, rowVirtualizer, visibleItems, hashVisibleIndex]); useLayoutEffect(() => { if (visibleCurrentMatchIndex !== undefined && !isLoading) { @@ -200,7 +198,7 @@ export const TaskLogContent = ({ _rtl={{ left: "auto", right: 0 }} bgColor={getHighlightColor({ currentMatchLineIndex: visibleCurrentMatchIndex, - hash, + hashIndex: hashVisibleIndex, index: virtualRow.index, searchMatchIndices: visibleSearchMatchIndices, })} @@ -248,7 +246,7 @@ export const TaskLogContent = ({ _rtl={{ left: "auto", right: 0 }} bgColor={getHighlightColor({ currentMatchLineIndex: visibleCurrentMatchIndex, - hash, + hashIndex: hashVisibleIndex, index: virtualRow.index, searchMatchIndices: visibleSearchMatchIndices, })} diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/useLogGroups.test.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/useLogGroups.test.tsx new file mode 100644 index 0000000000000..3b5af89a11878 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/useLogGroups.test.tsx @@ -0,0 +1,50 @@ +/*! + * 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 { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { ParsedLogEntry } from "src/queries/useLogs"; + +import { useLogGroups } from "./useLogGroups"; + +const parsedLogs: Array = [ + { element: "task identity preamble" }, + { element: "::group::setup", group: { id: 0, level: 0, type: "header" } }, + { element: "line 1", group: { id: 0, level: 0, type: "line" }, lineNumber: 1 }, + { element: "line 2", group: { id: 0, level: 0, type: "line" }, lineNumber: 2 }, + { element: "line 3", lineNumber: 3 }, +]; + +describe("useLogGroups", () => { + it("maps line numbers to visible indexes when groups are expanded", () => { + const { result } = renderHook(() => useLogGroups({ expanded: true, parsedLogs })); + + expect([...result.current.lineNumberToVisibleIndex]).toStrictEqual([ + [1, 2], + [2, 3], + [3, 4], + ]); + }); + + it("skips lines hidden inside a collapsed group", () => { + const { result } = renderHook(() => useLogGroups({ expanded: false, parsedLogs })); + + expect([...result.current.lineNumberToVisibleIndex]).toStrictEqual([[3, 2]]); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/useLogGroups.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/useLogGroups.tsx index 176f481cf55ad..12b290ffff9a6 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/useLogGroups.tsx +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/useLogGroups.tsx @@ -104,12 +104,16 @@ export const useLogGroups = ({ // Build visible items list with index mapping const visibleItems: Array = []; const originalToVisibleIndex = new Map(); + const lineNumberToVisibleIndex = new Map(); for (let idx = 0; idx < parsedLogs.length; idx += 1) { const entry = parsedLogs[idx]; if (entry && isEntryVisible(entry)) { originalToVisibleIndex.set(idx, visibleItems.length); + if (entry.lineNumber !== undefined) { + lineNumberToVisibleIndex.set(entry.lineNumber, visibleItems.length); + } visibleItems.push({ entry, originalIndex: idx }); } } @@ -162,7 +166,7 @@ export const useLogGroups = ({ return { expandedGroups, - originalToVisibleIndex, + lineNumberToVisibleIndex, toggleGroup, visibleCurrentMatchIndex, visibleItems, diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.test.ts b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.test.ts index 4ca6a9f2530bd..004f9a2b6e0ce 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.test.ts +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.test.ts @@ -110,7 +110,6 @@ describe("getHighlightColor", () => { expect( getHighlightColor({ currentMatchLineIndex: 3, - hash: "", index: 3, searchMatchIndices: new Set([1, 3, 5]), }), @@ -121,7 +120,6 @@ describe("getHighlightColor", () => { expect( getHighlightColor({ currentMatchLineIndex: 1, - hash: "", index: 3, searchMatchIndices: new Set([1, 3, 5]), }), @@ -131,8 +129,8 @@ describe("getHighlightColor", () => { it("returns brand.emphasized for the URL-hash-linked line when no search is active", () => { expect( getHighlightColor({ - hash: "5", - index: 4, // hash "5" maps to index 4 (1-based to 0-based) + hashIndex: 4, + index: 4, searchMatchIndices: undefined, }), ).toBe("brand.emphasized"); @@ -141,7 +139,6 @@ describe("getHighlightColor", () => { it("returns transparent when no condition matches", () => { expect( getHighlightColor({ - hash: "", index: 2, searchMatchIndices: undefined, }), @@ -152,7 +149,6 @@ describe("getHighlightColor", () => { expect( getHighlightColor({ currentMatchLineIndex: 0, - hash: "", index: 7, searchMatchIndices: new Set([0, 2]), }), @@ -163,7 +159,7 @@ describe("getHighlightColor", () => { expect( getHighlightColor({ currentMatchLineIndex: 4, - hash: "5", + hashIndex: 4, index: 4, searchMatchIndices: new Set([4]), }), @@ -174,7 +170,7 @@ describe("getHighlightColor", () => { expect( getHighlightColor({ currentMatchLineIndex: 0, - hash: "5", + hashIndex: 4, index: 4, searchMatchIndices: new Set([0, 4]), }), @@ -185,7 +181,6 @@ describe("getHighlightColor", () => { expect( getHighlightColor({ currentMatchLineIndex: undefined, - hash: "", index: 0, searchMatchIndices: new Set(), }), diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts index 424006498ee26..248a89de2a589 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts @@ -85,7 +85,7 @@ export const getDownloadText = ({ export type HighlightOptions = { currentMatchLineIndex?: number; - hash: string; + hashIndex?: number; index: number; searchMatchIndices?: Set; }; @@ -97,7 +97,7 @@ export type HighlightOptions = { */ export const getHighlightColor = ({ currentMatchLineIndex, - hash, + hashIndex, index, searchMatchIndices, }: HighlightOptions): string => { @@ -107,7 +107,7 @@ export const getHighlightColor = ({ if (searchMatchIndices?.has(index)) { return "yellow.subtle"; } - if (Boolean(hash) && index === Number(hash) - 1) { + if (hashIndex !== undefined && index === hashIndex) { return "brand.emphasized"; } diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx index 3d0443c04cdb3..52f54e9263a7f 100644 --- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx +++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx @@ -37,6 +37,7 @@ import { parseStreamingLogContent } from "src/utils/logs"; export type ParsedLogEntry = { element: JSX.Element | string | undefined; group?: { id: number; level: number; parentId?: number; type: "header" | "line" }; + lineNumber?: number; }; type Props = { @@ -103,19 +104,22 @@ const parseLogs = ({ } } - return renderStructuredLog({ - index: lineNumbers[index] ?? index, - logLevelFilters, - logLink, - logMessage: datum, - renderingMode: "jsx", - showSource, - showTimestamp, - sourceFilters, - translate, - }); + return { + element: renderStructuredLog({ + index: lineNumbers[index] ?? index, + logLevelFilters, + logLink, + logMessage: datum, + renderingMode: "jsx", + showSource, + showTimestamp, + sourceFilters, + translate, + }), + lineNumber: lineNumbers[index], + }; }) - .filter((parsedLine) => parsedLine !== ""); + .filter(({ element }) => element !== ""); } catch (error) { const errorMessage = error instanceof Error ? error.message : "An error occurred."; @@ -132,8 +136,8 @@ const parseLogs = ({ const result: Array = []; let nextGroupId = 0; - parsedLines.forEach((line) => { - const text = innerText(line); + parsedLines.forEach(({ element, lineNumber }) => { + const text = innerText(element); if (text.includes("::group::")) { const groupName = text.split("::group::")[1] as string; @@ -162,11 +166,12 @@ const parseLogs = ({ if (groupStack.length > 0 && currentGroup) { result.push({ - element: line, + element, group: { id: currentGroup.id, level: currentGroup.level, type: "line" }, + lineNumber, }); } else { - result.push({ element: line }); + result.push({ element, lineNumber }); } });