diff --git a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx index f3febe79687be..38b430ebb556f 100644 --- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx +++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx @@ -274,7 +274,7 @@ const renderStructuredLogImpl = ({ } return ( - + { await waitFor(() => expect(screen.queryByText(/Marking task as SUCCESS/iu)).not.toBeVisible()); }, 10_000); + + it("Has sequential line numbers with no gaps or duplicates", async () => { + render( + , + ); + + await waitForLogs(); + + // Expand all groups so their content lines are in the DOM + const summaryPre = screen.getByTestId("summary-Pre task execution logs"); + + fireEvent.click(summaryPre); + await waitFor(() => expect(screen.getByText(/starting attempt 1 of 3/iu)).toBeVisible()); + + const summaryPost = screen.getByTestId("summary-Post task execution logs"); + + fireEvent.click(summaryPost); + await waitFor(() => expect(screen.queryByText(/Marking task as SUCCESS/iu)).toBeVisible()); + + // Collect all rendered line number links + const lineNumbers = [...document.querySelectorAll("a[id]")] + .map((el) => parseInt(el.id, 10)) + .filter((num) => !isNaN(num)) + .sort((numA, numB) => numA - numB); + + expect(lineNumbers.length).toBeGreaterThan(0); + expect(lineNumbers[0]).toBe(0); + expect(new Set(lineNumbers).size).toBe(lineNumbers.length); + + // No gaps + lineNumbers.forEach((num, idx) => { + expect(num).toBe(idx); + }); + }, 10_000); }); diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx index 1a82896741468..1550f6eb64655 100644 --- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx +++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx @@ -75,6 +75,20 @@ const parseLogs = ({ const logLink = taskInstance ? `${getTaskInstanceLink(taskInstance)}?try_number=${tryNumber}` : ""; try { + let lineNumber = 0; + const lineNumbers = data.map((datum, dataIndex) => { + const text = typeof datum === "string" ? datum : datum.event; + + if (text.includes("::group::") || text.includes("::endgroup::")) { + return dataIndex; + } + const current = lineNumber; + + lineNumber += 1; + + return current; + }); + parsedLines = data .map((datum, index) => { if (typeof datum !== "string" && "logger" in datum) { @@ -86,7 +100,7 @@ const parseLogs = ({ } return renderStructuredLog({ - index, + index: lineNumbers[index] ?? index, logLevelFilters, logLink, logMessage: datum,