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

Reply via email to