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 a2ee0d80d69 UI - Download Tasks Logs button (#56771)
a2ee0d80d69 is described below
commit a2ee0d80d693890216694a571b11384c056fceb7
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Mon Oct 20 15:22:41 2025 +0200
UI - Download Tasks Logs button (#56771)
* Download Tasks Logs
* Update following code review
---
.../airflow/ui/public/i18n/locales/en/common.json | 5 +
.../ui/src/components/renderStructuredLog.tsx | 129 ++++++++++++++-------
.../ui/src/pages/Dag/Overview/TaskLogPreview.tsx | 6 +-
.../ui/src/pages/TaskInstance/Logs/Logs.tsx | 68 ++++++++---
.../src/pages/TaskInstance/Logs/TaskLogHeader.tsx | 17 +++
.../src/airflow/ui/src/queries/useLogs.tsx | 3 +-
6 files changed, 168 insertions(+), 60 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 ac440354e9f..1958abbb085 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
@@ -78,6 +78,11 @@
"githubRepo": "GitHub Repo",
"restApiReference": "REST API Reference"
},
+ "download": {
+ "download": "Download",
+ "hotkey": "d",
+ "tooltip": "Press {{hotkey}} to download logs"
+ },
"duration": "Duration",
"endDate": "End Date",
"error": {
diff --git a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
index a5707cdb1ce..f810f57554b 100644
--- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
+++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx
@@ -47,6 +47,7 @@ type RenderStructuredLogProps = {
logLevelFilters?: Array<string>;
logLink: string;
logMessage: string | StructuredLogMessage;
+ renderingMode?: "jsx" | "text";
showSource?: boolean;
showTimestamp?: boolean;
sourceFilters?: Array<string>;
@@ -107,17 +108,22 @@ const addAnsiWithLinks = (line: string) => {
const sourceFields = ["logger", "chan", "lineno", "filename", "loc"];
-export const renderStructuredLog = ({
+const renderStructuredLogImpl = ({
index,
logLevelFilters,
logLink,
logMessage,
+ renderingMode = "jsx",
showSource = true,
showTimestamp = true,
sourceFilters,
translate,
-}: RenderStructuredLogProps) => {
+}: RenderStructuredLogProps): JSX.Element | string => {
if (typeof logMessage === "string") {
+ if (renderingMode === "text") {
+ return logMessage;
+ }
+
return (
<chakra.span key={index} lineHeight={1.5}>
{addAnsiWithLinks(logMessage)}
@@ -147,22 +153,32 @@ export const renderStructuredLog = ({
}
if (Boolean(timestamp) && showTimestamp) {
- elements.push("[", <Time datetime={timestamp} key={0} />, "] ");
+ if (renderingMode === "text") {
+ elements.push(`[${timestamp}] `);
+ } else {
+ elements.push("[", <Time datetime={timestamp} key={0} />, "] ");
+ }
}
if (typeof level === "string") {
- elements.push(
- <Code
- colorPalette={level.toUpperCase() in LogLevel ?
logLevelColorMapping[level as LogLevel] : undefined}
- key={1}
- lineHeight={1.5}
- minH={0}
- px={0}
- >
- {level.toUpperCase()}
- </Code>,
- " - ",
- );
+ const formattedLevel = level.toUpperCase();
+
+ if (renderingMode === "text") {
+ elements.push(`${formattedLevel} - `);
+ } else {
+ elements.push(
+ <Code
+ colorPalette={level.toUpperCase() in LogLevel ?
logLevelColorMapping[level as LogLevel] : undefined}
+ key={1}
+ lineHeight={1.5}
+ minH={0}
+ px={0}
+ >
+ {formattedLevel}
+ </Code>,
+ " - ",
+ );
+ }
}
const { error_detail: errorDetail, ...reStructured } = structured;
@@ -170,13 +186,23 @@ export const renderStructuredLog = ({
if (errorDetail !== undefined) {
details = (errorDetail as Array<ErrorDetail>).map((error) => {
- const errorLines = error.frames.map((frame) => (
- <chakra.p
key={`frame-${frame.name}-${frame.filename}-${frame.lineno}`}>
- {translate("components:logs.file")}{" "}
- <chakra.span
color="fg.info">{JSON.stringify(frame.filename)}</chakra.span>,{" "}
- {translate("components:logs.location", { line: frame.lineno, name:
frame.name })}
- </chakra.p>
- ));
+ const errorLines = error.frames.map((frame) => {
+ if (renderingMode === "text") {
+ return ` ${translate("components:logs.file")} ${frame.filename},
${translate("components:logs.location", { line: frame.lineno, name: frame.name
})}\n`;
+ }
+
+ return (
+ <chakra.p
key={`frame-${frame.name}-${frame.filename}-${frame.lineno}`}>
+ {translate("components:logs.file")}{" "}
+ <chakra.span
color="fg.info">{JSON.stringify(frame.filename)}</chakra.span>,{" "}
+ {translate("components:logs.location", { line: frame.lineno, name:
frame.name })}
+ </chakra.p>
+ );
+ });
+
+ if (renderingMode === "text") {
+ return `${error.exc_type}: ${error.exc_value}\n${(errorLines as
Array<string>).join("")}`;
+ }
return (
<chakra.details key={error.exc_type} ms="20em" open={true}>
@@ -192,9 +218,13 @@ export const renderStructuredLog = ({
}
elements.push(
- <chakra.span className="event" key={2} whiteSpace="pre-wrap">
- {addAnsiWithLinks(event)}
- </chakra.span>,
+ renderingMode === "text" ? (
+ event
+ ) : (
+ <chakra.span className="event" key={2} whiteSpace="pre-wrap">
+ {addAnsiWithLinks(event)}
+ </chakra.span>
+ ),
);
if (Object.hasOwn(reStructured, "filename") && Object.hasOwn(reStructured,
"lineno")) {
@@ -211,27 +241,37 @@ export const renderStructuredLog = ({
}
const val = reStructured[key] as boolean | number | object | string |
null;
- elements.push(
- <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
- val instanceof Object ? JSON.stringify(val) : val
- }
- </span>
- </span>,
- );
+ // Let strings, ints, etc through as is, but JSON stringify anything
more complex
+ const stringifiedValue = val instanceof Object ? JSON.stringify(val) :
val;
+
+ if (renderingMode === "text") {
+ elements.push(`${key === "logger" ? "source" :
key}=${stringifiedValue} `);
+ } else {
+ elements.push(
+ <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>{stringifiedValue}</span>
+ </span>,
+ );
+ }
}
}
elements.push(
- <chakra.span className="event" key={3} whiteSpace="pre-wrap">
- {details}
- </chakra.span>,
+ renderingMode === "text" ? (
+ details
+ ) : (
+ <chakra.span className="event" key={3} whiteSpace="pre-wrap">
+ {details}
+ </chakra.span>
+ ),
);
+ if (renderingMode === "text") {
+ return (elements as Array<string>).join("");
+ }
+
return (
<chakra.div display="flex" key={index} lineHeight={1.5}>
<RouterLink
@@ -257,3 +297,12 @@ export const renderStructuredLog = ({
</chakra.div>
);
};
+
+// Overloads for renderStructuredLog function for stick type safety
+type RenderStructuredLogOverloads = {
+ (props: { renderingMode: "jsx" } & Omit<RenderStructuredLogProps,
"renderingMode">): JSX.Element | "";
+ (props: { renderingMode: "text" } & Omit<RenderStructuredLogProps,
"renderingMode">): string;
+};
+
+export const renderStructuredLog: RenderStructuredLogOverloads =
+ renderStructuredLogImpl as unknown as RenderStructuredLogOverloads;
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 0c3809ca21d..b5ac3707ea5 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
@@ -39,7 +39,11 @@ export const TaskLogPreview = ({
const { t: translate } = useTranslation("dag");
const [isExpanded, setIsExpanded] = useState(false);
- const { data, error, isLoading } = useLogs(
+ const {
+ error,
+ isLoading,
+ parsedData: data,
+ } = useLogs(
{
dagId: taskInstance.dag_id,
limit: 100,
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 86678a308c6..6215cdabd15 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,10 +24,12 @@ 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 { 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 } from "./TaskLogContent";
@@ -83,6 +85,48 @@ export const Logs = () => {
const [fullscreen, setFullscreen] = useState(false);
const [expanded, setExpanded] = useState(false);
+ const {
+ error: logError,
+ fetchedData,
+ isLoading: isLoadingLogs,
+ parsedData,
+ } = useLogs({
+ dagId,
+ expanded,
+ logLevelFilters,
+ showSource,
+ showTimestamp,
+ sourceFilters,
+ taskInstance,
+ tryNumber,
+ });
+
+ const downloadLogs = () => {
+ const lines = parseStreamingLogContent(fetchedData);
+ const parsedLines = lines.map((line) =>
+ renderStructuredLog({
+ index: 0,
+ logLevelFilters,
+ logLink: "",
+ logMessage: line,
+ renderingMode: "text",
+ showSource,
+ showTimestamp,
+ sourceFilters,
+ translate,
+ }),
+ );
+
+ const logContent = parsedLines.join("\n");
+ const element = document.createElement("a");
+
+ element.href = URL.createObjectURL(new Blob([logContent], { type:
"text/plain" }));
+ element.download =
`logs_${taskInstance?.dag_id}_${taskInstance?.dag_run_id}_${taskInstance?.task_id}_${taskInstance?.map_index}_${taskInstance?.try_number}`;
+ document.body.append(element);
+ element.click();
+ element.remove();
+ };
+
const toggleWrap = () => setWrap(!wrap);
const toggleTimestamp = () => setShowTimestamp(!showTimestamp);
const toggleSource = () => setShowSource(!showSource);
@@ -94,37 +138,24 @@ export const Logs = () => {
useHotkeys("e", toggleExpanded);
useHotkeys("t", toggleTimestamp);
useHotkeys("s", toggleSource);
+ useHotkeys("d", downloadLogs);
const onOpenChange = () => {
setFullscreen(false);
};
- const {
- data,
- error: logError,
- isLoading: isLoadingLogs,
- } = useLogs({
- dagId,
- expanded,
- logLevelFilters,
- showSource,
- showTimestamp,
- sourceFilters,
- taskInstance,
- tryNumber,
- });
-
const externalLogName = useConfig("external_log_name") as string;
const showExternalLogRedirect =
Boolean(useConfig("show_external_log_redirect"));
return (
<Box display="flex" flexDirection="column" h="100%" p={2}>
<TaskLogHeader
+ downloadLogs={downloadLogs}
expanded={expanded}
onSelectTryNumber={onSelectTryNumber}
showSource={showSource}
showTimestamp={showTimestamp}
- sourceOptions={data.sources}
+ sourceOptions={parsedData.sources}
taskInstance={taskInstance}
toggleExpanded={toggleExpanded}
toggleFullscreen={toggleFullscreen}
@@ -149,7 +180,7 @@ export const Logs = () => {
error={error}
isLoading={isLoading || isLoadingLogs}
logError={logError}
- parsedLogs={data.parsedLogs ?? []}
+ parsedLogs={parsedData.parsedLogs ?? []}
wrap={wrap}
/>
<Dialog.Root onOpenChange={onOpenChange} open={fullscreen}
scrollBehavior="inside" size="full">
@@ -158,6 +189,7 @@ export const Logs = () => {
<VStack alignItems="flex-start" gap={2}>
<Heading size="xl">{taskId}</Heading>
<TaskLogHeader
+ downloadLogs={downloadLogs}
expanded={expanded}
isFullscreen
onSelectTryNumber={onSelectTryNumber}
@@ -182,7 +214,7 @@ export const Logs = () => {
error={error}
isLoading={isLoading || isLoadingLogs}
logError={logError}
- parsedLogs={data.parsedLogs ?? []}
+ parsedLogs={parsedData.parsedLogs ?? []}
wrap={wrap}
/>
</Dialog.Body>
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 f99051d92a0..0d8d6c88e77 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
@@ -34,6 +34,7 @@ import {
MdOutlineOpenInFull,
MdSettings,
MdWrapText,
+ MdOutlineFileDownload,
} from "react-icons/md";
import { useSearchParams } from "react-router-dom";
@@ -45,6 +46,7 @@ import { system } from "src/theme";
import { type LogLevel, logLevelColorMapping, logLevelOptions } from
"src/utils/logs";
type Props = {
+ readonly downloadLogs?: () => void;
readonly expanded?: boolean;
readonly isFullscreen?: boolean;
readonly onSelectTryNumber: (tryNumber: number) => void;
@@ -62,6 +64,7 @@ type Props = {
};
export const TaskLogHeader = ({
+ downloadLogs,
expanded,
isFullscreen = false,
onSelectTryNumber,
@@ -255,6 +258,20 @@ export const TaskLogHeader = ({
</IconButton>
</Tooltip>
)}
+
+ <Tooltip closeDelay={100} content={translate("download.tooltip", {
hotkey: "d" })} openDelay={100}>
+ <IconButton
+ aria-label={translate("download.download")}
+ bg="bg.panel"
+ m={0}
+ onClick={downloadLogs}
+ px={4}
+ py={2}
+ variant="outline"
+ >
+ <MdOutlineFileDownload />
+ </IconButton>
+ </Tooltip>
</HStack>
</HStack>
</Box>
diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
index f2390554ae6..24f3907c87a 100644
--- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
@@ -90,6 +90,7 @@ const parseLogs = ({
logLevelFilters,
logLink,
logMessage: datum,
+ renderingMode: "jsx",
showSource,
showTimestamp,
sourceFilters,
@@ -249,5 +250,5 @@ export const useLogs = (
tryNumber,
});
- return { data: parsedData, ...rest };
+ return { parsedData, ...rest, fetchedData: data };
};