This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-1-test by this push:
new d2de0140214 [v3-1-test] Add ANSI support to log viewer (#56463)
(#56721)
d2de0140214 is described below
commit d2de014021457113428efe3afbf5844fb0b68d0a
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Oct 16 13:27:43 2025 +0200
[v3-1-test] Add ANSI support to log viewer (#56463) (#56721)
(cherry picked from commit 65cfdc8282491aa9430b275c34ca2d0a4b041bc0)
Co-authored-by: LI,JHE-CHEN <[email protected]>
---
airflow-core/src/airflow/ui/package.json | 1 +
airflow-core/src/airflow/ui/pnpm-lock.yaml | 8 +
.../src/airflow/ui/src/components/AnsiRenderer.tsx | 235 +++++++++++++++++++++
.../ui/src/components/renderStructuredLog.tsx | 52 +++--
docs/spelling_wordlist.txt | 1 +
5 files changed, 276 insertions(+), 21 deletions(-)
diff --git a/airflow-core/src/airflow/ui/package.json
b/airflow-core/src/airflow/ui/package.json
index 70ad713ba27..71eba906d10 100644
--- a/airflow-core/src/airflow/ui/package.json
+++ b/airflow-core/src/airflow/ui/package.json
@@ -29,6 +29,7 @@
"@visx/group": "^3.12.0",
"@visx/shape": "^3.12.0",
"@xyflow/react": "^12.4.4",
+ "anser": "^2.3.2",
"axios": "^1.12.0",
"chakra-react-select": "6.1.0",
"chart.js": "^4.4.9",
diff --git a/airflow-core/src/airflow/ui/pnpm-lock.yaml
b/airflow-core/src/airflow/ui/pnpm-lock.yaml
index ae88dad9792..faa04f116d2 100644
--- a/airflow-core/src/airflow/ui/pnpm-lock.yaml
+++ b/airflow-core/src/airflow/ui/pnpm-lock.yaml
@@ -47,6 +47,9 @@ importers:
'@xyflow/react':
specifier: ^12.4.4
version:
12.4.4(@types/[email protected])([email protected]([email protected]))([email protected])
+ anser:
+ specifier: ^2.3.2
+ version: 2.3.2
axios:
specifier: ^1.12.0
version: 1.12.0
@@ -1761,6 +1764,9 @@ packages:
[email protected]:
resolution: {integrity:
sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+ [email protected]:
+ resolution: {integrity:
sha512-PMqBCBvrOVDRqLGooQb+z+t1Q0PiPyurUQeZRR5uHBOVZcW8B04KMmnT12USnhpNX2wCPagWzLVppQMUG3u0Dw==}
+
[email protected]:
resolution: {integrity:
sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
engines: {node: '>=8'}
@@ -6793,6 +6799,8 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
+ [email protected]: {}
+
[email protected]:
dependencies:
type-fest: 0.21.3
diff --git a/airflow-core/src/airflow/ui/src/components/AnsiRenderer.tsx
b/airflow-core/src/airflow/ui/src/components/AnsiRenderer.tsx
new file mode 100644
index 00000000000..aea00ca4afa
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/AnsiRenderer.tsx
@@ -0,0 +1,235 @@
+/*!
+ * 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 { chakra } from "@chakra-ui/react";
+import Anser, { type AnserJsonEntry } from "anser";
+import * as React from "react";
+
+const fixBackspace = (inputText: string): string => {
+ let tmp = inputText;
+
+ do {
+ const previous = tmp;
+
+ // eslint-disable-next-line no-control-regex
+ tmp = tmp.replaceAll(/[^\n]\u0008/gmu, "");
+ if (tmp.length >= previous.length) {
+ break;
+ }
+ } while (tmp.length > 0);
+
+ return tmp;
+};
+
+const ansiToJSON = (input: string, useClasses: boolean = false):
Array<AnserJsonEntry> => {
+ const processedInput = fixBackspace(input.replaceAll("\r", ""));
+
+ return Anser.ansiToJson(processedInput, {
+ json: true,
+ remove_empty: true,
+ use_classes: useClasses,
+ });
+};
+
+const createClass = (bundle: AnserJsonEntry): string | undefined => {
+ let classNames = "";
+
+ if (bundle.bg) {
+ classNames += `${bundle.bg}-bg `;
+ }
+ if (bundle.fg) {
+ classNames += `${bundle.fg}-fg `;
+ }
+ if (bundle.decoration) {
+ classNames += `ansi-${bundle.decoration} `;
+ }
+
+ if (classNames === "") {
+ return undefined;
+ }
+
+ return classNames.slice(0, classNames.length - 1);
+};
+
+// Map RGB values to Chakra UI semantic tokens
+// These are the standard ANSI color RGB values that anser library outputs
+const rgbToChakraColorMap: Record<string, string> = {
+ "0, 0, 0": "gray.900", // Black (30m)
+ "0, 0, 187": "blue.fg", // Blue (34m)
+ "0, 187, 0": "green.fg", // Green (32m)
+ "0, 187, 187": "cyan.fg", // Cyan (36m)
+ "0, 255, 0": "green.400", // Bright Green (92m)
+ "85, 85, 85": "black", // Bright Black/Gray (90m)
+ "85, 85, 255": "blue.400", // Bright Blue (94m)
+ "85, 255, 255": "cyan.400", // Bright Cyan (96m)
+ "187, 0, 0": "red.fg", // Red (31m)
+ "187, 0, 187": "purple.fg", // Magenta (35m)
+ "187, 187, 0": "yellow.fg", // Yellow (33m)
+ "187, 187, 187": "gray.100", // White (37m)
+ "255, 85, 85": "red.400", // Bright Red (91m)
+ "255, 85, 255": "purple.400", // Bright Magenta (95m)
+ "255, 255, 85": "yellow.400", // Bright Yellow (93m)
+ "255, 255, 255": "white", // Bright White (97m)
+};
+
+const createChakraProps = (bundle: AnserJsonEntry) => {
+ const props: Record<string, number | string> = {};
+
+ // Handle background colors
+ if (bundle.bg) {
+ const bgColor = rgbToChakraColorMap[bundle.bg];
+
+ props.bg = bgColor ?? `rgb(${bundle.bg})`;
+ }
+
+ // Handle foreground colors
+ if (bundle.fg) {
+ const fgColor = rgbToChakraColorMap[bundle.fg];
+
+ props.color = fgColor ?? `rgb(${bundle.fg})`;
+ }
+
+ // Handle text decorations
+ switch (bundle.decoration) {
+ case "blink":
+ props.textDecoration = "blink";
+ break;
+ case "bold":
+ props.fontWeight = "bold";
+ break;
+ case "dim":
+ props.opacity = "0.5";
+ break;
+ case "hidden":
+ props.visibility = "hidden";
+ break;
+ case "italic":
+ props.fontStyle = "italic";
+ break;
+ case "reverse":
+ // Could implement reverse video if needed
+ break;
+ case "strikethrough":
+ props.textDecoration = "line-through";
+ break;
+ case "underline":
+ props.textDecoration = "underline";
+ break;
+ // eslint-disable-next-line unicorn/no-useless-switch-case
+ case null:
+ default:
+ break;
+ }
+
+ return props;
+};
+
+const convertBundleIntoReact = (options: {
+ bundle: AnserJsonEntry;
+ key: number;
+ linkify: boolean;
+ useClasses: boolean;
+}): JSX.Element => {
+ const { bundle, key, linkify, useClasses } = options;
+ const style = useClasses ? undefined : createChakraProps(bundle);
+ const className = useClasses ? createClass(bundle) : undefined;
+
+ if (!linkify) {
+ if (useClasses) {
+ return (
+ <span className={className} key={key}>
+ {bundle.content}
+ </span>
+ );
+ }
+
+ return (
+ <chakra.span key={key} {...style}>
+ {bundle.content}
+ </chakra.span>
+ );
+ }
+
+ const content: Array<React.ReactNode> = [];
+ const linkRegex =
+
/(?<whitespace>\s|^)(?<url>https?:\/\/(?:www\.|(?!www))[^\s.]+\.\S{2,}|www\.\S+\.\S{2,})/gu;
+
+ let index = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = linkRegex.exec(bundle.content)) !== null) {
+ const { groups } = match;
+ const pre = groups?.whitespace ?? "";
+ const url = groups?.url ?? "";
+ const startIndex = match.index + pre.length;
+
+ if (startIndex > index) {
+ content.push(bundle.content.slice(index, startIndex));
+ }
+
+ const href = url.startsWith("www.") ? `http://${url}` : url;
+
+ content.push(
+ <a href={href} key={index} rel="noreferrer" target="_blank">
+ {url}
+ </a>,
+ );
+
+ index = linkRegex.lastIndex;
+ }
+
+ if (index < bundle.content.length) {
+ content.push(bundle.content.slice(index));
+ }
+
+ if (useClasses) {
+ return (
+ <span className={className} key={key}>
+ {content}
+ </span>
+ );
+ }
+
+ return (
+ <chakra.span key={key} {...style}>
+ {content}
+ </chakra.span>
+ );
+};
+
+type AnsiRendererProps = {
+ readonly children?: string;
+ readonly className?: string;
+ readonly linkify?: boolean;
+ readonly useClasses?: boolean;
+};
+
+export const AnsiRenderer: React.FC<AnsiRendererProps> = ({
+ children = "",
+ className,
+ linkify = false,
+ useClasses = false,
+}) => (
+ <code className={className}>
+ {ansiToJSON(children, useClasses).map((bundle, index) =>
+ convertBundleIntoReact({ bundle, key: index, linkify, useClasses }),
+ )}
+ </code>
+);
+
+export default AnsiRenderer;
diff --git a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
index a30247c4769..a5707cdb1ce 100644
--- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
+++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
@@ -18,9 +18,11 @@
*/
import { chakra, Code, Link } from "@chakra-ui/react";
import type { TFunction } from "i18next";
+import * as React from "react";
import { Link as RouterLink } from "react-router-dom";
import type { StructuredLogMessage } from "openapi/requests/types.gen";
+import AnsiRenderer from "src/components/AnsiRenderer";
import Time from "src/components/Time";
import { urlRegex } from "src/constants/urlRegex";
import { LogLevel, logLevelColorMapping } from "src/utils/logs";
@@ -51,28 +53,34 @@ type RenderStructuredLogProps = {
translate: TFunction;
};
-const addLinks = (line: string) => {
- const matches = [...line.matchAll(urlRegex)];
- let currentIndex = 0;
- const elements: Array<JSX.Element | string> = [];
+const addAnsiWithLinks = (line: string) => {
+ const urlMatches = [...line.matchAll(urlRegex)];
- if (!matches.length) {
- return line;
+ if (!urlMatches.length) {
+ return <AnsiRenderer linkify={false}>{line}</AnsiRenderer>;
}
- matches.forEach((match) => {
- const startIndex = match.index;
+ let currentIndex = 0;
+ const elements: Array<React.ReactNode> = [];
+
+ urlMatches.forEach((match) => {
+ const { index: startIndex } = match;
- // Add text before the URL
if (startIndex > currentIndex) {
- elements.push(line.slice(currentIndex, startIndex));
+ const textBeforeUrl = line.slice(currentIndex, startIndex);
+
+ elements.push(
+ <AnsiRenderer key={`ansi-before-${textBeforeUrl}`} linkify={false}>
+ {textBeforeUrl}
+ </AnsiRenderer>,
+ );
}
elements.push(
<Link
color="fg.info"
href={match[0]}
- key={match[0]}
+ key={`link-${match[0]}-${startIndex}`}
rel="noopener noreferrer"
target="_blank"
textDecoration="underline"
@@ -84,9 +92,14 @@ const addLinks = (line: string) => {
currentIndex = startIndex + match[0].length;
});
- // Add remaining text after the last URL
if (currentIndex < line.length) {
- elements.push(line.slice(currentIndex));
+ const textAfterUrl = line.slice(currentIndex);
+
+ elements.push(
+ <AnsiRenderer key="ansi-after" linkify={false}>
+ {textAfterUrl}
+ </AnsiRenderer>,
+ );
}
return elements;
@@ -107,7 +120,7 @@ export const renderStructuredLog = ({
if (typeof logMessage === "string") {
return (
<chakra.span key={index} lineHeight={1.5}>
- {addLinks(logMessage)}
+ {addAnsiWithLinks(logMessage)}
</chakra.span>
);
}
@@ -180,7 +193,7 @@ export const renderStructuredLog = ({
elements.push(
<chakra.span className="event" key={2} whiteSpace="pre-wrap">
- {addLinks(event)}
+ {addAnsiWithLinks(event)}
</chakra.span>,
);
@@ -199,12 +212,9 @@ export const renderStructuredLog = ({
const val = reStructured[key] as boolean | number | object | string |
null;
elements.push(
- " ",
- <span data-key={key}>
- <chakra.span color="fg.info" key={`prop_${key}`}>
- {key === "logger" ? "source" : key}
- </chakra.span>
- =
+ <React.Fragment key={`space_${key}`}> </React.Fragment>,
+ <span data-key={key} key={`struct_${key}`}>
+ <chakra.span color="fg.info">{key === "logger" ? "source" :
key}</chakra.span>=
<span data-value>
{
// Let strings, ints, etc through as is, but JSON stringify
anything more complex
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index 531e5a0a6db..a4dbbdf3202 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -66,6 +66,7 @@ AnExampleDisplayName
AnnotateTextResponse
AnotherExampleDisplayName
ans
+anser
Ansible
anyOf
apache