diff --git a/airflow/config_templates/config.yml b/airflow/config_templates/config.yml index 60a9d3c7dc6b7..995f9f0393561 100644 --- a/airflow/config_templates/config.yml +++ b/airflow/config_templates/config.yml @@ -979,6 +979,22 @@ logging: type: boolean example: ~ default: "True" + color_log_error_keywords: + description: | + A comma separated list of keywords related to errors whose presence should display the line in red + color in UI + version_added: 2.10.0 + type: string + example: ~ + default: "error,exception" + color_log_warning_keywords: + description: | + A comma separated list of keywords related to warning whose presence should display the line in yellow + color in UI + version_added: 2.10.0 + type: string + example: ~ + default: "warn" metrics: description: | StatsD (https://github.com/etsy/statsd) integration settings. diff --git a/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts b/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts index 1d41b3dccd5a1..0de5676916bbb 100644 --- a/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts +++ b/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts @@ -20,6 +20,7 @@ /* global moment */ import { AnsiUp } from "ansi_up"; +import { getMetaValue, highlightByKeywords } from "src/utils"; import { defaultFormatWithTZ } from "src/datetime_utils"; export enum LogLevel { @@ -38,6 +39,15 @@ export const logLevelColorMapping = { [LogLevel.CRITICAL]: "red.400", }; +const errorKeywords = getMetaValue("color_log_error_keywords") + .split(",") + .filter((keyword) => keyword.length > 0) + .map((keyword) => keyword.toLowerCase()); +const warningKeywords = getMetaValue("color_log_warning_keywords") + .split(",") + .filter((keyword) => keyword.length > 0) + .map((keyword) => keyword.toLowerCase()); + export const parseLogs = ( data: string | undefined, timezone: string | null, @@ -112,6 +122,11 @@ export const parseLogs = ( line.includes(fileSourceFilter) ) ) { + parsedLine = highlightByKeywords( + parsedLine, + errorKeywords, + warningKeywords + ); // for lines with color convert to nice HTML const coloredLine = ansiUp.ansi_to_html(parsedLine); diff --git a/airflow/www/static/js/utils/index.test.ts b/airflow/www/static/js/utils/index.test.ts index 4c8be82cfd470..a76ee62c867dd 100644 --- a/airflow/www/static/js/utils/index.test.ts +++ b/airflow/www/static/js/utils/index.test.ts @@ -19,7 +19,12 @@ import { isEmpty } from "lodash"; import type { DagRun } from "src/types"; -import { getDagRunLabel, getTask, getTaskSummary } from "."; +import { + getDagRunLabel, + getTask, + getTaskSummary, + highlightByKeywords, +} from "."; const sampleTasks = { id: null, @@ -148,3 +153,45 @@ describe("Test getDagRunLabel", () => { expect(runLabel).toBe(dagRun.executionDate); }); }); + +describe("Test highlightByKeywords", () => { + test("Highlight error line by red color", async () => { + const originalLine = "line with Error"; + const expected = `\x1b[1m\x1b[31mline with Error\x1b[39m\x1b[0m`; + const highlightedLine = highlightByKeywords( + originalLine, + ["error"], + ["warn"] + ); + expect(highlightedLine).toBe(expected); + }); + test("Highlight warning line by yellow color", async () => { + const originalLine = "line with Warning"; + const expected = `\x1b[1m\x1b[33mline with Warning\x1b[39m\x1b[0m`; + const highlightedLine = highlightByKeywords( + originalLine, + ["error"], + ["warn"] + ); + expect(highlightedLine).toBe(expected); + }); + test("Highlight line by red color when line has both error and warning", async () => { + const originalLine = "line with error Warning"; + const expected = `\x1b[1m\x1b[31mline with error Warning\x1b[39m\x1b[0m`; + const highlightedLine = highlightByKeywords( + originalLine, + ["error"], + ["warn"] + ); + expect(highlightedLine).toBe(expected); + }); + test("No highlight", async () => { + const originalLine = "sample line"; + const highlightedLine = highlightByKeywords( + originalLine, + ["error"], + ["warn"] + ); + expect(highlightedLine).toBe(originalLine); + }); +}); diff --git a/airflow/www/static/js/utils/index.ts b/airflow/www/static/js/utils/index.ts index 37dc5cb022c88..9eb8af9638c42 100644 --- a/airflow/www/static/js/utils/index.ts +++ b/airflow/www/static/js/utils/index.ts @@ -185,6 +185,34 @@ const toSentenceCase = (camelCase: string): string => { return ""; }; +const highlightByKeywords = ( + parsedLine: string, + errorKeywords: string[], + warningKeywords: string[] +): string => { + const lowerParsedLine = parsedLine.toLowerCase(); + const red = (line: string) => `\x1b[1m\x1b[31m${line}\x1b[39m\x1b[0m`; + const yellow = (line: string) => `\x1b[1m\x1b[33m${line}\x1b[39m\x1b[0m`; + + const containsError = errorKeywords.some((keyword) => + lowerParsedLine.includes(keyword) + ); + + if (containsError) { + return red(parsedLine); + } + + const containsWarning = warningKeywords.some((keyword) => + lowerParsedLine.includes(keyword) + ); + + if (containsWarning) { + return yellow(parsedLine); + } + + return parsedLine; +}; + export { hoverDelay, finalStatesMap, @@ -197,4 +225,5 @@ export { getStatusBackgroundColor, useOffsetTop, toSentenceCase, + highlightByKeywords, }; diff --git a/airflow/www/templates/airflow/grid.html b/airflow/www/templates/airflow/grid.html index 3c7bb236e178e..e90389212b14f 100644 --- a/airflow/www/templates/airflow/grid.html +++ b/airflow/www/templates/airflow/grid.html @@ -28,6 +28,8 @@ + + {% endblock %} {% block content %} diff --git a/airflow/www/views.py b/airflow/www/views.py index 328312658bd18..f26a5337ff548 100644 --- a/airflow/www/views.py +++ b/airflow/www/views.py @@ -2786,6 +2786,9 @@ def legacy_tree(self): @provide_session def grid(self, dag_id: str, session: Session = NEW_SESSION): """Get Dag's grid view.""" + color_log_error_keywords = conf.get("logging", "color_log_error_keywords", fallback="") + color_log_warning_keywords = conf.get("logging", "color_log_warning_keywords", fallback="") + dag = get_airflow_app().dag_bag.get_dag(dag_id, session=session) dag_model = DagModel.get_dagmodel(dag_id, session=session) if not dag: @@ -2843,6 +2846,8 @@ def grid(self, dag_id: str, session: Session = NEW_SESSION): ), included_events_raw=included_events_raw, excluded_events_raw=excluded_events_raw, + color_log_error_keywords=color_log_error_keywords, + color_log_warning_keywords=color_log_warning_keywords, ) @expose("/calendar")