This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 4d5d876d610 feat(ui): add search functionality to task log viewer
(#63467)
4d5d876d610 is described below
commit 4d5d876d610c986ecce1c61a779fd21b37e9f314
Author: Antonio Mello <[email protected]>
AuthorDate: Mon Apr 13 14:55:43 2026 -0300
feat(ui): add search functionality to task log viewer (#63467)
Add text search with navigation and highlighting to the task log viewer.
Search input in the header with keyboard shortcuts (/, Enter, Shift+Enter,
Escape), yellow highlighting for matches (emphasized for active, subtle for
others), auto-scroll via useVirtualizer, and i18n support.
Log groups (::group::/::endgroup::) are rendered as flat entries with group
metadata instead of collapsed <details> elements. This allows search to find
matches per-line inside groups rather than treating each group as a single
searchable unit. A useLogGroups hook manages expand/collapse state with
support for nested groups and auto-expansion when search navigates to a
match inside a collapsed group.
---
.../src/airflow/ui/public/i18n/locales/en/dag.json | 5 +
.../src/airflow/ui/src/mocks/handlers/log.ts | 9 +
.../ui/src/pages/Dag/Overview/TaskLogPreview.tsx | 3 +-
.../pages/TaskInstance/Logs/HighlightedText.tsx | 47 ++++
.../TaskInstance/Logs/LogSearchInput.test.tsx | 177 ++++++++++++++
.../src/pages/TaskInstance/Logs/LogSearchInput.tsx | 118 ++++++++++
.../ui/src/pages/TaskInstance/Logs/Logs.test.tsx | 252 ++++++++++++++++++--
.../ui/src/pages/TaskInstance/Logs/Logs.tsx | 80 +++++--
.../src/pages/TaskInstance/Logs/ScrollToButton.tsx | 60 +++++
.../src/pages/TaskInstance/Logs/TaskLogContent.tsx | 253 +++++++++++++--------
.../src/pages/TaskInstance/Logs/TaskLogHeader.tsx | 7 +-
.../src/pages/TaskInstance/Logs/useLogGroups.tsx | 171 ++++++++++++++
.../ui/src/pages/TaskInstance/Logs/utils.test.ts | 162 +++++++++++++
.../ui/src/pages/TaskInstance/Logs/utils.ts | 113 +++++++++
.../src/airflow/ui/src/queries/useLogs.tsx | 93 ++++----
15 files changed, 1357 insertions(+), 193 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index 58626c24942..563b53eefe1 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -66,6 +66,11 @@
},
"info": "INFO",
"noTryNumber": "No try number",
+ "search": {
+ "matchCount": "{{current}} of {{total}}",
+ "noMatches": "No matches",
+ "placeholder": "Search logs..."
+ },
"settings": "Log Settings",
"viewInExternal": "View logs in {{name}} (attempt {{attempt}})",
"warning": "WARNING"
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/log.ts
b/airflow-core/src/airflow/ui/src/mocks/handlers/log.ts
index b9ed6b73448..b9d9d2cfc62 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/log.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/log.ts
@@ -82,6 +82,11 @@ export const handlers: Array<HttpHandler> = [
"[2025-02-28T10:49:09.535+0530] {local_task_job_runner.py:120}
INFO - ::group::Pre task execution logs",
timestamp: "2025-02-28T10:49:09.535000+05:30",
},
+ {
+ event:
+ "[2025-02-28T10:49:09.673+0530] {taskinstance.py:2340} INFO -
::group::Dependency check details",
+ timestamp: "2025-02-28T10:49:09.673000+05:30",
+ },
{
event:
"[2025-02-28T10:49:09.674+0530] {taskinstance.py:2348} INFO -
Dependencies all met for dep_context=non-requeueable deps ti=<TaskInstance:
tutorial_dag.load manual__2025-02-28T05:18:54.249762+00:00 [queued]>",
@@ -92,6 +97,10 @@ export const handlers: Array<HttpHandler> = [
"[2025-02-28T10:49:09.678+0530] {taskinstance.py:2348} INFO -
Dependencies all met for dep_context=requeueable deps ti=<TaskInstance:
tutorial_dag.load manual__2025-02-28T05:18:54.249762+00:00 [queued]>",
timestamp: "2025-02-28T10:49:09.678000+05:30",
},
+ {
+ event: "[2025-02-28T10:49:09.679+0530] {taskinstance.py:2349} INFO -
::endgroup::",
+ timestamp: "2025-02-28T10:49:09.679000+05:30",
+ },
{
event: "[2025-02-28T10:49:09.679+0530] {taskinstance.py:2589} INFO -
Starting attempt 1 of 3",
timestamp: "2025-02-28T10:49:09.679000+05:30",
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
index 9554c02e216..e4ca4e1e966 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
@@ -84,13 +84,14 @@ export const TaskLogPreview = ({
<Box borderTopStyle="solid" borderTopWidth={1} maxHeight="200px"
overflow="auto">
<TaskLogContent
error={error}
+ expanded
isLoading={isLoading}
logError={error}
parsedLogs={data.parsedLogs ?? []}
wrap={wrap}
/>
</Box>
- ) : null}
+ ) : undefined}
</Box>
);
};
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/HighlightedText.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/HighlightedText.tsx
new file mode 100644
index 00000000000..48f647f2f29
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/HighlightedText.tsx
@@ -0,0 +1,47 @@
+/*!
+ * 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 { Mark } from "@chakra-ui/react";
+import type { PropsWithChildren } from "react";
+
+import { splitBySearchQuery } from "./utils";
+
+type HighlightedTextProps = PropsWithChildren<{ readonly query?: string }>;
+
+export const HighlightedText = ({ children, query }: HighlightedTextProps) => {
+ if (typeof query !== "string" || query.length === 0 || typeof children !==
"string") {
+ return children;
+ }
+
+ const segments = splitBySearchQuery(children, query);
+
+ return (
+ <span>
+ {segments.map((segment, idx) =>
+ segment.highlight ? (
+ // eslint-disable-next-line react/no-array-index-key
+ <Mark bg="yellow.subtle" key={idx}>
+ {segment.text}
+ </Mark>
+ ) : (
+ segment.text
+ ),
+ )}
+ </span>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.test.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.test.tsx
new file mode 100644
index 00000000000..53f48ad9fbc
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.test.tsx
@@ -0,0 +1,177 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { LogSearchInput } from "./LogSearchInput";
+
+const defaultProps = {
+ currentMatchIndex: 0,
+ onSearchChange: vi.fn(),
+ onSearchNext: vi.fn(),
+ onSearchPrevious: vi.fn(),
+ searchQuery: "",
+ totalMatches: 0,
+};
+
+describe("LogSearchInput", () => {
+ describe("initial render", () => {
+ it("renders the search input", () => {
+ render(<LogSearchInput {...defaultProps} />, { wrapper: Wrapper });
+ expect(screen.getByTestId("log-search-input")).toBeInTheDocument();
+ });
+
+ it("does not show match counter or navigation buttons when searchQuery is
empty", () => {
+ render(<LogSearchInput {...defaultProps} />, { wrapper: Wrapper });
+ expect(screen.queryByRole("button", { name: /next match/iu
})).toBeNull();
+ expect(screen.queryByRole("button", { name: /previous match/iu
})).toBeNull();
+ });
+
+ it("shows match counter and navigation buttons when searchQuery is
non-empty", () => {
+ render(<LogSearchInput {...defaultProps} searchQuery="error"
totalMatches={3} />, {
+ wrapper: Wrapper,
+ });
+ expect(screen.getByRole("button", { name: /next match/iu
})).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /previous match/iu
})).toBeInTheDocument();
+ });
+ });
+
+ describe("keyboard navigation", () => {
+ it("calls onSearchNext when Enter is pressed in the input", () => {
+ const onSearchNext = vi.fn();
+
+ render(
+ <LogSearchInput {...defaultProps} onSearchNext={onSearchNext}
searchQuery="info" totalMatches={5} />,
+ {
+ wrapper: Wrapper,
+ },
+ );
+
+ fireEvent.keyDown(screen.getByTestId("log-search-input"), { key: "Enter"
});
+ expect(onSearchNext).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onSearchPrevious when Shift+Enter is pressed", () => {
+ const onSearchPrevious = vi.fn();
+
+ render(
+ <LogSearchInput
+ {...defaultProps}
+ onSearchPrevious={onSearchPrevious}
+ searchQuery="info"
+ totalMatches={5}
+ />,
+ { wrapper: Wrapper },
+ );
+
+ fireEvent.keyDown(screen.getByTestId("log-search-input"), { key:
"Enter", shiftKey: true });
+ expect(onSearchPrevious).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onSearchChange with empty string when Escape is pressed", () => {
+ const onSearchChange = vi.fn();
+
+ render(
+ <LogSearchInput
+ {...defaultProps}
+ onSearchChange={onSearchChange}
+ searchQuery="warning"
+ totalMatches={2}
+ />,
+ {
+ wrapper: Wrapper,
+ },
+ );
+
+ fireEvent.keyDown(screen.getByTestId("log-search-input"), { key:
"Escape" });
+ expect(onSearchChange).toHaveBeenCalledWith("");
+ });
+ });
+
+ describe("navigation buttons", () => {
+ it("calls onSearchNext when the next-match button is clicked", () => {
+ const onSearchNext = vi.fn();
+
+ render(
+ <LogSearchInput {...defaultProps} onSearchNext={onSearchNext}
searchQuery="task" totalMatches={4} />,
+ {
+ wrapper: Wrapper,
+ },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /next match/iu }));
+ expect(onSearchNext).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onSearchPrevious when the previous-match button is clicked", ()
=> {
+ const onSearchPrevious = vi.fn();
+
+ render(
+ <LogSearchInput
+ {...defaultProps}
+ onSearchPrevious={onSearchPrevious}
+ searchQuery="task"
+ totalMatches={4}
+ />,
+ {
+ wrapper: Wrapper,
+ },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /previous match/iu
}));
+ expect(onSearchPrevious).toHaveBeenCalledTimes(1);
+ });
+
+ it("navigation buttons are disabled when there are no matches", () => {
+ render(<LogSearchInput {...defaultProps} searchQuery="notfound"
totalMatches={0} />, {
+ wrapper: Wrapper,
+ });
+
+ expect(screen.getByRole("button", { name: /next match/iu
})).toBeDisabled();
+ expect(screen.getByRole("button", { name: /previous match/iu
})).toBeDisabled();
+ });
+ });
+
+ describe("clear button", () => {
+ it("calls onSearchChange with empty string when the clear button is
clicked", () => {
+ const onSearchChange = vi.fn();
+
+ render(
+ <LogSearchInput
+ {...defaultProps}
+ onSearchChange={onSearchChange}
+ searchQuery="error"
+ totalMatches={1}
+ />,
+ {
+ wrapper: Wrapper,
+ },
+ );
+
+ // The CloseButton has no label text; find it by its role inside the
search widget
+ const closeButton = screen.getByRole("button", { name: /close/iu });
+
+ fireEvent.click(closeButton);
+ expect(onSearchChange).toHaveBeenCalledWith("");
+ });
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
new file mode 100644
index 00000000000..d9a417e0a66
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
@@ -0,0 +1,118 @@
+/*!
+ * 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 { CloseButton, HStack, IconButton, Input, InputGroup, Text } from
"@chakra-ui/react";
+import { useRef, type KeyboardEvent } from "react";
+import { useHotkeys } from "react-hotkeys-hook";
+import { useTranslation } from "react-i18next";
+import { FiChevronDown, FiChevronUp, FiSearch } from "react-icons/fi";
+
+export type LogSearchInputProps = {
+ readonly currentMatchIndex: number;
+ readonly onSearchChange: (query: string) => void;
+ readonly onSearchNext: () => void;
+ readonly onSearchPrevious: () => void;
+ readonly searchQuery: string;
+ readonly totalMatches: number;
+};
+
+export const LogSearchInput = ({
+ currentMatchIndex,
+ onSearchChange,
+ onSearchNext,
+ onSearchPrevious,
+ searchQuery,
+ totalMatches,
+}: LogSearchInputProps) => {
+ const { t: translate } = useTranslation("dag");
+ const searchInputRef = useRef<HTMLInputElement>(null);
+
+ useHotkeys(
+ "/",
+ (event) => {
+ event.preventDefault();
+ searchInputRef.current?.focus();
+ },
+ { enableOnFormTags: false },
+ );
+
+ const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === "Enter") {
+ if (event.shiftKey) {
+ onSearchPrevious();
+ } else {
+ onSearchNext();
+ }
+ }
+ if (event.key === "Escape") {
+ onSearchChange("");
+ searchInputRef.current?.blur();
+ }
+ };
+
+ return (
+ <HStack flex={1} gap={1} maxW="350px" minW="150px">
+ <InputGroup
+ endElement={
+ searchQuery ? (
+ <HStack gap={0.5}>
+ <Text color="fg.muted" fontSize="xs" whiteSpace="nowrap">
+ {totalMatches > 0
+ ? translate("logs.search.matchCount", {
+ current: currentMatchIndex + 1,
+ total: totalMatches,
+ })
+ : translate("logs.search.noMatches")}
+ </Text>
+ <IconButton
+ aria-label="Previous match"
+ disabled={totalMatches === 0}
+ onClick={onSearchPrevious}
+ size="2xs"
+ variant="ghost"
+ >
+ <FiChevronUp />
+ </IconButton>
+ <IconButton
+ aria-label="Next match"
+ disabled={totalMatches === 0}
+ onClick={onSearchNext}
+ size="2xs"
+ variant="ghost"
+ >
+ <FiChevronDown />
+ </IconButton>
+ <CloseButton onClick={() => onSearchChange("")} size="2xs" />
+ </HStack>
+ ) : undefined
+ }
+ startElement={<FiSearch />}
+ >
+ <Input
+ data-testid="log-search-input"
+ onChange={(event) => onSearchChange(event.target.value)}
+ onKeyDown={handleSearchKeyDown}
+ placeholder={translate("logs.search.placeholder")}
+ ref={searchInputRef}
+ size="sm"
+ value={searchQuery}
+ />
+ </InputGroup>
+ </HStack>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
index 253bdd0bc3d..cae7f847caa 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
@@ -37,9 +37,12 @@ const waitForLogs = async () => {
await waitFor(() =>
expect(screen.getByTestId("virtualized-list")).toBeInTheDocument());
// Wait for virtualized items to be rendered - they might not all be visible
initially
+ // Items can have either virtualized-item- or group-header- testid prefixes
await waitFor(() => {
const virtualizedList = screen.getByTestId("virtualized-list");
- const virtualizedItems =
virtualizedList.querySelectorAll('[data-testid^="virtualized-item-"]');
+ const virtualizedItems = virtualizedList.querySelectorAll(
+ '[data-testid^="virtualized-item-"], [data-testid^="group-header-"]',
+ );
expect(virtualizedItems.length).toBeGreaterThan(0);
});
@@ -87,36 +90,52 @@ describe("Task log grouping", () => {
await waitForLogs();
+ // Group headers use the summary-{name} testid pattern and are always
visible
const summarySource = screen.getByTestId(
'summary-Log message source details
sources=["/home/airflow/logs/dag_id=tutorial_dag/run_id=manual__2025-02-28T05:18:54.249762+00:00/task_id=load/attempt=1.log"]',
);
expect(summarySource).toBeVisible();
- fireEvent.click(summarySource);
- await waitFor(() =>
expect(screen.queryByText(/sources=\[/iu)).toBeVisible());
const summaryPre = screen.getByTestId("summary-Pre task execution logs");
expect(summaryPre).toBeVisible();
- fireEvent.click(summaryPre);
- await waitFor(() => expect(screen.getByText(/starting attempt 1 of
3/iu)).toBeVisible());
+ // All groups start collapsed. Verify Post header is in the virtualizer
range.
const summaryPost = screen.getByTestId("summary-Post task execution logs");
expect(summaryPost).toBeVisible();
- fireEvent.click(summaryPost);
- await waitFor(() => expect(screen.queryByText(/Marking task as
SUCCESS/iu)).toBeVisible());
+ // Groups start collapsed — content should not be in DOM
+ expect(screen.queryByText(/starting attempt 1 of 3/iu)).toBeNull();
+
+ // Click to expand Pre
+ fireEvent.click(summaryPre);
+ await waitFor(() => expect(screen.getByText(/starting attempt 1 of
3/iu)).toBeInTheDocument());
+
+ // Click Pre to collapse
fireEvent.click(summaryPre);
- await waitFor(() => expect(screen.queryByText(/Task instance is in running
state/iu)).not.toBeVisible());
+ await waitFor(() => expect(screen.queryByText(/Task instance is in running
state/iu)).toBeNull());
+
+ // Click Pre to expand again
fireEvent.click(summaryPre);
- await waitFor(() => expect(screen.queryByText(/Task instance is in running
state/iu)).toBeVisible());
+ await waitFor(() =>
+ expect(screen.queryByText(/Task instance is in running
state/iu)).toBeInTheDocument(),
+ );
- fireEvent.click(summaryPost);
- await waitFor(() => expect(screen.queryByText(/Marking task as
SUCCESS/iu)).not.toBeVisible());
- fireEvent.click(summaryPost);
- await waitFor(() => expect(screen.queryByText(/Marking task as
SUCCESS/iu)).toBeVisible());
+ // Click Pre to collapse again (to return to compact view)
+ fireEvent.click(summaryPre);
+ await waitFor(() => expect(screen.queryByText(/Task instance is in running
state/iu)).toBeNull());
+ // Now expand Post (which is visible because Pre is collapsed again)
+ fireEvent.click(screen.getByTestId("summary-Post task execution logs"));
+ await waitFor(() => expect(screen.queryByText(/Marking task as
SUCCESS/iu)).toBeInTheDocument());
+
+ // Collapse Post
+ fireEvent.click(screen.getByTestId("summary-Post task execution logs"));
+ await waitFor(() => expect(screen.queryByText(/Marking task as
SUCCESS/iu)).toBeNull());
+
+ // Test Expand All / Collapse All via settings menu
const settingsBtn = screen.getByRole("button", { name: /settings/iu });
fireEvent.click(settingsBtn);
@@ -125,12 +144,213 @@ describe("Task log grouping", () => {
fireEvent.click(expandItem);
- /* ─── NEW: open again & click "Collapse" ─── */
- fireEvent.click(settingsBtn); // menu is closed after previous click, so
reopen
+ // After "Expand All", Pre group content near the top should be visible
+ await waitFor(() => expect(screen.queryByText(/starting attempt 1 of
3/iu)).toBeInTheDocument());
+
+ /* ─── Click "Collapse" ─── */
+ fireEvent.click(settingsBtn);
const collapseItem = await screen.findByRole("menuitem", { name:
/collapse/iu });
fireEvent.click(collapseItem);
- await waitFor(() => expect(screen.queryByText(/Marking task as
SUCCESS/iu)).not.toBeVisible());
+ // After "Collapse All", group content should be gone from the DOM
+ await waitFor(() => expect(screen.queryByText(/starting attempt 1 of
3/iu)).toBeNull());
+ }, 10_000);
+
+ it("renders nested groups correctly", async () => {
+ render(
+ <AppWrapper
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]}
/>,
+ );
+
+ await waitForLogs();
+
+ // The nested group "Dependency check details" should have a header
+ // First expand the parent group "Pre task execution logs"
+ const summaryPre = screen.getByTestId("summary-Pre task execution logs");
+
+ fireEvent.click(summaryPre);
+
+ // The nested group header should now be visible
+ await waitFor(() => expect(screen.getByTestId("summary-Dependency check
details")).toBeInTheDocument());
+
+ // But nested group content is collapsed
+ expect(screen.queryByText(/dep_context=non-requeueable/iu)).toBeNull();
+
+ // Expand the nested group
+ fireEvent.click(screen.getByTestId("summary-Dependency check details"));
+ await waitFor(() =>
expect(screen.getByText(/dep_context=non-requeueable/iu)).toBeInTheDocument());
+ }, 10_000);
+});
+
+describe("Task log search", () => {
+ it("search input is rendered in the log header", async () => {
+ render(
+ <AppWrapper
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
/>,
+ );
+
+ await waitForLogs();
+
+ expect(screen.getByTestId("log-search-input")).toBeInTheDocument();
+ });
+
+ it("typing in the search input enables navigation buttons for a known term",
async () => {
+ render(
+ <AppWrapper
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
/>,
+ );
+
+ await waitForLogs();
+
+ const searchInput = screen.getByTestId("log-search-input");
+
+ // "running state" appears in the mock log data
+ fireEvent.change(searchInput, { target: { value: "running state" } });
+
+ // Navigation buttons should become enabled once matches are found
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /next match/iu
})).not.toBeDisabled();
+ expect(screen.getByRole("button", { name: /previous match/iu
})).not.toBeDisabled();
+ });
+ });
+
+ it("shows no-matches indicator for a term that does not exist in logs",
async () => {
+ render(
+ <AppWrapper
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
/>,
+ );
+
+ await waitForLogs();
+
+ const searchInput = screen.getByTestId("log-search-input");
+
+ fireEvent.change(searchInput, { target: { value: "zzz_not_in_logs_zzz" }
});
+
+ await waitFor(() => {
+ // Navigation buttons should be disabled with zero matches
+ const nextBtn = screen.getByRole("button", { name: /next match/iu });
+
+ expect(nextBtn).toBeDisabled();
+ });
+ });
+
+ it("pressing Escape clears the search query", async () => {
+ render(
+ <AppWrapper
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
/>,
+ );
+
+ await waitForLogs();
+
+ const searchInput = screen.getByTestId("log-search-input");
+
+ fireEvent.change(searchInput, { target: { value: "running" } });
+
+ await waitFor(() => expect(screen.queryByRole("button", { name: /next
match/iu })).toBeInTheDocument());
+
+ fireEvent.keyDown(searchInput, { key: "Escape" });
+
+ await waitFor(() => {
+ expect((searchInput as HTMLInputElement).value).toBe("");
+ });
+ });
+
+ it("pressing Enter keeps navigation buttons enabled (navigates to next
match)", async () => {
+ render(
+ <AppWrapper
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
/>,
+ );
+
+ await waitForLogs();
+
+ const searchInput = screen.getByTestId("log-search-input");
+
+ // "state" appears multiple times in the mock log data
+ fireEvent.change(searchInput, { target: { value: "state" } });
+
+ // Wait for matches to be found (navigation buttons become enabled)
+ await waitFor(() => expect(screen.getByRole("button", { name: /next
match/iu })).not.toBeDisabled());
+
+ // Navigate forward — buttons remain enabled
+ fireEvent.keyDown(searchInput, { key: "Enter" });
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /next match/iu
})).not.toBeDisabled();
+ });
+ });
+
+ it("pressing Shift+Enter keeps navigation buttons enabled (navigates to
previous match)", async () => {
+ render(
+ <AppWrapper
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
/>,
+ );
+
+ await waitForLogs();
+
+ const searchInput = screen.getByTestId("log-search-input");
+
+ fireEvent.change(searchInput, { target: { value: "state" } });
+
+ // Wait for matches to be found
+ await waitFor(() => expect(screen.getByRole("button", { name: /previous
match/iu })).not.toBeDisabled());
+
+ // Navigate backward — buttons remain enabled
+ fireEvent.keyDown(searchInput, { key: "Enter", shiftKey: true });
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /previous match/iu
})).not.toBeDisabled();
+ });
+ });
+
+ it("search finds matches per-line inside log groups (not collapsed into 1
match per group)", async () => {
+ render(
+ <AppWrapper
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]}
/>,
+ );
+
+ await waitForLogs();
+
+ const searchInput = screen.getByTestId("log-search-input");
+
+ // "INFO" appears in many individual log lines across multiple groups.
+ // With the old code, each group collapsed into 1 match. Now each line is
a separate match.
+ fireEvent.change(searchInput, { target: { value: "INFO" } });
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /next match/iu
})).not.toBeDisabled();
+ });
+
+ // Verify per-line search by navigating through matches.
+ // With the old code (1 match per group), there were only 3 matches for
"INFO".
+ // Now each line is a separate match, so we can navigate through many more.
+ const nextBtn = screen.getByRole("button", { name: /next match/iu });
+
+ // Navigate forward 4 times — if only 3 matches existed, the 4th click
would wrap to #1
+ // We just verify it stays enabled (which it always does with >0 matches).
+ // The real proof is that the auto-expand test passes: searching "starting
attempt"
+ // finds a match INSIDE a group, which was impossible with the old
collapsed approach.
+ for (let navStep = 0; navStep < 4; navStep += 1) {
+ fireEvent.click(nextBtn);
+ }
+
+ await waitFor(() => {
+ expect(nextBtn).not.toBeDisabled();
+ });
+ }, 10_000);
+
+ it("search navigating to match in collapsed group auto-expands it", async ()
=> {
+ render(
+ <AppWrapper
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]}
/>,
+ );
+
+ await waitForLogs();
+
+ // All groups start collapsed. Search for text that only appears inside a
group.
+ const searchInput = screen.getByTestId("log-search-input");
+
+ fireEvent.change(searchInput, { target: { value: "starting attempt" } });
+
+ await waitFor(() => {
+ const nextBtn = screen.getByRole("button", { name: /next match/iu });
+
+ expect(nextBtn).not.toBeDisabled();
+ });
+
+ // The match is inside "Pre task execution logs" group.
+ // Auto-expand should make it visible.
+ await waitFor(() => expect(screen.getByText(/starting attempt 1 of
3/iu)).toBeInTheDocument());
}, 10_000);
});
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
index c37b1e0b052..b9a2d61f08c 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
@@ -24,17 +24,16 @@ import { useParams, useSearchParams } from
"react-router-dom";
import { useLocalStorage } from "usehooks-ts";
import { useTaskInstanceServiceGetMappedTaskInstance } from "openapi/queries";
-import { renderStructuredLog } from "src/components/renderStructuredLog";
import { Dialog } from "src/components/ui";
import { LOG_SHOW_SOURCE_KEY, LOG_SHOW_TIMESTAMP_KEY, LOG_WRAP_KEY } from
"src/constants/localStorage";
import { SearchParamsKeys } from "src/constants/searchParams";
import { useConfig } from "src/queries/useConfig";
import { useLogs } from "src/queries/useLogs";
-import { parseStreamingLogContent } from "src/utils/logs";
import { ExternalLogLink } from "./ExternalLogLink";
import { TaskLogContent, type TaskLogContentProps } from "./TaskLogContent";
import { TaskLogHeader, type TaskLogHeaderProps } from "./TaskLogHeader";
+import { getDownloadText } from "./utils";
export const Logs = () => {
const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams();
@@ -89,7 +88,6 @@ export const Logs = () => {
parsedData,
} = useLogs({
dagId,
- expanded,
logLevelFilters,
showSource,
showTimestamp,
@@ -98,28 +96,52 @@ export const Logs = () => {
tryNumber,
});
- const getParsedLogs = () => {
- const lines = parseStreamingLogContent(fetchedData);
-
- return lines.map((line) =>
- renderStructuredLog({
- index: 0,
- logLevelFilters,
- logLink: "",
- logMessage: line,
- renderingMode: "text",
- showSource,
- showTimestamp,
- sourceFilters,
- translate,
- }),
- );
+ const downloadTextLines = getDownloadText({
+ fetchedData,
+ logLevelFilters,
+ showSource,
+ showTimestamp,
+ sourceFilters,
+ translate,
+ });
+
+ const getLogString = () => downloadTextLines.filter((line) => line !==
"").join("\n");
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [activeSearchIndex, setActiveSearchIndex] = useState(0);
+
+ const searchMatchIndices = (() => {
+ if (!searchQuery) {
+ return [];
+ }
+ const query = searchQuery.toLowerCase();
+ const indices: Array<number> = [];
+
+ parsedData.searchableText.forEach((line, index) => {
+ if (line.toLowerCase().includes(query)) {
+ indices.push(index);
+ }
+ });
+
+ return indices;
+ })();
+
+ const handleSearchChange = (query: string) => {
+ setSearchQuery(query);
+ setActiveSearchIndex(0);
+ };
+
+ const handleSearchNext = () => {
+ if (searchMatchIndices.length > 0) {
+ setActiveSearchIndex((prev) => (prev + 1) % searchMatchIndices.length);
+ }
};
- const getLogString = () =>
- getParsedLogs()
- .filter((line) => line !== "")
- .join("\n");
+ const handleSearchPrevious = () => {
+ if (searchMatchIndices.length > 0) {
+ setActiveSearchIndex((prev) => (prev - 1 + searchMatchIndices.length) %
searchMatchIndices.length);
+ }
+ };
const downloadLogs = () => {
const logContent = getLogString();
@@ -157,6 +179,14 @@ export const Logs = () => {
expanded,
getLogString,
onSelectTryNumber,
+ search: {
+ currentMatchIndex: activeSearchIndex,
+ onSearchChange: handleSearchChange,
+ onSearchNext: handleSearchNext,
+ onSearchPrevious: handleSearchPrevious,
+ searchQuery,
+ totalMatches: searchMatchIndices.length,
+ },
showSource,
showTimestamp,
sourceOptions: parsedData.sources,
@@ -171,10 +201,14 @@ export const Logs = () => {
};
const logContentProps: TaskLogContentProps = {
+ currentMatchLineIndex: searchMatchIndices[activeSearchIndex],
error,
+ expanded,
isLoading: isLoading || isLoadingLogs,
logError,
parsedLogs: parsedData.parsedLogs ?? [],
+ searchMatchIndices: searchQuery ? new Set(searchMatchIndices) : undefined,
+ searchQuery: searchQuery || undefined,
wrap,
};
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ScrollToButton.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ScrollToButton.tsx
new file mode 100644
index 00000000000..579f23ef814
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ScrollToButton.tsx
@@ -0,0 +1,60 @@
+/*!
+ * 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 { IconButton } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiChevronDown, FiChevronUp } from "react-icons/fi";
+
+import { Tooltip } from "src/components/ui";
+import { getMetaKey } from "src/utils";
+
+export const ScrollToButton = ({
+ direction,
+ onClick,
+}: {
+ readonly direction: "bottom" | "top";
+ readonly onClick: () => void;
+}) => {
+ const { t: translate } = useTranslation("common");
+
+ return (
+ <Tooltip
+ closeDelay={100}
+ content={translate("scroll.tooltip", {
+ direction: translate(`scroll.direction.${direction}`),
+ hotkey: `${getMetaKey()}+${direction === "bottom" ? "↓" : "↑"}`,
+ })}
+ openDelay={100}
+ >
+ <IconButton
+ _ltr={{ left: "auto", right: 4 }}
+ _rtl={{ left: 4, right: "auto" }}
+ aria-label={translate(`scroll.direction.${direction}`)}
+ bg="bg.panel"
+ bottom={direction === "bottom" ? 4 : 14}
+ onClick={onClick}
+ position="absolute"
+ rounded="full"
+ size="xs"
+ variant="outline"
+ >
+ {direction === "bottom" ? <FiChevronDown /> : <FiChevronUp />}
+ </IconButton>
+ </Tooltip>
+ );
+};
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 59ba801bacc..21583e19764 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
@@ -16,84 +16,63 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Code, VStack, IconButton } from "@chakra-ui/react";
+import { Box, Code, VStack } from "@chakra-ui/react";
import { useVirtualizer } from "@tanstack/react-virtual";
-import { type JSX, useLayoutEffect, useRef, useCallback, useEffect } from
"react";
+import { useLayoutEffect, useRef, useCallback, useEffect } from "react";
import { useHotkeys } from "react-hotkeys-hook";
-import { useTranslation } from "react-i18next";
-import { FiChevronDown, FiChevronUp } from "react-icons/fi";
import { ErrorAlert } from "src/components/ErrorAlert";
-import { ProgressBar, Tooltip } from "src/components/ui";
-import { getMetaKey } from "src/utils";
+import { ProgressBar } from "src/components/ui";
+import type { ParsedLogEntry } from "src/queries/useLogs";
-import { scrollToBottom, scrollToTop } from "./utils";
+import { HighlightedText } from "./HighlightedText";
+import { ScrollToButton } from "./ScrollToButton";
+import { useLogGroups } from "./useLogGroups";
+import { getHighlightColor, scrollToBottom, scrollToTop } from "./utils";
export type TaskLogContentProps = {
+ readonly currentMatchLineIndex?: number;
readonly error: unknown;
+ readonly expanded: boolean;
readonly isLoading: boolean;
readonly logError: unknown;
- readonly parsedLogs: Array<JSX.Element | string | undefined>;
+ readonly parsedLogs: Array<ParsedLogEntry>;
+ readonly searchMatchIndices?: Set<number>;
+ readonly searchQuery?: string;
readonly wrap: boolean;
};
// How close to the bottom (in px) before we consider the user "at the bottom"
const SCROLL_BOTTOM_THRESHOLD = 100;
-const ScrollToButton = ({
- direction,
- onClick,
-}: {
- readonly direction: "bottom" | "top";
- readonly onClick: () => void;
-}) => {
- const { t: translate } = useTranslation("common");
-
- return (
- <Tooltip
- closeDelay={100}
- content={translate("scroll.tooltip", {
- direction: translate(`scroll.direction.${direction}`),
- hotkey: `${getMetaKey()}+${direction === "bottom" ? "↓" : "↑"}`,
- })}
- openDelay={100}
- >
- <IconButton
- _ltr={{
- left: "auto",
- right: 4,
- }}
- _rtl={{
- left: 4,
- right: "auto",
- }}
- aria-label={translate(`scroll.direction.${direction}`)}
- bg="bg.panel"
- bottom={direction === "bottom" ? 4 : 14}
- onClick={onClick}
- position="absolute"
- rounded="full"
- size="xs"
- variant="outline"
- >
- {direction === "bottom" ? <FiChevronDown /> : <FiChevronUp />}
- </IconButton>
- </Tooltip>
- );
-};
-
-export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap
}: TaskLogContentProps) => {
+export const TaskLogContent = ({
+ currentMatchLineIndex,
+ error,
+ expanded,
+ isLoading,
+ logError,
+ parsedLogs,
+ searchMatchIndices,
+ searchQuery,
+ wrap,
+}: TaskLogContentProps) => {
const hash = location.hash.replace("#", "");
const parentRef = useRef<HTMLDivElement | null>(null);
- // Track whether user is at the bottom so we don't hijack their scroll
position
- // if they scrolled up to read something
+ const {
+ expandedGroups,
+ originalToVisibleIndex,
+ toggleGroup,
+ visibleCurrentMatchIndex,
+ visibleItems,
+ visibleSearchMatchIndices,
+ } = useLogGroups({ currentMatchLineIndex, expanded, parsedLogs,
searchMatchIndices });
+
const isAtBottomRef = useRef<boolean>(true);
- // Track previous log count to detect new lines arriving
- const prevLogCountRef = useRef<number>(0);
+ const prevVisibleCountRef = useRef<number>(0);
const rowVirtualizer = useVirtualizer({
- count: parsedLogs.length,
+ count: visibleItems.length,
estimateSize: () => 20,
getScrollElement: () => parentRef.current,
overscan: 10,
@@ -101,18 +80,15 @@ export const TaskLogContent = ({ error, isLoading,
logError, parsedLogs, wrap }:
const contentHeight = rowVirtualizer.getTotalSize();
const containerHeight = rowVirtualizer.scrollElement?.clientHeight ?? 0;
- const showScrollButtons = parsedLogs.length > 1 && contentHeight >
containerHeight;
+ const showScrollButtons = visibleItems.length > 1 && contentHeight >
containerHeight;
- // Check if user is near the bottom on scroll
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) {
return;
}
- const distanceFromBottom = el.scrollHeight - el.scrollTop -
el.clientHeight;
-
- isAtBottomRef.current = distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD;
+ isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight
<= SCROLL_BOTTOM_THRESHOLD;
}, []);
useEffect(() => {
@@ -123,41 +99,46 @@ export const TaskLogContent = ({ error, isLoading,
logError, parsedLogs, wrap }:
return () => el?.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
- // Auto-scroll to bottom when:
- // 1. Logs first load (prevLogCount was 0)
- // 2. New lines arrive AND user was already at the bottom
useLayoutEffect(() => {
- if (parsedLogs.length === 0) {
+ if (visibleItems.length === 0) {
return;
}
-
- const isFirstLoad = prevLogCountRef.current === 0;
- const hasNewLines = parsedLogs.length > prevLogCountRef.current;
+ const isFirstLoad = prevVisibleCountRef.current === 0;
+ const hasNewLines = visibleItems.length > prevVisibleCountRef.current;
if ((isFirstLoad || (hasNewLines && isAtBottomRef.current)) &&
!location.hash) {
- rowVirtualizer.scrollToIndex(parsedLogs.length - 1, { align: "end" });
+ rowVirtualizer.scrollToIndex(visibleItems.length - 1, { align: "end" });
}
-
- prevLogCountRef.current = parsedLogs.length;
- }, [parsedLogs.length, rowVirtualizer]);
+ prevVisibleCountRef.current = visibleItems.length;
+ }, [visibleItems.length, rowVirtualizer]);
useLayoutEffect(() => {
if (location.hash && !isLoading) {
- rowVirtualizer.scrollToIndex(Math.min(Number(hash) + 5,
parsedLogs.length - 1));
+ const hashVisibleIndex = originalToVisibleIndex.get(Number(hash) - 1);
+
+ if (hashVisibleIndex !== undefined) {
+ rowVirtualizer.scrollToIndex(Math.min(hashVisibleIndex + 5,
visibleItems.length - 1));
+ }
}
- }, [isLoading, rowVirtualizer, hash, parsedLogs]);
+ // React Compiler auto-memoizes; safe to include in deps
+ }, [isLoading, rowVirtualizer, hash, visibleItems, originalToVisibleIndex]);
+
+ useLayoutEffect(() => {
+ if (visibleCurrentMatchIndex !== undefined && !isLoading) {
+ rowVirtualizer.scrollToIndex(Math.min(visibleCurrentMatchIndex + 3,
visibleItems.length - 1));
+ }
+ // React Compiler auto-memoizes; safe to include in deps
+ }, [visibleCurrentMatchIndex, isLoading, rowVirtualizer, visibleItems]);
const handleScrollTo = (to: "bottom" | "top") => {
- if (parsedLogs.length === 0) {
+ if (visibleItems.length === 0) {
return;
}
-
const el = rowVirtualizer.scrollElement ?? parentRef.current;
if (!el) {
return;
}
-
if (to === "top") {
isAtBottomRef.current = false;
scrollToTop({ element: el, virtualizer: rowVirtualizer });
@@ -185,9 +166,7 @@ export const TaskLogContent = ({ error, isLoading,
logError, parsedLogs, wrap }:
width="100%"
>
<Code
- css={{
- "& *::selection": { bg: "blue.emphasized" },
- }}
+ css={{ "& *::selection": { bg: "blue.emphasized" } }}
data-testid="virtualized-list"
display="block"
overflowX="auto"
@@ -200,29 +179,103 @@ export const TaskLogContent = ({ error, isLoading,
logError, parsedLogs, wrap }:
h={`${rowVirtualizer.getTotalSize()}px`}
position="relative"
>
- {rowVirtualizer.getVirtualItems().map((virtualRow) => (
- <Box
- _ltr={{ left: 0, right: "auto" }}
- _rtl={{ left: "auto", right: 0 }}
- bgColor={
- Boolean(hash) && virtualRow.index === Number(hash) - 1 ?
"brand.emphasized" : "transparent"
- }
- data-index={virtualRow.index}
- data-testid={`virtualized-item-${virtualRow.index}`}
- key={virtualRow.key}
- position="absolute"
- ref={rowVirtualizer.measureElement}
- top={0}
- transform={`translateY(${virtualRow.start}px)`}
- width={wrap ? "100%" : "max-content"}
- >
- {parsedLogs[virtualRow.index] ?? undefined}
- </Box>
- ))}
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+ const item = visibleItems[virtualRow.index];
+
+ if (!item) {
+ return undefined;
+ }
+
+ const { entry, originalIndex } = item;
+ const isGroupHeader = entry.group?.type === "header";
+ const groupLevel = entry.group?.level ?? 0;
+ const indent = entry.group ? groupLevel * 4 + (isGroupHeader ? 0
: 4) : 0;
+
+ if (isGroupHeader && entry.group) {
+ const isExpanded = expandedGroups.has(entry.group.id);
+
+ return (
+ <Box
+ _ltr={{ left: 0, right: "auto" }}
+ _rtl={{ left: "auto", right: 0 }}
+ bgColor={getHighlightColor({
+ currentMatchLineIndex: visibleCurrentMatchIndex,
+ hash,
+ index: virtualRow.index,
+ searchMatchIndices: visibleSearchMatchIndices,
+ })}
+ cursor="pointer"
+ data-index={virtualRow.index}
+ data-testid={`group-header-${virtualRow.index}`}
+ key={virtualRow.key}
+ onClick={() => entry.group && toggleGroup(entry.group.id)}
+ pl={indent}
+ position="absolute"
+ ref={rowVirtualizer.measureElement}
+ top={0}
+ transform={`translateY(${virtualRow.start}px)`}
+ width={wrap ? "100%" : "max-content"}
+ >
+ <Box
+ as="span"
+ color="fg.info"
+ data-testid={`summary-${typeof entry.element ===
"string" ? entry.element : ""}`}
+ >
+ <Box
+ as="span"
+ display="inline-block"
+ mr={1}
+ transform={isExpanded ? "rotate(90deg)" :
"rotate(0deg)"}
+ transition="transform 0.15s"
+ >
+ {"\u25B6"}
+ </Box>
+ {visibleSearchMatchIndices?.has(virtualRow.index) ? (
+ <HighlightedText query={searchQuery}>
+ {typeof entry.element === "string" ? entry.element :
undefined}
+ </HighlightedText>
+ ) : (
+ entry.element
+ )}
+ </Box>
+ </Box>
+ );
+ }
+
+ return (
+ <Box
+ _ltr={{ left: 0, right: "auto" }}
+ _rtl={{ left: "auto", right: 0 }}
+ bgColor={getHighlightColor({
+ currentMatchLineIndex: visibleCurrentMatchIndex,
+ hash,
+ index: virtualRow.index,
+ searchMatchIndices: visibleSearchMatchIndices,
+ })}
+ data-index={virtualRow.index}
+ data-original-index={originalIndex}
+ data-testid={`virtualized-item-${virtualRow.index}`}
+ key={virtualRow.key}
+ pl={indent}
+ position="absolute"
+ ref={rowVirtualizer.measureElement}
+ top={0}
+ transform={`translateY(${virtualRow.start}px)`}
+ width={wrap ? "100%" : "max-content"}
+ >
+ {visibleSearchMatchIndices?.has(virtualRow.index) ? (
+ <HighlightedText query={searchQuery}>
+ {typeof entry.element === "string" ? entry.element :
(entry.element ?? undefined)}
+ </HighlightedText>
+ ) : (
+ (entry.element ?? undefined)
+ )}
+ </Box>
+ );
+ })}
</VStack>
</Code>
</Box>
-
{showScrollButtons ? (
<>
<ScrollToButton direction="top" onClick={() =>
handleScrollTo("top")} />
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx
index e94a258ffcb..bee839f744e 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx
@@ -45,12 +45,15 @@ import { SearchParamsKeys } from
"src/constants/searchParams";
import { defaultSystem } from "src/theme";
import { type LogLevel, logLevelColorMapping, logLevelOptions } from
"src/utils/logs";
+import { LogSearchInput, type LogSearchInputProps } from "./LogSearchInput";
+
export type TaskLogHeaderProps = {
readonly downloadLogs?: () => void;
readonly expanded?: boolean;
readonly getLogString: () => string;
readonly isFullscreen?: boolean;
readonly onSelectTryNumber: (tryNumber: number) => void;
+ readonly search: LogSearchInputProps;
readonly showSource: boolean;
readonly showTimestamp: boolean;
readonly sourceOptions?: Array<string>;
@@ -70,6 +73,7 @@ export const TaskLogHeader = ({
getLogString,
isFullscreen = false,
onSelectTryNumber,
+ search,
showSource,
showTimestamp,
sourceOptions,
@@ -141,7 +145,7 @@ export const TaskLogHeader = ({
taskInstance={taskInstance}
/>
)}
- <HStack justifyContent="space-between">
+ <HStack flexWrap="wrap" gap={2} justifyContent="space-between">
<Select.Root
collection={logLevelOptions}
maxW="250px"
@@ -201,6 +205,7 @@ export const TaskLogHeader = ({
</Select.Content>
</Select.Root>
) : undefined}
+ <LogSearchInput {...search} />
<HStack gap={1}>
<Menu.Root>
<Menu.Trigger asChild>
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
new file mode 100644
index 00000000000..176f481cf55
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/useLogGroups.tsx
@@ -0,0 +1,171 @@
+/*!
+ * 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 { useEffect, useState } from "react";
+
+import type { ParsedLogEntry } from "src/queries/useLogs";
+
+type VisibleItem = { entry: ParsedLogEntry; originalIndex: number };
+
+/**
+ * Manages log group expand/collapse state and computes the list of
+ * visible entries for the virtualizer. Handles nested groups and
+ * auto-expanding collapsed groups when search navigates into them.
+ */
+export const useLogGroups = ({
+ currentMatchLineIndex,
+ expanded,
+ parsedLogs,
+ searchMatchIndices,
+}: {
+ currentMatchLineIndex?: number;
+ expanded: boolean;
+ parsedLogs: Array<ParsedLogEntry>;
+ searchMatchIndices?: Set<number>;
+}) => {
+ // Build parent map for nested visibility checks
+ const groupHeaders = parsedLogs.filter(
+ (entry): entry is { group: NonNullable<ParsedLogEntry["group"]> } &
ParsedLogEntry =>
+ entry.group?.type === "header",
+ );
+ const allGroupIds = new Set(groupHeaders.map((entry) => entry.group.id));
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- React Compiler
auto-memoizes this
+ const groupParentMap = new Map<number, number | undefined>(
+ groupHeaders.map((entry) => [entry.group.id, entry.group.parentId]),
+ );
+
+ const [expandedGroups, setExpandedGroups] = useState<Set<number>>(() =>
+ expanded ? new Set(allGroupIds) : new Set<number>(),
+ );
+
+ // Sync expandedGroups when expanded prop changes (toggle all)
+ useEffect(() => {
+ if (expanded) {
+ setExpandedGroups(new Set(allGroupIds));
+ } else {
+ setExpandedGroups(new Set<number>());
+ }
+ // Only react to the expanded prop, not allGroupIds
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [expanded]);
+
+ const toggleGroup = (groupId: number) => {
+ setExpandedGroups((prev) => {
+ const next = new Set(prev);
+
+ if (next.has(groupId)) {
+ next.delete(groupId);
+ } else {
+ next.add(groupId);
+ }
+
+ return next;
+ });
+ };
+
+ // Check if all ancestors of a group are expanded
+ const isGroupAncestryExpanded = (groupId: number): boolean => {
+ const parentId = groupParentMap.get(groupId);
+
+ if (parentId === undefined) {
+ return true;
+ }
+
+ return expandedGroups.has(parentId) && isGroupAncestryExpanded(parentId);
+ };
+
+ const isEntryVisible = (entry: ParsedLogEntry): boolean => {
+ if (!entry.group) {
+ return true;
+ }
+
+ if (entry.group.type === "header") {
+ return isGroupAncestryExpanded(entry.group.id);
+ }
+
+ return expandedGroups.has(entry.group.id) &&
isGroupAncestryExpanded(entry.group.id);
+ };
+
+ // Build visible items list with index mapping
+ const visibleItems: Array<VisibleItem> = [];
+ const originalToVisibleIndex = new Map<number, number>();
+
+ for (let idx = 0; idx < parsedLogs.length; idx += 1) {
+ const entry = parsedLogs[idx];
+
+ if (entry && isEntryVisible(entry)) {
+ originalToVisibleIndex.set(idx, visibleItems.length);
+ visibleItems.push({ entry, originalIndex: idx });
+ }
+ }
+
+ // Map search match indices from original to visible indices
+ const visibleSearchMatchIndices = searchMatchIndices
+ ? new Set(
+ [...searchMatchIndices]
+ .map((idx) => originalToVisibleIndex.get(idx))
+ .filter((idx): idx is number => idx !== undefined),
+ )
+ : undefined;
+
+ const visibleCurrentMatchIndex =
+ currentMatchLineIndex === undefined ? undefined :
originalToVisibleIndex.get(currentMatchLineIndex);
+
+ // Auto-expand group (and all ancestors) when search navigates to a match
inside a collapsed group
+ useEffect(() => {
+ if (currentMatchLineIndex === undefined) {
+ return;
+ }
+ const entry = parsedLogs[currentMatchLineIndex];
+
+ if (!entry?.group) {
+ return;
+ }
+
+ const groupsToExpand: Array<number> = [];
+
+ if (entry.group.type === "line" && !expandedGroups.has(entry.group.id)) {
+ groupsToExpand.push(entry.group.id);
+ }
+
+ // Walk up the parent chain
+ let currentId: number | undefined = entry.group.id;
+
+ while (currentId !== undefined) {
+ const parentId = groupParentMap.get(currentId);
+
+ if (parentId !== undefined && !expandedGroups.has(parentId)) {
+ groupsToExpand.push(parentId);
+ }
+ currentId = parentId;
+ }
+
+ if (groupsToExpand.length > 0) {
+ setExpandedGroups((prev) => new Set([...prev, ...groupsToExpand]));
+ }
+ }, [currentMatchLineIndex, parsedLogs, expandedGroups, groupParentMap]);
+
+ return {
+ expandedGroups,
+ originalToVisibleIndex,
+ toggleGroup,
+ visibleCurrentMatchIndex,
+ visibleItems,
+ visibleSearchMatchIndices,
+ };
+};
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
new file mode 100644
index 00000000000..5fd23ac5841
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.test.ts
@@ -0,0 +1,162 @@
+/*!
+ * 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 { describe, expect, it } from "vitest";
+
+import { getHighlightColor, splitBySearchQuery } from "./utils";
+
+describe("getHighlightColor", () => {
+ it("returns yellow.emphasized for the current search match", () => {
+ expect(
+ getHighlightColor({
+ currentMatchLineIndex: 3,
+ hash: "",
+ index: 3,
+ searchMatchIndices: new Set([1, 3, 5]),
+ }),
+ ).toBe("yellow.emphasized");
+ });
+
+ it("returns yellow.subtle for a non-current search match", () => {
+ expect(
+ getHighlightColor({
+ currentMatchLineIndex: 1,
+ hash: "",
+ index: 3,
+ searchMatchIndices: new Set([1, 3, 5]),
+ }),
+ ).toBe("yellow.subtle");
+ });
+
+ 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)
+ searchMatchIndices: undefined,
+ }),
+ ).toBe("brand.emphasized");
+ });
+
+ it("returns transparent when no condition matches", () => {
+ expect(
+ getHighlightColor({
+ hash: "",
+ index: 2,
+ searchMatchIndices: undefined,
+ }),
+ ).toBe("transparent");
+ });
+
+ it("returns transparent when search is active but line is not a match", ()
=> {
+ expect(
+ getHighlightColor({
+ currentMatchLineIndex: 0,
+ hash: "",
+ index: 7,
+ searchMatchIndices: new Set([0, 2]),
+ }),
+ ).toBe("transparent");
+ });
+
+ it("current match takes priority over hash highlight on same line", () => {
+ expect(
+ getHighlightColor({
+ currentMatchLineIndex: 4,
+ hash: "5",
+ index: 4,
+ searchMatchIndices: new Set([4]),
+ }),
+ ).toBe("yellow.emphasized");
+ });
+
+ it("search match highlight takes priority over hash highlight on same line",
() => {
+ expect(
+ getHighlightColor({
+ currentMatchLineIndex: 0,
+ hash: "5",
+ index: 4,
+ searchMatchIndices: new Set([0, 4]),
+ }),
+ ).toBe("yellow.subtle");
+ });
+
+ it("returns transparent when searchMatchIndices is an empty Set", () => {
+ expect(
+ getHighlightColor({
+ currentMatchLineIndex: undefined,
+ hash: "",
+ index: 0,
+ searchMatchIndices: new Set(),
+ }),
+ ).toBe("transparent");
+ });
+});
+
+describe("splitBySearchQuery", () => {
+ it("returns the full text as a single non-highlight segment when query is
empty", () => {
+ expect(splitBySearchQuery("hello world", "")).toEqual([{ highlight: false,
text: "hello world" }]);
+ });
+
+ it("returns the full text when query does not match", () => {
+ expect(splitBySearchQuery("hello world", "xyz")).toEqual([{ highlight:
false, text: "hello world" }]);
+ });
+
+ it("highlights a single match", () => {
+ expect(splitBySearchQuery("hello world", "world")).toEqual([
+ { highlight: false, text: "hello " },
+ { highlight: true, text: "world" },
+ ]);
+ });
+
+ it("highlights multiple matches", () => {
+ expect(splitBySearchQuery("foo bar foo", "foo")).toEqual([
+ { highlight: true, text: "foo" },
+ { highlight: false, text: " bar " },
+ { highlight: true, text: "foo" },
+ ]);
+ });
+
+ it("is case-insensitive", () => {
+ expect(splitBySearchQuery("Hello HELLO hello", "hello")).toEqual([
+ { highlight: true, text: "Hello" },
+ { highlight: false, text: " " },
+ { highlight: true, text: "HELLO" },
+ { highlight: false, text: " " },
+ { highlight: true, text: "hello" },
+ ]);
+ });
+
+ it("handles match at the start", () => {
+ expect(splitBySearchQuery("error: something", "error")).toEqual([
+ { highlight: true, text: "error" },
+ { highlight: false, text: ": something" },
+ ]);
+ });
+
+ it("handles match at the end", () => {
+ expect(splitBySearchQuery("something error", "error")).toEqual([
+ { highlight: false, text: "something " },
+ { highlight: true, text: "error" },
+ ]);
+ });
+
+ it("handles entire string as match", () => {
+ expect(splitBySearchQuery("error", "error")).toEqual([{ highlight: true,
text: "error" }]);
+ });
+});
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 44fce3cc06c..276856b3f8c 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
@@ -17,6 +17,119 @@
* under the License.
*/
import type { Virtualizer } from "@tanstack/react-virtual";
+import type { TFunction } from "i18next";
+
+import type { TaskInstancesLogResponse } from "openapi/requests/types.gen";
+import { renderStructuredLog } from "src/components/renderStructuredLog";
+import { parseStreamingLogContent } from "src/utils/logs";
+
+type GetDownloadTextOptions = {
+ fetchedData: TaskInstancesLogResponse | undefined;
+ logLevelFilters: Array<string>;
+ showSource: boolean;
+ showTimestamp: boolean;
+ sourceFilters: Array<string>;
+ translate: TFunction;
+};
+
+/**
+ * Parse raw streaming log data into plain-text lines for log download.
+ * Search matching now uses the searchableText array from useLogs instead,
+ * which is derived from the rendered parsedLogs to stay aligned with log
groups.
+ */
+export const getDownloadText = ({
+ fetchedData,
+ logLevelFilters,
+ showSource,
+ showTimestamp,
+ sourceFilters,
+ translate,
+}: GetDownloadTextOptions): Array<string> => {
+ const lines = parseStreamingLogContent(fetchedData);
+
+ return lines.map((line) =>
+ renderStructuredLog({
+ index: 0,
+ logLevelFilters,
+ logLink: "",
+ logMessage: line,
+ renderingMode: "text",
+ showSource,
+ showTimestamp,
+ sourceFilters,
+ translate,
+ }),
+ );
+};
+
+export type HighlightOptions = {
+ currentMatchLineIndex?: number;
+ hash: string;
+ index: number;
+ searchMatchIndices?: Set<number>;
+};
+
+/**
+ * Returns the background color token for a virtualized log row.
+ * Priority: current search match (yellow.emphasized) > URL hash line >
transparent.
+ * Non-current search matches use yellow.subtle for a softer highlight.
+ */
+export const getHighlightColor = ({
+ currentMatchLineIndex,
+ hash,
+ index,
+ searchMatchIndices,
+}: HighlightOptions): string => {
+ if (currentMatchLineIndex !== undefined && index === currentMatchLineIndex) {
+ return "yellow.emphasized";
+ }
+ if (searchMatchIndices?.has(index)) {
+ return "yellow.subtle";
+ }
+ if (Boolean(hash) && index === Number(hash) - 1) {
+ return "brand.emphasized";
+ }
+
+ return "transparent";
+};
+
+/**
+ * Wraps matching substrings in a log line with a highlight marker.
+ * Returns an array of strings and { text, highlight } objects for rendering.
+ */
+export type HighlightSegment = { highlight: boolean; text: string };
+
+export const splitBySearchQuery = (text: string, query: string):
Array<HighlightSegment> => {
+ if (!query) {
+ return [{ highlight: false, text }];
+ }
+
+ const lowerText = text.toLowerCase();
+ const lowerQuery = query.toLowerCase();
+ const segments: Array<HighlightSegment> = [];
+ let lastIndex = 0;
+
+ let matchIndex = lowerText.indexOf(lowerQuery, lastIndex);
+
+ while (matchIndex !== -1) {
+ if (matchIndex > lastIndex) {
+ segments.push({ highlight: false, text: text.slice(lastIndex,
matchIndex) });
+ }
+ segments.push({ highlight: true, text: text.slice(matchIndex, matchIndex +
query.length) });
+ lastIndex = matchIndex + query.length;
+ matchIndex = lowerText.indexOf(lowerQuery, lastIndex);
+ }
+
+ if (lastIndex < text.length) {
+ segments.push({ highlight: false, text: text.slice(lastIndex) });
+ }
+
+ if (segments.length === 0) {
+ return [{ highlight: false, text }];
+ }
+
+ return segments;
+};
type VirtualizerInstance = Virtualizer<HTMLDivElement, Element>;
diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
index 1a828967414..5c4862499d0 100644
--- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { chakra, Box } from "@chakra-ui/react";
import type { UseQueryOptions } from "@tanstack/react-query";
import dayjs from "dayjs";
import type { TFunction } from "i18next";
@@ -31,10 +30,14 @@ import { isStatePending, useAutoRefresh } from "src/utils";
import { getTaskInstanceLink } from "src/utils/links";
import { parseStreamingLogContent } from "src/utils/logs";
+export type ParsedLogEntry = {
+ element: JSX.Element | string | undefined;
+ group?: { id: number; level: number; parentId?: number; type: "header" |
"line" };
+};
+
type Props = {
accept?: "*/*" | "application/json" | "application/x-ndjson";
dagId: string;
- expanded?: boolean;
limit?: number;
logLevelFilters?: Array<string>;
showSource?: boolean;
@@ -46,7 +49,6 @@ type Props = {
type ParseLogsProps = {
data: TaskInstancesLogResponse["content"];
- expanded?: boolean;
logLevelFilters?: Array<string>;
showSource?: boolean;
showTimestamp?: boolean;
@@ -58,7 +60,6 @@ type ParseLogsProps = {
const parseLogs = ({
data,
- expanded,
logLevelFilters,
showSource,
showTimestamp,
@@ -71,7 +72,6 @@ const parseLogs = ({
let parsedLines;
const sources: Array<string> = [];
- const open = expanded ?? Boolean(globalThis.location.hash);
const logLink = taskInstance ?
`${getTaskInstanceLink(taskInstance)}?try_number=${tryNumber}` : "";
try {
@@ -108,75 +108,56 @@ const parseLogs = ({
return { data, warning };
}
- parsedLines = (() => {
- type Group = { level: number; lines: Array<JSX.Element | "">; name: string
};
+ const flatEntries: Array<ParsedLogEntry> = (() => {
+ type Group = { id: number; level: number; name: string };
const groupStack: Array<Group> = [];
- const result: Array<JSX.Element | ""> = [];
+ const result: Array<ParsedLogEntry> = [];
+ let nextGroupId = 0;
parsedLines.forEach((line) => {
const text = innerText(line);
if (text.includes("::group::")) {
const groupName = text.split("::group::")[1] as string;
+ const id = nextGroupId;
- groupStack.push({ level: groupStack.length, lines: [], name: groupName
});
+ nextGroupId += 1;
+ const level = groupStack.length;
+ const parentGroup = groupStack[groupStack.length - 1];
+
+ groupStack.push({ id, level, name: groupName });
+ result.push({
+ element: groupName,
+ group: { id, level, parentId: parentGroup?.id, type: "header" },
+ });
return;
}
if (text.includes("::endgroup::")) {
- const finishedGroup = groupStack.pop();
-
- if (finishedGroup) {
- const groupElement = (
- <Box key={finishedGroup.name} mb={2} pl={finishedGroup.level * 2}>
- <chakra.details open={open} w="100%">
- <chakra.summary data-testid={`summary-${finishedGroup.name}`}>
- <chakra.span color="fg.info" cursor="pointer">
- {finishedGroup.name}
- </chakra.span>
- </chakra.summary>
- {finishedGroup.lines}
- </chakra.details>
- </Box>
- );
-
- const lastGroup = groupStack[groupStack.length - 1];
-
- if (groupStack.length > 0 && lastGroup) {
- lastGroup.lines.push(groupElement);
- } else {
- result.push(groupElement);
- }
- }
+ groupStack.pop();
return;
}
- if (groupStack.length > 0 && groupStack[groupStack.length - 1]) {
- groupStack[groupStack.length - 1]?.lines.push(line);
+ const currentGroup = groupStack[groupStack.length - 1];
+
+ if (groupStack.length > 0 && currentGroup) {
+ result.push({
+ element: line,
+ group: { id: currentGroup.id, level: currentGroup.level, type:
"line" },
+ });
} else {
- result.push(line);
+ result.push({ element: line });
}
});
- while (groupStack.length > 0) {
- const unfinished = groupStack.pop();
-
- if (unfinished) {
- result.push(
- <Box key={unfinished.name} mb={2} pl={unfinished.level * 2}>
- {unfinished.lines}
- </Box>,
- );
- }
- }
-
+ // Handle unclosed groups: their lines are already in result as flat
entries
return result;
})();
return {
- parsedLogs: parsedLines,
+ parsedLogs: flatEntries,
sources,
warning,
};
@@ -203,7 +184,6 @@ export const useLogs = (
{
accept = "application/x-ndjson",
dagId,
- expanded,
limit,
logLevelFilters,
showSource,
@@ -240,7 +220,6 @@ export const useLogs = (
const parsedData = parseLogs({
data: parseStreamingLogContent(truncateData(data, limit)),
- expanded,
logLevelFilters,
showSource,
showTimestamp,
@@ -250,5 +229,15 @@ export const useLogs = (
tryNumber,
});
- return { parsedData, ...rest, fetchedData: data };
+ // Build a 1:1 searchable text array from parsedLogs so search indices align
+ // with the rendered output. Each entry maps to exactly one line.
+ const searchableText: Array<string> = (parsedData.parsedLogs ??
[]).map((entry) => {
+ if (typeof entry.element === "string") {
+ return entry.element;
+ }
+
+ return entry.element ? innerText(entry.element) : "";
+ });
+
+ return { parsedData: { ...parsedData, searchableText }, ...rest,
fetchedData: data };
};