Skip to content

Commit 20fceea

Browse files
Download Tasks Logs
1 parent d271390 commit 20fceea

6 files changed

Lines changed: 158 additions & 60 deletions

File tree

airflow-core/src/airflow/ui/public/i18n/locales/en/common.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@
7878
"githubRepo": "GitHub Repo",
7979
"restApiReference": "REST API Reference"
8080
},
81+
"download": {
82+
"download": "Download",
83+
"hotkey": "d",
84+
"tooltip": "Press {{hotkey}} to download logs"
85+
},
8186
"duration": "Duration",
8287
"endDate": "End Date",
8388
"error": {

airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx

Lines changed: 89 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type RenderStructuredLogProps = {
4747
logLevelFilters?: Array<string>;
4848
logLink: string;
4949
logMessage: string | StructuredLogMessage;
50+
renderingMode?: "jsx" | "text";
5051
showSource?: boolean;
5152
showTimestamp?: boolean;
5253
sourceFilters?: Array<string>;
@@ -107,17 +108,22 @@ const addAnsiWithLinks = (line: string) => {
107108

108109
const sourceFields = ["logger", "chan", "lineno", "filename", "loc"];
109110

110-
export const renderStructuredLog = ({
111+
const renderStructuredLogImpl = ({
111112
index,
112113
logLevelFilters,
113114
logLink,
114115
logMessage,
116+
renderingMode = "jsx",
115117
showSource = true,
116118
showTimestamp = true,
117119
sourceFilters,
118120
translate,
119-
}: RenderStructuredLogProps) => {
121+
}: RenderStructuredLogProps): JSX.Element | string => {
120122
if (typeof logMessage === "string") {
123+
if (renderingMode === "text") {
124+
return logMessage;
125+
}
126+
121127
return (
122128
<chakra.span key={index} lineHeight={1.5}>
123129
{addAnsiWithLinks(logMessage)}
@@ -147,36 +153,56 @@ export const renderStructuredLog = ({
147153
}
148154

149155
if (Boolean(timestamp) && showTimestamp) {
150-
elements.push("[", <Time datetime={timestamp} key={0} />, "] ");
156+
if (renderingMode === "text") {
157+
elements.push(`[${timestamp}] `);
158+
} else {
159+
elements.push("[", <Time datetime={timestamp} key={0} />, "] ");
160+
}
151161
}
152162

153163
if (typeof level === "string") {
154-
elements.push(
155-
<Code
156-
colorPalette={level.toUpperCase() in LogLevel ? logLevelColorMapping[level as LogLevel] : undefined}
157-
key={1}
158-
lineHeight={1.5}
159-
minH={0}
160-
px={0}
161-
>
162-
{level.toUpperCase()}
163-
</Code>,
164-
" - ",
165-
);
164+
const formattedLevel = level.toUpperCase();
165+
166+
if (renderingMode === "text") {
167+
elements.push(`${formattedLevel} - `);
168+
} else {
169+
elements.push(
170+
<Code
171+
colorPalette={level.toUpperCase() in LogLevel ? logLevelColorMapping[level as LogLevel] : undefined}
172+
key={1}
173+
lineHeight={1.5}
174+
minH={0}
175+
px={0}
176+
>
177+
{formattedLevel}
178+
</Code>,
179+
" - ",
180+
);
181+
}
166182
}
167183

168184
const { error_detail: errorDetail, ...reStructured } = structured;
169185
let details;
170186

171187
if (errorDetail !== undefined) {
172188
details = (errorDetail as Array<ErrorDetail>).map((error) => {
173-
const errorLines = error.frames.map((frame) => (
174-
<chakra.p key={`frame-${frame.name}-${frame.filename}-${frame.lineno}`}>
175-
{translate("components:logs.file")}{" "}
176-
<chakra.span color="fg.info">{JSON.stringify(frame.filename)}</chakra.span>,{" "}
177-
{translate("components:logs.location", { line: frame.lineno, name: frame.name })}
178-
</chakra.p>
179-
));
189+
const errorLines = error.frames.map((frame) => {
190+
if (renderingMode === "text") {
191+
return ` ${translate("components:logs.file")} ${frame.filename}, ${translate("components:logs.location", { line: frame.lineno, name: frame.name })}\n`;
192+
}
193+
194+
return (
195+
<chakra.p key={`frame-${frame.name}-${frame.filename}-${frame.lineno}`}>
196+
{translate("components:logs.file")}{" "}
197+
<chakra.span color="fg.info">{JSON.stringify(frame.filename)}</chakra.span>,{" "}
198+
{translate("components:logs.location", { line: frame.lineno, name: frame.name })}
199+
</chakra.p>
200+
);
201+
});
202+
203+
if (renderingMode === "text") {
204+
return `${error.exc_type}: ${error.exc_value}\n${(errorLines as Array<string>).join("")}`;
205+
}
180206

181207
return (
182208
<chakra.details key={error.exc_type} ms="20em" open={true}>
@@ -192,9 +218,13 @@ export const renderStructuredLog = ({
192218
}
193219

194220
elements.push(
195-
<chakra.span className="event" key={2} whiteSpace="pre-wrap">
196-
{addAnsiWithLinks(event)}
197-
</chakra.span>,
221+
renderingMode === "text" ? (
222+
event
223+
) : (
224+
<chakra.span className="event" key={2} whiteSpace="pre-wrap">
225+
{addAnsiWithLinks(event)}
226+
</chakra.span>
227+
),
198228
);
199229

200230
if (Object.hasOwn(reStructured, "filename") && Object.hasOwn(reStructured, "lineno")) {
@@ -211,27 +241,37 @@ export const renderStructuredLog = ({
211241
}
212242
const val = reStructured[key] as boolean | number | object | string | null;
213243

214-
elements.push(
215-
<React.Fragment key={`space_${key}`}> </React.Fragment>,
216-
<span data-key={key} key={`struct_${key}`}>
217-
<chakra.span color="fg.info">{key === "logger" ? "source" : key}</chakra.span>=
218-
<span data-value>
219-
{
220-
// Let strings, ints, etc through as is, but JSON stringify anything more complex
221-
val instanceof Object ? JSON.stringify(val) : val
222-
}
223-
</span>
224-
</span>,
225-
);
244+
// Let strings, ints, etc through as is, but JSON stringify anything more complex
245+
const stringifiedValue = val instanceof Object ? JSON.stringify(val) : val;
246+
247+
if (renderingMode === "text") {
248+
elements.push(`${key === "logger" ? "source" : key}=${stringifiedValue} `);
249+
} else {
250+
elements.push(
251+
<React.Fragment key={`space_${key}`}> </React.Fragment>,
252+
<span data-key={key} key={`struct_${key}`}>
253+
<chakra.span color="fg.info">{key === "logger" ? "source" : key}</chakra.span>=
254+
<span data-value>{stringifiedValue}</span>
255+
</span>,
256+
);
257+
}
226258
}
227259
}
228260

229261
elements.push(
230-
<chakra.span className="event" key={3} whiteSpace="pre-wrap">
231-
{details}
232-
</chakra.span>,
262+
renderingMode === "text" ? (
263+
details
264+
) : (
265+
<chakra.span className="event" key={3} whiteSpace="pre-wrap">
266+
{details}
267+
</chakra.span>
268+
),
233269
);
234270

271+
if (renderingMode === "text") {
272+
return (elements as Array<string>).join("");
273+
}
274+
235275
return (
236276
<chakra.div display="flex" key={index} lineHeight={1.5}>
237277
<RouterLink
@@ -257,3 +297,12 @@ export const renderStructuredLog = ({
257297
</chakra.div>
258298
);
259299
};
300+
301+
// Overloads for renderStructuredLog function for stick type safety
302+
type RenderStructuredLogOverloads = {
303+
(props: { renderingMode: "jsx" } & Omit<RenderStructuredLogProps, "renderingMode">): JSX.Element | "";
304+
(props: { renderingMode: "text" } & Omit<RenderStructuredLogProps, "renderingMode">): string;
305+
};
306+
307+
export const renderStructuredLog: RenderStructuredLogOverloads =
308+
renderStructuredLogImpl as unknown as RenderStructuredLogOverloads;

airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ export const TaskLogPreview = ({
3939
const { t: translate } = useTranslation("dag");
4040
const [isExpanded, setIsExpanded] = useState(false);
4141

42-
const { data, error, isLoading } = useLogs(
42+
const {
43+
error,
44+
isLoading,
45+
parsedData: data,
46+
} = useLogs(
4347
{
4448
dagId: taskInstance.dag_id,
4549
limit: 100,

airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ import { useParams, useSearchParams } from "react-router-dom";
2424
import { useLocalStorage } from "usehooks-ts";
2525

2626
import { useTaskInstanceServiceGetMappedTaskInstance } from "openapi/queries";
27+
import { renderStructuredLog } from "src/components/renderStructuredLog";
2728
import { Dialog } from "src/components/ui";
2829
import { SearchParamsKeys } from "src/constants/searchParams";
2930
import { useConfig } from "src/queries/useConfig";
3031
import { useLogs } from "src/queries/useLogs";
32+
import { parseStreamingLogContent } from "src/utils/logs";
3133

3234
import { ExternalLogLink } from "./ExternalLogLink";
3335
import { TaskLogContent } from "./TaskLogContent";
@@ -83,6 +85,48 @@ export const Logs = () => {
8385
const [fullscreen, setFullscreen] = useState(false);
8486
const [expanded, setExpanded] = useState(false);
8587

88+
const {
89+
error: logError,
90+
fetchedData,
91+
isLoading: isLoadingLogs,
92+
parsedData,
93+
} = useLogs({
94+
dagId,
95+
expanded,
96+
logLevelFilters,
97+
showSource,
98+
showTimestamp,
99+
sourceFilters,
100+
taskInstance,
101+
tryNumber,
102+
});
103+
104+
const downloadLogs = () => {
105+
const lines = parseStreamingLogContent(fetchedData);
106+
const parsedLines = lines.map((line) =>
107+
renderStructuredLog({
108+
index: 0,
109+
logLevelFilters,
110+
logLink: "",
111+
logMessage: line,
112+
renderingMode: "text",
113+
showSource,
114+
showTimestamp,
115+
sourceFilters,
116+
translate,
117+
}),
118+
);
119+
120+
const logContent = parsedLines.join("\n");
121+
const element = document.createElement("a");
122+
123+
element.href = URL.createObjectURL(new Blob([logContent], { type: "text/plain" }));
124+
element.download = `logs_${taskInstance?.dag_id}_${taskInstance?.dag_run_id}_${taskInstance?.task_id}_${taskInstance?.map_index}_${taskInstance?.try_number}`;
125+
document.body.append(element);
126+
element.click();
127+
element.remove();
128+
};
129+
86130
const toggleWrap = () => setWrap(!wrap);
87131
const toggleTimestamp = () => setShowTimestamp(!showTimestamp);
88132
const toggleSource = () => setShowSource(!showSource);
@@ -94,37 +138,24 @@ export const Logs = () => {
94138
useHotkeys("e", toggleExpanded);
95139
useHotkeys("t", toggleTimestamp);
96140
useHotkeys("s", toggleSource);
141+
useHotkeys("d", downloadLogs);
97142

98143
const onOpenChange = () => {
99144
setFullscreen(false);
100145
};
101146

102-
const {
103-
data,
104-
error: logError,
105-
isLoading: isLoadingLogs,
106-
} = useLogs({
107-
dagId,
108-
expanded,
109-
logLevelFilters,
110-
showSource,
111-
showTimestamp,
112-
sourceFilters,
113-
taskInstance,
114-
tryNumber,
115-
});
116-
117147
const externalLogName = useConfig("external_log_name") as string;
118148
const showExternalLogRedirect = Boolean(useConfig("show_external_log_redirect"));
119149

120150
return (
121151
<Box display="flex" flexDirection="column" h="100%" p={2}>
122152
<TaskLogHeader
153+
downloadLogs={downloadLogs}
123154
expanded={expanded}
124155
onSelectTryNumber={onSelectTryNumber}
125156
showSource={showSource}
126157
showTimestamp={showTimestamp}
127-
sourceOptions={data.sources}
158+
sourceOptions={parsedData.sources}
128159
taskInstance={taskInstance}
129160
toggleExpanded={toggleExpanded}
130161
toggleFullscreen={toggleFullscreen}
@@ -149,7 +180,7 @@ export const Logs = () => {
149180
error={error}
150181
isLoading={isLoading || isLoadingLogs}
151182
logError={logError}
152-
parsedLogs={data.parsedLogs ?? []}
183+
parsedLogs={parsedData.parsedLogs ?? []}
153184
wrap={wrap}
154185
/>
155186
<Dialog.Root onOpenChange={onOpenChange} open={fullscreen} scrollBehavior="inside" size="full">
@@ -158,6 +189,7 @@ export const Logs = () => {
158189
<VStack alignItems="flex-start" gap={2}>
159190
<Heading size="xl">{taskId}</Heading>
160191
<TaskLogHeader
192+
downloadLogs={downloadLogs}
161193
expanded={expanded}
162194
isFullscreen
163195
onSelectTryNumber={onSelectTryNumber}
@@ -182,7 +214,7 @@ export const Logs = () => {
182214
error={error}
183215
isLoading={isLoading || isLoadingLogs}
184216
logError={logError}
185-
parsedLogs={data.parsedLogs ?? []}
217+
parsedLogs={parsedData.parsedLogs ?? []}
186218
wrap={wrap}
187219
/>
188220
</Dialog.Body>

airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
MdOutlineOpenInFull,
3535
MdSettings,
3636
MdWrapText,
37+
MdOutlineFileDownload,
3738
} from "react-icons/md";
3839
import { useSearchParams } from "react-router-dom";
3940

@@ -45,6 +46,7 @@ import { system } from "src/theme";
4546
import { type LogLevel, logLevelColorMapping, logLevelOptions } from "src/utils/logs";
4647

4748
type Props = {
49+
readonly downloadLogs?: () => void;
4850
readonly expanded?: boolean;
4951
readonly isFullscreen?: boolean;
5052
readonly onSelectTryNumber: (tryNumber: number) => void;
@@ -62,6 +64,7 @@ type Props = {
6264
};
6365

6466
export const TaskLogHeader = ({
67+
downloadLogs,
6568
expanded,
6669
isFullscreen = false,
6770
onSelectTryNumber,
@@ -234,6 +237,10 @@ export const TaskLogHeader = ({
234237
<MdCode /> {showSource ? translate("source.hide") : translate("source.show")}
235238
<Menu.ItemCommand>{translate("source.hotkey")}</Menu.ItemCommand>
236239
</Menu.Item>
240+
<Menu.Item onClick={downloadLogs} value="download">
241+
<MdOutlineFileDownload /> {translate("download.download")}
242+
<Menu.ItemCommand>{translate("download.hotkey")}</Menu.ItemCommand>
243+
</Menu.Item>
237244
</Menu.Content>
238245
</Menu.Root>
239246
{!isFullscreen && (

airflow-core/src/airflow/ui/src/queries/useLogs.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ const parseLogs = ({
9090
logLevelFilters,
9191
logLink,
9292
logMessage: datum,
93+
renderingMode: "jsx",
9394
showSource,
9495
showTimestamp,
9596
sourceFilters,
@@ -249,5 +250,5 @@ export const useLogs = (
249250
tryNumber,
250251
});
251252

252-
return { data: parsedData, ...rest };
253+
return { parsedData, ...rest, fetchedData: data };
253254
};

0 commit comments

Comments
 (0)