This is an automated email from the ASF dual-hosted git repository.

bbovenzi 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 5c80d985a3 Maintain manual scroll position in task logs (#28386)
5c80d985a3 is described below

commit 5c80d985a3102a46f198aec1c57a255e00784c51
Author: Brent Bovenzi <br...@astronomer.io>
AuthorDate: Sun Dec 18 19:00:34 2022 -0600

    Maintain manual scroll position in task logs (#28386)
---
 airflow/www/static/js/api/useTaskLog.ts            | 20 ++++-
 .../js/dag/details/taskInstance/Logs/LogBlock.tsx  | 90 ++++++++++++++++++++++
 .../js/dag/details/taskInstance/Logs/index.tsx     | 45 +++--------
 3 files changed, 117 insertions(+), 38 deletions(-)

diff --git a/airflow/www/static/js/api/useTaskLog.ts 
b/airflow/www/static/js/api/useTaskLog.ts
index 580c5e0ab4..bbb6395878 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<AxiosResponse, string>(url, { headers: { Accept: 
'text/plain' }, params: { map_index: mapIndex, full_content: fullContent } }),
+    ['taskLogs', dagId, dagRunId, taskId, mapIndex, taskTryNumber, 
fullContent],
+    () => {
+      setPrevState(isStatePending);
+      return axios.get<AxiosResponse, string>(
+        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 0000000000..0ffa76e21f
--- /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<HTMLPreElement>(null);
+
+  const maxHeight = useOffsetHeight(logBoxRef, parsedLogs);
+
+  const codeBlockBottomDiv = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
+    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 (
+    <Code
+      ref={logBoxRef}
+      onScroll={onScroll}
+      height="100%"
+      maxHeight={maxHeight}
+      overflowY="auto"
+      p={3}
+      pb={0}
+      display="block"
+      whiteSpace={wrap ? 'pre-wrap' : 'pre'}
+      border="1px solid"
+      borderRadius={3}
+      borderColor="blue.500"
+    >
+      {parsedLogs}
+      <div ref={codeBlockBottomDiv} />
+    </Code>
+  );
+};
+
+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 43976a7b52..0139d8e223 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<Array<LogLevelOption>>([]);
   const [fileSourceFilters, setFileSourceFilters] = 
useState<Array<FileSourceOption>>([]);
   const { timezone } = useTimezone();
-  const logBoxRef = useRef<HTMLPreElement>(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<HTMLDivElement>(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 = ({
               </Flex>
             </Flex>
           </Box>
-          <Code
-            ref={logBoxRef}
-            height="100%"
-            maxHeight={offsetHeight}
-            overflowY="auto"
-            p={3}
-            pb={0}
-            display="block"
-            whiteSpace={wrap ? 'pre-wrap' : 'pre'}
-            border="1px solid"
-            borderRadius={3}
-            borderColor="blue.500"
-          >
-            {isSuccess && (
-              <>
-                {parsedLogs}
-                <div ref={codeBlockBottomDiv} />
-              </>
-            )}
-          </Code>
+          {!!parsedLogs && (
+            <LogBlock
+              parsedLogs={parsedLogs}
+              wrap={wrap}
+              tryNumber={taskTryNumber}
+            />
+          )}
         </>
       )}
       {externalLogName && externalIndexes.length > 0 && (

Reply via email to