This is an automated email from the ASF dual-hosted git repository.
bbovenzi 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 7a9de0307f9 UI: Add toggle to show or hide log levels in task logs
(#72363)
7a9de0307f9 is described below
commit 7a9de0307f9437c1ce850c7a029cf1d4eae686b9
Author: PolitePp <[email protected]>
AuthorDate: Wed Sep 23 17:51:13 2026 +0300
UI: Add toggle to show or hide log levels in task logs (#72363)
* issue-72360: UI: Add toggle to show or hide log levels in task logs
* issue-72360: UI: update icon for the button
* issue-72360: fix statis check, move renderWithLevel to the outer scope
---------
Co-authored-by: Vladimir Krokodilov <[email protected]>
---
.../airflow/ui/public/i18n/locales/en/common.json | 6 ++++
.../ui/src/components/renderStructuredLog.test.tsx | 33 ++++++++++++++++++++++
.../ui/src/components/renderStructuredLog.tsx | 4 ++-
.../src/airflow/ui/src/constants/localStorage.ts | 1 +
.../ui/src/context/keyboardShortcuts/shortcuts.ts | 5 ++++
.../ui/src/pages/TaskInstance/Logs/Logs.tsx | 17 ++++++++++-
.../src/pages/TaskInstance/Logs/TaskLogHeader.tsx | 9 ++++++
.../ui/src/pages/TaskInstance/Logs/utils.test.ts | 13 +++++++++
.../ui/src/pages/TaskInstance/Logs/utils.ts | 3 ++
.../src/airflow/ui/src/queries/useLogs.test.ts | 12 ++++++++
.../src/airflow/ui/src/queries/useLogs.tsx | 19 ++++++++++++-
.../airflow/ui/tests/e2e/specs/task-logs.spec.ts | 6 ++++
12 files changed, 125 insertions(+), 3 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 0f5d63160f1..fe8b8b93d0d 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -168,6 +168,11 @@
"generateToken": "Generate Token",
"includedIn": "Included in",
"key": "Key",
+ "logLevel": {
+ "hide": "Hide Log Levels",
+ "hotkey": "l",
+ "show": "Show Log Levels"
+ },
"logicalDate": "Logical Date",
"logout": "Logout",
"logoutConfirmation": "You are about to logout from the application.",
@@ -352,6 +357,7 @@
"toggleExpand": "Expand or collapse all groups",
"toggleFullscreen": "Toggle fullscreen",
"toggleGraphGrid": "Toggle graph / grid view",
+ "toggleLogLevel": "Toggle log levels",
"toggleSource": "Toggle source",
"toggleTaskGroup": "Expand or collapse $t(taskGroup_one)",
"toggleTimestamp": "Toggle timestamps",
diff --git
a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx
b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx
index 655522ef45e..af86bc0a853 100644
--- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx
@@ -26,6 +26,23 @@ import { renderStructuredLog, renderTIContextPreamble,
tiContextFields } from ".
const translate = (key: string) => key;
+const renderWithLevel = (showLogLevel: boolean) => {
+ const result = renderStructuredLog({
+ index: 0,
+ logLink: "",
+ logMessage: {
+ event: "Task started",
+ level: "info",
+ timestamp: "2026-01-01T00:00:00Z",
+ },
+ renderingMode: "jsx",
+ showLogLevel,
+ translate: translate as never,
+ });
+
+ return render(<Wrapper>{result}</Wrapper>);
+};
+
describe("tiContextFields", () => {
it("contains the six fields bound via bind_contextvars", () => {
expect(tiContextFields).toEqual(
@@ -76,6 +93,22 @@ describe("renderStructuredLog — traceback frame
highlighting", () => {
});
});
+describe("renderStructuredLog — log level visibility", () => {
+ it("renders the level when showLogLevel is true", () => {
+ renderWithLevel(true);
+
+ expect(screen.getByText("INFO")).toBeInTheDocument();
+ expect(screen.getByText("Task started")).toBeInTheDocument();
+ });
+
+ it("omits the level when showLogLevel is false", () => {
+ renderWithLevel(false);
+
+ expect(screen.queryByText("INFO")).toBeNull();
+ expect(screen.getByText("Task started")).toBeInTheDocument();
+ });
+});
+
describe("renderStructuredLog — TI context field stripping", () => {
it("does not render TI context fields as per-line structured attributes", ()
=> {
const result = renderStructuredLog({
diff --git a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
index dd7fc9a84dc..37fdb5d1c15 100644
--- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
+++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
@@ -52,6 +52,7 @@ type RenderStructuredLogProps = {
logLink: string;
logMessage: string | StructuredLogMessage;
renderingMode?: "jsx" | "text";
+ showLogLevel?: boolean;
showSource?: boolean;
showTimestamp?: boolean;
sourceFilters?: Array<string>;
@@ -181,6 +182,7 @@ const renderStructuredLogImpl = ({
logLink,
logMessage,
renderingMode = "jsx",
+ showLogLevel = true,
showSource = true,
showTimestamp = true,
sourceFilters,
@@ -227,7 +229,7 @@ const renderStructuredLogImpl = ({
}
}
- if (typeof level === "string") {
+ if (typeof level === "string" && showLogLevel) {
const formattedLevel = level.toUpperCase();
if (renderingMode === "text") {
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index 16926fe8c66..1152dd06f5b 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -26,6 +26,7 @@ export const CALENDAR_VIEW_MODE_KEY = "calendar-view-mode";
export const LOG_WRAP_KEY = "log_wrap";
export const LOG_SHOW_TIMESTAMP_KEY = "log_show_timestamp";
export const LOG_SHOW_SOURCE_KEY = "log_show_source";
+export const LOG_SHOW_LOG_LEVEL_KEY = "log_show_log_level";
export const VERSION_INDICATOR_DISPLAY_MODE_KEY =
"version_indicator_display_mode";
export const COLLAPSED_UI_ALERTS_KEY = "collapsed_ui_alerts";
export const SHOW_ALL_DEPENDENCIES_KEY = "show_all_dependencies";
diff --git
a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
index 1271037a99f..4482f944eac 100644
--- a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
+++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
@@ -101,6 +101,11 @@ export const SHORTCUTS = {
descriptionKey: "shortcuts.descriptions.toggleFullscreen",
keys: "f",
},
+ toggleLogLevel: {
+ category: "logs",
+ descriptionKey: "shortcuts.descriptions.toggleLogLevel",
+ keys: "l",
+ },
toggleSource: { category: "logs", descriptionKey:
"shortcuts.descriptions.toggleSource", keys: "s" },
toggleTimestamp: {
category: "logs",
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 c64fbe9191f..65a23edebf7 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
@@ -27,7 +27,12 @@ import { useTaskInstanceServiceGetMappedTaskInstance } from
"openapi/queries";
import { Modal } from "src/system-components";
-import { LOG_SHOW_SOURCE_KEY, LOG_SHOW_TIMESTAMP_KEY, LOG_WRAP_KEY } from
"src/constants/localStorage";
+import {
+ LOG_SHOW_LOG_LEVEL_KEY,
+ LOG_SHOW_SOURCE_KEY,
+ LOG_SHOW_TIMESTAMP_KEY,
+ LOG_WRAP_KEY,
+} from "src/constants/localStorage";
import { SearchParamsKeys } from "src/constants/searchParams";
import { SHORTCUTS } from "src/context/keyboardShortcuts";
import { useShortcut } from "src/hooks/useShortcut";
@@ -82,6 +87,7 @@ export const Logs = () => {
const [wrap, setWrap] = useLocalStorage<boolean>(LOG_WRAP_KEY, defaultWrap);
const [showTimestamp, setShowTimestamp] =
useLocalStorage<boolean>(LOG_SHOW_TIMESTAMP_KEY, true);
const [showSource, setShowSource] =
useLocalStorage<boolean>(LOG_SHOW_SOURCE_KEY, false);
+ const [showLogLevel, setShowLogLevel] =
useLocalStorage<boolean>(LOG_SHOW_LOG_LEVEL_KEY, true);
const [fullscreen, setFullscreen] = useState(false);
const [expanded, setExpanded] = useState(false);
@@ -93,6 +99,7 @@ export const Logs = () => {
} = useLogs({
dagId,
logLevelFilters,
+ showLogLevel,
showSource,
showTimestamp,
sourceFilters,
@@ -103,6 +110,7 @@ export const Logs = () => {
const downloadTextLines = getDownloadText({
fetchedData,
logLevelFilters,
+ showLogLevel,
showSource,
showTimestamp,
sourceFilters,
@@ -160,6 +168,7 @@ export const Logs = () => {
const toggleWrap = () => setWrap(!wrap);
const toggleTimestamp = () => setShowTimestamp(!showTimestamp);
+ const toggleLogLevel = () => setShowLogLevel(!showLogLevel);
const toggleSource = () => setShowSource(!showSource);
const toggleFullscreen = () => setFullscreen(!fullscreen);
const toggleExpanded = () => setExpanded((act) => !act);
@@ -180,6 +189,10 @@ export const Logs = () => {
...SHORTCUTS.logs.toggleTimestamp,
callback: toggleTimestamp,
});
+ useShortcut({
+ ...SHORTCUTS.logs.toggleLogLevel,
+ callback: toggleLogLevel,
+ });
useShortcut({
...SHORTCUTS.logs.toggleSource,
callback: toggleSource,
@@ -209,12 +222,14 @@ export const Logs = () => {
searchQuery,
totalMatches: searchMatchIndices.length,
},
+ showLogLevel,
showSource,
showTimestamp,
sourceOptions: parsedData.sources,
taskInstance,
toggleExpanded,
toggleFullscreen,
+ toggleLogLevel,
toggleSource,
toggleTimestamp,
toggleWrap,
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 c351c5f666e..108587ab864 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
@@ -24,6 +24,7 @@ import {
MdCompress,
MdExpand,
MdOutlineFileDownload,
+ MdOutlineLabel,
MdOutlineOpenInFull,
MdSettings,
MdWrapText,
@@ -49,12 +50,14 @@ export type TaskLogHeaderProps = {
readonly isFullscreen?: boolean;
readonly onSelectTryNumber: (tryNumber: number) => void;
readonly search: LogSearchInputProps;
+ readonly showLogLevel: boolean;
readonly showSource: boolean;
readonly showTimestamp: boolean;
readonly sourceOptions?: Array<string>;
readonly taskInstance?: TaskInstanceResponse;
readonly toggleExpanded?: () => void;
readonly toggleFullscreen: () => void;
+ readonly toggleLogLevel: () => void;
readonly toggleSource: () => void;
readonly toggleTimestamp: () => void;
readonly toggleWrap: () => void;
@@ -69,12 +72,14 @@ export const TaskLogHeader = ({
isFullscreen = false,
onSelectTryNumber,
search,
+ showLogLevel,
showSource,
showTimestamp,
sourceOptions,
taskInstance,
toggleExpanded,
toggleFullscreen,
+ toggleLogLevel,
toggleSource,
toggleTimestamp,
toggleWrap,
@@ -217,6 +222,10 @@ export const TaskLogHeader = ({
<MdAccessTime /> {showTimestamp ? translate("timestamp.hide")
: translate("timestamp.show")}
<Menu.ItemCommand>{translate("timestamp.hotkey")}</Menu.ItemCommand>
</Menu.Item>
+ <Menu.Item data-testid="log-settings-log-level"
onClick={toggleLogLevel} value="log-level">
+ <MdOutlineLabel /> {showLogLevel ? translate("logLevel.hide")
: translate("logLevel.show")}
+
<Menu.ItemCommand>{translate("logLevel.hotkey")}</Menu.ItemCommand>
+ </Menu.Item>
<Menu.Item data-testid="log-settings-expand"
onClick={toggleExpanded} value="expand">
{expanded ? (
<>
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 6bb68d7192a..eb0d6567edd 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
@@ -38,6 +38,7 @@ const tiLine = (event: string, timestamp: string) => ({
describe("getDownloadText", () => {
const baseOptions = {
logLevelFilters: [],
+ showLogLevel: true,
showSource: false,
showTimestamp: false,
sourceFilters: [],
@@ -103,6 +104,18 @@ describe("getDownloadText", () => {
expect(lines.every((line) => !line.includes("Task Identity"))).toBe(true);
});
+
+ it("omits log level prefix when showLogLevel is false", () => {
+ const fetchedData = {
+ content: [{ event: "Task started", level: "info", timestamp:
"2026-01-01T00:00:00Z" }],
+ continuation_token: null,
+ };
+
+ const lines = getDownloadText({ ...baseOptions, fetchedData, showLogLevel:
false });
+
+ expect(lines).toContain("Task started");
+ expect(lines.every((line) => !line.includes("INFO -"))).toBe(true);
+ });
});
describe("getHighlightColor", () => {
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 ad88b22e6d6..e3040081940 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
@@ -34,6 +34,7 @@ export const getGroupHeaderMarker = (isExpanded: boolean):
string => (isExpanded
type GetDownloadTextOptions = {
fetchedData: TaskInstancesLogResponse | undefined;
logLevelFilters: Array<string>;
+ showLogLevel: boolean;
showSource: boolean;
showTimestamp: boolean;
sourceFilters: Array<string>;
@@ -48,6 +49,7 @@ type GetDownloadTextOptions = {
export const getDownloadText = ({
fetchedData,
logLevelFilters,
+ showLogLevel,
showSource,
showTimestamp,
sourceFilters,
@@ -63,6 +65,7 @@ export const getDownloadText = ({
logLink: "",
logMessage: line,
renderingMode: "text",
+ showLogLevel,
showSource,
showTimestamp,
sourceFilters,
diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.test.ts
b/airflow-core/src/airflow/ui/src/queries/useLogs.test.ts
index 2f56c48e608..4671c69c138 100644
--- a/airflow-core/src/airflow/ui/src/queries/useLogs.test.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useLogs.test.ts
@@ -50,6 +50,18 @@ describe("getLogLineText", () => {
);
});
+ it("omits the level when showLogLevel is false", () => {
+ const logMessage = {
+ event: "task done",
+ level: "info",
+ timestamp: "2026-01-01T00:00:00Z",
+ } as StructuredLogMessage;
+
+ expect(
+ getLogLineText({ logMessage, showLogLevel: false, showSource: false,
showTimestamp: true, translate }),
+ ).toBe("[2026-01-01T00:00:00Z] 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 143d47a3b19..6e102a0f33b 100644
--- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
@@ -53,6 +53,7 @@ export type ParsedLogEntry = {
type GetLogLineTextOptions = {
logLevelFilters?: Array<string>;
logMessage: string | StructuredLogMessage;
+ showLogLevel?: boolean;
showSource?: boolean;
showTimestamp?: boolean;
sourceFilters?: Array<string>;
@@ -67,6 +68,7 @@ type GetLogLineTextOptions = {
export const getLogLineText = ({
logLevelFilters,
logMessage,
+ showLogLevel,
showSource,
showTimestamp,
sourceFilters,
@@ -79,6 +81,7 @@ export const getLogLineText = ({
logLink: "",
logMessage,
renderingMode: "text",
+ showLogLevel,
showSource,
showTimestamp,
sourceFilters,
@@ -91,6 +94,7 @@ type Props = {
dagId: string;
limit?: number;
logLevelFilters?: Array<string>;
+ showLogLevel?: boolean;
showSource?: boolean;
showTimestamp?: boolean;
sourceFilters?: Array<string>;
@@ -101,6 +105,7 @@ type Props = {
type ParseLogsProps = {
data: TaskInstancesLogResponse["content"];
logLevelFilters?: Array<string>;
+ showLogLevel?: boolean;
showSource?: boolean;
showTimestamp?: boolean;
sourceFilters?: Array<string>;
@@ -112,6 +117,7 @@ type ParseLogsProps = {
const parseLogs = ({
data,
logLevelFilters,
+ showLogLevel,
showSource,
showTimestamp,
sourceFilters,
@@ -157,6 +163,7 @@ const parseLogs = ({
logLink,
logMessage: datum,
renderingMode: "jsx",
+ showLogLevel,
showSource,
showTimestamp,
sourceFilters,
@@ -186,7 +193,15 @@ const parseLogs = ({
parsedLines.forEach(({ element, lineNumber, logMessage }) => {
const text = innerText(element);
const getPlainText = () =>
- getLogLineText({ logLevelFilters, logMessage, showSource,
showTimestamp, sourceFilters, translate });
+ getLogLineText({
+ logLevelFilters,
+ logMessage,
+ showLogLevel,
+ showSource,
+ showTimestamp,
+ sourceFilters,
+ translate,
+ });
if (text.includes("::group::")) {
const groupName = text.split("::group::")[1] as string;
@@ -294,6 +309,7 @@ export const useLogs = (
dagId,
limit,
logLevelFilters,
+ showLogLevel,
showSource,
showTimestamp,
sourceFilters,
@@ -329,6 +345,7 @@ export const useLogs = (
const parsedData = parseLogs({
data: parseStreamingLogContent(truncateData(data, limit)),
logLevelFilters,
+ showLogLevel,
showSource,
showTimestamp,
sourceFilters,
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/task-logs.spec.ts
b/airflow-core/src/airflow/ui/tests/e2e/specs/task-logs.spec.ts
index 0f60a5400b9..6c12f45af03 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/specs/task-logs.spec.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/task-logs.spec.ts
@@ -78,6 +78,12 @@ test.describe("Verify task logs display", () => {
await page.getByTestId("log-settings-timestamp").click();
await expect(virtualizedList).not.toContainText(/\[\d{4}-\d{2}-\d{2}
\d{2}:\d{2}:\d{2}]/);
+ await expect(virtualizedList).toContainText(/INFO\s-/);
+
+ await page.getByTestId("log-settings-button").click();
+ await page.getByTestId("log-settings-log-level").click();
+ await expect(virtualizedList).not.toContainText(/INFO\s-/);
+
await page.getByTestId("log-settings-button").click();
await page.getByTestId("log-settings-source").click();
await expect(virtualizedList).toContainText(/source/);