diff --git a/airflow/www/static/js/api/useTaskLog.ts b/airflow/www/static/js/api/useTaskLog.ts index 580c5e0ab4995..bbb6395878b4f 100644 --- a/airflow/www/static/js/api/useTaskLog.ts +++ b/airflow/www/static/js/api/useTaskLog.ts @@ -17,6 +17,7 @@ * under the License. */ +import { useState } from 'react'; import axios, { AxiosResponse } from 'axios'; import { useQuery } from 'react-query'; import { useAutoRefresh } from 'src/context/autorefresh'; @@ -34,6 +35,7 @@ const useTaskLog = ({ dagId, dagRunId, taskId, taskTryNumber, mapIndex, fullContent, state, }: Props) => { let url: string = ''; + const [isPreviousStatePending, setPrevState] = useState(true); if (taskLogApi) { url = taskLogApi.replace('_DAG_RUN_ID_', dagRunId).replace('_TASK_ID_', taskId).replace(/-1$/, taskTryNumber.toString()); } @@ -49,12 +51,24 @@ const useTaskLog = ({ || state === 'queued' || state === 'restarting'; + // We also want to get the last log when the task was finished + const expectingLogs = isStatePending || isPreviousStatePending; + return useQuery( - ['taskLogs', dagId, dagRunId, taskId, mapIndex, taskTryNumber, fullContent, state], - () => axios.get(url, { headers: { Accept: 'text/plain' }, params: { map_index: mapIndex, full_content: fullContent } }), + ['taskLogs', dagId, dagRunId, taskId, mapIndex, taskTryNumber, fullContent], + () => { + setPrevState(isStatePending); + return axios.get( + url, + { + headers: { Accept: 'text/plain' }, + params: { map_index: mapIndex, full_content: fullContent }, + }, + ); + }, { placeholderData: '', - refetchInterval: isStatePending && isRefreshOn && (autoRefreshInterval || 1) * 1000, + refetchInterval: expectingLogs && isRefreshOn && (autoRefreshInterval || 1) * 1000, }, ); }; diff --git a/airflow/www/static/js/dag/details/taskInstance/Logs/LogBlock.tsx b/airflow/www/static/js/dag/details/taskInstance/Logs/LogBlock.tsx new file mode 100644 index 0000000000000..0ffa76e21f905 --- /dev/null +++ b/airflow/www/static/js/dag/details/taskInstance/Logs/LogBlock.tsx @@ -0,0 +1,90 @@ +/*! + * 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 React, { + useRef, useEffect, useState, +} from 'react'; +import { + Code, +} from '@chakra-ui/react'; + +import useOffsetHeight from 'src/utils/useOffsetHeight'; + +interface Props { + parsedLogs: string; + wrap: boolean; + tryNumber: number; +} + +const LogBlock = ({ + parsedLogs, + wrap, + tryNumber, +}: Props) => { + const [autoScroll, setAutoScroll] = useState(true); + const logBoxRef = useRef(null); + + const maxHeight = useOffsetHeight(logBoxRef, parsedLogs); + + const codeBlockBottomDiv = useRef(null); + + const scrollToBottom = () => { + codeBlockBottomDiv.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }; + + useEffect(() => { + // Always scroll to bottom when wrap or tryNumber change + scrollToBottom(); + }, [wrap, tryNumber]); + + useEffect(() => { + // When logs change, only scroll if autoScroll is enabled + if (autoScroll) scrollToBottom(); + }, [parsedLogs, autoScroll]); + + const onScroll = (e: React.UIEvent) => { + if (e.currentTarget) { + const { scrollTop, offsetHeight, scrollHeight } = e.currentTarget; + // Enable autoscroll if we've scrolled to the bottom of the logs + setAutoScroll(scrollTop + offsetHeight >= scrollHeight); + } + }; + + return ( + + {parsedLogs} +
+ + ); +}; + +export default LogBlock; diff --git a/airflow/www/static/js/dag/details/taskInstance/Logs/index.tsx b/airflow/www/static/js/dag/details/taskInstance/Logs/index.tsx index 43976a7b52974..0139d8e22392a 100644 --- a/airflow/www/static/js/dag/details/taskInstance/Logs/index.tsx +++ b/airflow/www/static/js/dag/details/taskInstance/Logs/index.tsx @@ -18,14 +18,13 @@ */ import React, { - useRef, useState, useEffect, useMemo, + useState, useEffect, useMemo, } from 'react'; import { Text, Box, Flex, Divider, - Code, Button, Checkbox, } from '@chakra-ui/react'; @@ -36,12 +35,12 @@ import LinkButton from 'src/components/LinkButton'; import { useTimezone } from 'src/context/timezone'; import type { Dag, DagRun, TaskInstance } from 'src/types'; import MultiSelect from 'src/components/MultiSelect'; -import useOffsetHeight from 'src/utils/useOffsetHeight'; import URLSearchParamsWrapper from 'src/utils/URLSearchParamWrapper'; import LogLink from './LogLink'; import { LogLevel, logLevelColorMapping, parseLogs } from './utils'; +import LogBlock from './LogBlock'; interface LogLevelOption { label: LogLevel; @@ -108,10 +107,9 @@ const Logs = ({ const [logLevelFilters, setLogLevelFilters] = useState>([]); const [fileSourceFilters, setFileSourceFilters] = useState>([]); const { timezone } = useTimezone(); - const logBoxRef = useRef(null); const taskTryNumber = selectedTryNumber || tryNumber || 1; - const { data, isSuccess } = useTaskLog({ + const { data } = useTaskLog({ dagId, dagRunId, taskId, @@ -121,8 +119,6 @@ const Logs = ({ state, }); - const offsetHeight = useOffsetHeight(logBoxRef, data); - const params = new URLSearchParamsWrapper({ task_id: taskId, execution_date: executionDate, @@ -142,14 +138,6 @@ const Logs = ({ [data, fileSourceFilters, logLevelFilters, timezone], ); - const codeBlockBottomDiv = useRef(null); - - useEffect(() => { - if (codeBlockBottomDiv.current && parsedLogs) { - codeBlockBottomDiv.current.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - } - }, [wrap, parsedLogs]); - useEffect(() => { // Reset fileSourceFilters and selected attempt when changing to // a task that do not have those filters anymore. @@ -257,26 +245,13 @@ const Logs = ({ - - {isSuccess && ( - <> - {parsedLogs} -
- - )} - + {!!parsedLogs && ( + + )} )} {externalLogName && externalIndexes.length > 0 && (