diff --git a/airflow/www/static/js/grid/Main.jsx b/airflow/www/static/js/grid/Main.tsx similarity index 95% rename from airflow/www/static/js/grid/Main.jsx rename to airflow/www/static/js/grid/Main.tsx index 5e9a4f2a9a12de..8779b547ec16b9 100644 --- a/airflow/www/static/js/grid/Main.jsx +++ b/airflow/www/static/js/grid/Main.tsx @@ -47,10 +47,10 @@ const Main = () => { const onPanelToggle = () => { if (!isOpen) { - localStorage.setItem(detailsPanelKey, false); + localStorage.setItem(detailsPanelKey, 'false'); } else { clearSelection(); - localStorage.setItem(detailsPanelKey, true); + localStorage.setItem(detailsPanelKey, 'true'); } onToggle(); }; diff --git a/airflow/www/static/js/grid/ToggleGroups.jsx b/airflow/www/static/js/grid/ToggleGroups.jsx index 3705d67d80241a..2f027cd6687768 100644 --- a/airflow/www/static/js/grid/ToggleGroups.jsx +++ b/airflow/www/static/js/grid/ToggleGroups.jsx @@ -34,15 +34,15 @@ const getGroupIds = (groups) => { }; const ToggleGroups = ({ groups, openGroupIds, onToggleGroups }) => { + // Don't show button if the DAG has no task groups + const hasGroups = groups.children && groups.children.find((c) => !!c.children); + if (!hasGroups) return null; + const allGroupIds = getGroupIds(groups.children); const isExpandDisabled = allGroupIds.length === openGroupIds.length; const isCollapseDisabled = !openGroupIds.length; - // Don't show button if the DAG has no task groups - const hasGroups = groups.children.find((c) => !!c.children); - if (!hasGroups) return null; - const onExpand = () => { onToggleGroups(allGroupIds); }; diff --git a/airflow/www/static/js/grid/api/index.js b/airflow/www/static/js/grid/api/index.ts similarity index 93% rename from airflow/www/static/js/grid/api/index.js rename to airflow/www/static/js/grid/api/index.ts index 3487ecd6eaff00..0ac8e4e28410df 100644 --- a/airflow/www/static/js/grid/api/index.js +++ b/airflow/www/static/js/grid/api/index.ts @@ -17,7 +17,7 @@ * under the License. */ -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import camelcaseKeys from 'camelcase-keys'; import useTasks from './useTasks'; @@ -35,7 +35,7 @@ import useGridData from './useGridData'; import useMappedInstances from './useMappedInstances'; axios.interceptors.response.use( - (res) => (res.data ? camelcaseKeys(res.data, { deep: true }) : res), + (res: AxiosResponse) => (res.data ? camelcaseKeys(res.data, { deep: true }) : res), ); axios.defaults.headers.common.Accept = 'application/json'; diff --git a/airflow/www/static/js/grid/api/useGridData.js b/airflow/www/static/js/grid/api/useGridData.ts similarity index 64% rename from airflow/www/static/js/grid/api/useGridData.js rename to airflow/www/static/js/grid/api/useGridData.ts index 38d4e00748d32f..ec12ee6d601dde 100644 --- a/airflow/www/static/js/grid/api/useGridData.js +++ b/airflow/www/static/js/grid/api/useGridData.ts @@ -17,10 +17,8 @@ * under the License. */ -/* global autoRefreshInterval */ - import { useQuery } from 'react-query'; -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { getMetaValue } from '../../utils'; import { useAutoRefresh } from '../context/autorefresh'; @@ -28,6 +26,7 @@ import useErrorToast from '../utils/useErrorToast'; import useFilters, { BASE_DATE_PARAM, NUM_RUNS_PARAM, RUN_STATE_PARAM, RUN_TYPE_PARAM, now, } from '../utils/useFilters'; +import type { Task, DagRun } from '../types'; const DAG_ID_PARAM = 'dag_id'; @@ -36,12 +35,21 @@ const dagId = getMetaValue(DAG_ID_PARAM); const gridDataUrl = getMetaValue('grid_data_url') || ''; const urlRoot = getMetaValue('root'); -const emptyData = { +interface GridData { + dagRuns: DagRun[]; + groups: Task; +} + +const emptyGridData: GridData = { dagRuns: [], - groups: {}, + groups: { + id: null, + label: null, + instances: [], + }, }; -export const areActiveRuns = (runs = []) => runs.filter((run) => ['queued', 'running', 'scheduled'].includes(run.state)).length > 0; +export const areActiveRuns = (runs: DagRun[] = []) => runs.filter((run) => ['queued', 'running', 'scheduled'].includes(run.state)).length > 0; const useGridData = () => { const { isRefreshOn, stopRefresh } = useAutoRefresh(); @@ -52,8 +60,9 @@ const useGridData = () => { }, } = useFilters(); - return useQuery(['gridData', baseDate, numRuns, runType, runState], async () => { - try { + const query = useQuery( + ['gridData', baseDate, numRuns, runType, runState], + async () => { const params = { root: urlRoot || undefined, [DAG_ID_PARAM]: dagId, @@ -62,24 +71,29 @@ const useGridData = () => { [RUN_TYPE_PARAM]: runType, [RUN_STATE_PARAM]: runState, }; - const newData = await axios.get(gridDataUrl, { params }); + const response = await axios.get(gridDataUrl, { params }); // turn off auto refresh if there are no active runs - if (!areActiveRuns(newData.dagRuns)) stopRefresh(); - return newData; - } catch (error) { - stopRefresh(); - errorToast({ - title: 'Auto-refresh Error', - error, - }); - throw (error); - } - }, { - placeholderData: emptyData, - // only refetch if the refresh switch is on - refetchInterval: isRefreshOn && autoRefreshInterval * 1000, - keepPreviousData: true, - }); + if (!areActiveRuns(response.dagRuns)) stopRefresh(); + return response; + }, + { + // only refetch if the refresh switch is on + refetchInterval: isRefreshOn && (autoRefreshInterval || 1) * 1000, + keepPreviousData: true, + onError: (error) => { + stopRefresh(); + errorToast({ + title: 'Auto-refresh Error', + error, + }); + throw (error); + }, + }, + ); + return { + ...query, + data: query.data ?? emptyGridData, + }; }; export default useGridData; diff --git a/airflow/www/static/js/grid/api/useTasks.js b/airflow/www/static/js/grid/api/useTasks.ts similarity index 77% rename from airflow/www/static/js/grid/api/useTasks.js rename to airflow/www/static/js/grid/api/useTasks.ts index c214444dcb81ee..68878a78a006a5 100644 --- a/airflow/www/static/js/grid/api/useTasks.js +++ b/airflow/www/static/js/grid/api/useTasks.ts @@ -17,19 +17,25 @@ * under the License. */ -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { useQuery } from 'react-query'; import { getMetaValue } from '../../utils'; +interface TaskData { + tasks: any[]; + totalEntries: number; +} + export default function useTasks() { - return useQuery( + const query = useQuery( 'tasks', () => { const tasksUrl = getMetaValue('tasks_api'); - return axios.get(tasksUrl); - }, - { - initialData: { tasks: [], totalEntries: 0 }, + return axios.get(tasksUrl || ''); }, ); + return { + ...query, + data: query.data || { tasks: [], totalEntries: 0 }, + }; } diff --git a/airflow/www/static/js/grid/components/InstanceTooltip.test.jsx b/airflow/www/static/js/grid/components/InstanceTooltip.test.tsx similarity index 80% rename from airflow/www/static/js/grid/components/InstanceTooltip.test.jsx rename to airflow/www/static/js/grid/components/InstanceTooltip.test.tsx index eb1abe8ba4a794..71e1147da94538 100644 --- a/airflow/www/static/js/grid/components/InstanceTooltip.test.jsx +++ b/airflow/www/static/js/grid/components/InstanceTooltip.test.tsx @@ -24,19 +24,21 @@ import { render } from '@testing-library/react'; import InstanceTooltip from './InstanceTooltip'; import { Wrapper } from '../utils/testUtils'; +import type { TaskState } from '../types'; const instance = { - startDate: new Date(), - endDate: new Date(), - state: 'success', + startDate: new Date().toISOString(), + endDate: new Date().toISOString(), + state: 'success' as TaskState, runId: 'run', + taskId: 'task', }; describe('Test Task InstanceTooltip', () => { test('Displays a normal task', () => { const { getByText } = render( , { wrapper: Wrapper }, @@ -48,7 +50,9 @@ describe('Test Task InstanceTooltip', () => { test('Displays a mapped task with overall status', () => { const { getByText } = render( , { wrapper: Wrapper }, @@ -63,12 +67,20 @@ describe('Test Task InstanceTooltip', () => { const { getByText, queryByText } = render( { +}: Props) => { + if (!group) return null; const isGroup = !!group.children; - const { isMapped } = group; - const summary = []; + const summary: React.ReactNode[] = []; + + const isMapped = group?.isMapped; const numMap = finalStatesMap(); let numMapped = 0; - if (isGroup) { + if (isGroup && group.children) { group.children.forEach((child) => { const taskInstance = child.instances.find((ti) => ti.runId === runId); if (taskInstance) { const stateKey = taskInstance.state == null ? 'no_status' : taskInstance.state; - if (numMap.has(stateKey)) numMap.set(stateKey, numMap.get(stateKey) + 1); + if (numMap.has(stateKey)) numMap.set(stateKey, (numMap.get(stateKey) || 0) + 1); } }); } else if (isMapped && mappedStates) { @@ -88,7 +96,7 @@ const InstanceTooltip = ({ Started: {' '} - Duration: diff --git a/airflow/www/static/js/grid/components/StatusBox.jsx b/airflow/www/static/js/grid/components/StatusBox.tsx similarity index 71% rename from airflow/www/static/js/grid/components/StatusBox.jsx rename to airflow/www/static/js/grid/components/StatusBox.tsx index 2f079949a9fb80..f9acf57402ee17 100644 --- a/airflow/www/static/js/grid/components/StatusBox.jsx +++ b/airflow/www/static/js/grid/components/StatusBox.tsx @@ -17,36 +17,48 @@ * under the License. */ -/* global stateColors */ - import React from 'react'; import { isEqual } from 'lodash'; import { Box, useTheme, + BoxProps, } from '@chakra-ui/react'; import Tooltip from './Tooltip'; import InstanceTooltip from './InstanceTooltip'; import { useContainerRef } from '../context/containerRef'; +import type { Task, TaskInstance, TaskState } from '../types'; +import type { SelectionProps } from '../utils/useSelection'; export const boxSize = 10; export const boxSizePx = `${boxSize}px`; -export const SimpleStatus = ({ state, ...rest }) => ( +interface SimpleStatusProps extends BoxProps { + state: TaskState; +} + +export const SimpleStatus = ({ state, ...rest }: SimpleStatusProps) => ( ); +interface Props { + group: Task; + instance: TaskInstance; + onSelect: (selection: SelectionProps) => void; + isActive: boolean; +} + const StatusBox = ({ group, instance, onSelect, isActive, -}) => { +}: Props) => { const containerRef = useContainerRef(); const { runId, taskId } = instance; const { colors } = useTheme(); @@ -54,15 +66,19 @@ const StatusBox = ({ // Fetch the corresponding column element and set its background color when hovering const onMouseEnter = () => { - [...containerRef.current.getElementsByClassName(`js-${runId}`)] - .forEach((e) => { - // Don't apply hover if it is already selected - if (e.getAttribute('data-selected') === 'false') e.style.backgroundColor = hoverBlue; - }); + if (containerRef && containerRef.current) { + ([...containerRef.current.getElementsByClassName(`js-${runId}`)] as HTMLElement[]) + .forEach((e) => { + // Don't apply hover if it is already selected + if (e.getAttribute('data-selected') === 'false') e.style.backgroundColor = hoverBlue; + }); + } }; const onMouseLeave = () => { - [...containerRef.current.getElementsByClassName(`js-${runId}`)] - .forEach((e) => { e.style.backgroundColor = null; }); + if (containerRef && containerRef.current) { + ([...containerRef.current.getElementsByClassName(`js-${runId}`)] as HTMLElement[]) + .forEach((e) => { e.style.backgroundColor = ''; }); + } }; const onClick = () => { @@ -97,8 +113,8 @@ const StatusBox = ({ // The default equality function is a shallow comparison and json objects will return false // This custom compare function allows us to do a deeper comparison const compareProps = ( - prevProps, - nextProps, + prevProps: Props, + nextProps: Props, ) => ( isEqual(prevProps.group, nextProps.group) && isEqual(prevProps.instance, nextProps.instance) diff --git a/airflow/www/static/js/grid/components/Time.tsx b/airflow/www/static/js/grid/components/Time.tsx index 5712d163c98775..fbc0b16e792211 100644 --- a/airflow/www/static/js/grid/components/Time.tsx +++ b/airflow/www/static/js/grid/components/Time.tsx @@ -27,7 +27,7 @@ interface Props { format?: string; } -const Time: React.FC = ({ dateTime, format = defaultFormatWithTZ }) => { +const Time = ({ dateTime, format = defaultFormatWithTZ }: Props) => { const { timezone } = useTimezone(); const time = moment(dateTime); diff --git a/airflow/www/static/js/grid/context/autorefresh.jsx b/airflow/www/static/js/grid/context/autorefresh.jsx index 35df9b7daf9200..11c987fe34493f 100644 --- a/airflow/www/static/js/grid/context/autorefresh.jsx +++ b/airflow/www/static/js/grid/context/autorefresh.jsx @@ -29,7 +29,13 @@ const autoRefreshKey = 'disabledAutoRefresh'; const initialIsPaused = getMetaValue('is_paused') === 'True'; const isRefreshDisabled = JSON.parse(localStorage.getItem(autoRefreshKey)); -const AutoRefreshContext = React.createContext(null); +const AutoRefreshContext = React.createContext({ + isRefreshOn: false, + isPaused: true, + toggleRefresh: () => {}, + stopRefresh: () => {}, + startRefresh: () => {}, +}); export const AutoRefreshProvider = ({ children }) => { const [isPaused, setIsPaused] = useState(initialIsPaused); diff --git a/airflow/www/static/js/grid/context/containerRef.jsx b/airflow/www/static/js/grid/context/containerRef.tsx similarity index 80% rename from airflow/www/static/js/grid/context/containerRef.jsx rename to airflow/www/static/js/grid/context/containerRef.tsx index 9062f907ede00c..4ddc03642880c7 100644 --- a/airflow/www/static/js/grid/context/containerRef.jsx +++ b/airflow/www/static/js/grid/context/containerRef.tsx @@ -19,12 +19,17 @@ import React, { useContext, useRef } from 'react'; -const ContainerRefContext = React.createContext(null); +// eslint-disable-next-line max-len +const ContainerRefContext = React.createContext | undefined>(undefined); + +interface Props { + children: React.ReactNode; +} // containerRef is necessary to render for tooltips, modals, and dialogs // This provider allows the containerRef to be accessed by any react component -export const ContainerRefProvider = ({ children }) => { - const containerRef = useRef(); +export const ContainerRefProvider = ({ children }: Props) => { + const containerRef = useRef(null); return ( diff --git a/airflow/www/static/js/grid/dagRuns/index.jsx b/airflow/www/static/js/grid/dagRuns/index.tsx similarity index 94% rename from airflow/www/static/js/grid/dagRuns/index.jsx rename to airflow/www/static/js/grid/dagRuns/index.tsx index ec588313c6150f..679b7db3a5ecc6 100644 --- a/airflow/www/static/js/grid/dagRuns/index.jsx +++ b/airflow/www/static/js/grid/dagRuns/index.tsx @@ -24,14 +24,16 @@ import { Text, Box, Flex, + TextProps, } from '@chakra-ui/react'; import { useGridData } from '../api'; import DagRunBar from './Bar'; import { getDuration, formatDuration } from '../../datetime_utils'; import useSelection from '../utils/useSelection'; +import type { DagRun } from '../types'; -const DurationTick = ({ children, ...rest }) => ( +const DurationTick = ({ children, ...rest }: TextProps) => ( {children} @@ -40,7 +42,7 @@ const DurationTick = ({ children, ...rest }) => ( const DagRuns = () => { const { data: { dagRuns } } = useGridData(); const { selected, onSelect } = useSelection(); - const durations = []; + const durations: number[] = []; const runs = dagRuns.map((dagRun) => { const duration = getDuration(dagRun.startDate, dagRun.endDate); durations.push(duration); @@ -91,7 +93,7 @@ const DagRuns = () => { - {runs.map((run, i) => ( + {runs.map((run: DagRun, i: number) => ( ( + + {label} + {value} + +); + +export default BreadcrumbText; diff --git a/airflow/www/static/js/grid/details/Header.jsx b/airflow/www/static/js/grid/details/Header.tsx similarity index 86% rename from airflow/www/static/js/grid/details/Header.jsx rename to airflow/www/static/js/grid/details/Header.tsx index c158deabcaa59e..db6a75b993f834 100644 --- a/airflow/www/static/js/grid/details/Header.jsx +++ b/airflow/www/static/js/grid/details/Header.tsx @@ -22,8 +22,6 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, - Box, - Heading, Text, } from '@chakra-ui/react'; import { MdPlayArrow, MdOutlineSchedule } from 'react-icons/md'; @@ -33,20 +31,15 @@ import { getMetaValue } from '../../utils'; import useSelection from '../utils/useSelection'; import Time from '../components/Time'; import { useTasks, useGridData } from '../api'; +import BreadcrumbText from './BreadcrumbText'; const dagId = getMetaValue('dag_id'); -const LabelValue = ({ label, value }) => ( - - {label} - {value} - -); - const Header = () => { const { data: { dagRuns } } = useGridData(); - const { selected: { taskId, runId }, onSelect, clearSelection } = useSelection(); const { data: { tasks } } = useTasks(); + + const { selected: { taskId, runId }, onSelect, clearSelection } = useSelection(); const dagRun = dagRuns.find((r) => r.runId === runId); const task = tasks.find((t) => t.taskId === taskId); @@ -59,7 +52,7 @@ const Header = () => { }, [clearSelection, dagRun, runId]); let runLabel; - if (dagRun) { + if (dagRun && runId) { if (runId.includes('manual__') || runId.includes('scheduled__') || runId.includes('backfill__')) { runLabel = (}> - + {runId && ( onSelect({ runId })} _hover={isRunDetails ? { cursor: 'default' } : undefined}> - + )} {taskId && ( - + )} diff --git a/airflow/www/static/js/grid/details/content/taskInstance/index.jsx b/airflow/www/static/js/grid/details/content/taskInstance/index.tsx similarity index 87% rename from airflow/www/static/js/grid/details/content/taskInstance/index.jsx rename to airflow/www/static/js/grid/details/content/taskInstance/index.tsx index 7be2387ba569a0..bbb262eedae6ef 100644 --- a/airflow/www/static/js/grid/details/content/taskInstance/index.jsx +++ b/airflow/www/static/js/grid/details/content/taskInstance/index.tsx @@ -45,12 +45,22 @@ import Details from './Details'; import { useGridData, useTasks } from '../../../api'; import MappedInstances from './MappedInstances'; import { getMetaValue } from '../../../../utils'; +import type { Task, DagRun } from '../../../types'; const detailsPanelActiveTabIndex = 'detailsPanelActiveTabIndex'; const dagId = getMetaValue('dag_id'); -const getTask = ({ taskId, runId, task }) => { +interface Props { + taskId: Task['id']; + runId: DagRun['runId']; +} + +interface GetTaskProps extends Props { + task: Task; +} + +const getTask = ({ taskId, runId, task }: GetTaskProps) => { if (task.id === taskId) return task; if (task.children) { let foundTask; @@ -63,10 +73,10 @@ const getTask = ({ taskId, runId, task }) => { return null; }; -const TaskInstance = ({ taskId, runId }) => { +const TaskInstance = ({ taskId, runId }: Props) => { const [selectedRows, setSelectedRows] = useState([]); - const { data: { groups, dagRuns } } = useGridData(); - const { data: { tasks } } = useTasks(dagId); + const { data: { dagRuns, groups } } = useGridData(); + const { data: { tasks } } = useTasks(); const storageTabIndex = parseInt(localStorage.getItem(detailsPanelActiveTabIndex) || '0', 10); const [preferedTabIndex, setPreferedTabIndex] = useState(storageTabIndex); @@ -74,13 +84,13 @@ const TaskInstance = ({ taskId, runId }) => { const group = getTask({ taskId, runId, task: groups }); const run = dagRuns.find((r) => r.runId === runId); - const handleTabsChange = (index) => { - localStorage.setItem(detailsPanelActiveTabIndex, index); + const handleTabsChange = (index: number) => { + localStorage.setItem(detailsPanelActiveTabIndex, index.toString()); setPreferedTabIndex(index); }; - const { isMapped, extraLinks } = group; - const isGroup = !!group.children; + const isGroup = !!group?.children; + const isMapped = !!group?.isMapped; const isSimpleTask = !isMapped && !isGroup; @@ -102,8 +112,8 @@ const TaskInstance = ({ taskId, runId }) => { if (!group || !run) return null; const { executionDate } = run; - const task = tasks.find((t) => t.taskId === taskId); - const operator = task && task.classRef && task.classRef.className ? task.classRef.className : ''; + const task: any = tasks.find((t: any) => t.taskId === taskId); + const operator = (task?.classRef && task?.classRef?.className) ?? ''; const instance = group.instances.find((ti) => ti.runId === runId); @@ -128,16 +138,13 @@ const TaskInstance = ({ taskId, runId }) => { Details - { isSimpleTask && ( Logs )} - - {/* Details Tab */} @@ -180,7 +187,7 @@ const TaskInstance = ({ taskId, runId }) => { taskId={taskId} dagId={dagId} executionDate={executionDate} - extraLinks={extraLinks} + extraLinks={group?.extraLinks || []} /> {isMapped && ( { )} - {/* Logs Tab */} { isSimpleTask && ( @@ -201,9 +207,8 @@ const TaskInstance = ({ taskId, runId }) => { dagRunId={runId} taskId={taskId} executionDate={executionDate} - tryNumber={instance.tryNumber} + tryNumber={instance?.tryNumber} /> - )} diff --git a/airflow/www/static/js/grid/details/index.jsx b/airflow/www/static/js/grid/details/index.tsx similarity index 82% rename from airflow/www/static/js/grid/details/index.jsx rename to airflow/www/static/js/grid/details/index.tsx index a5a3a57e8bfd08..4c80fd65599249 100644 --- a/airflow/www/static/js/grid/details/index.jsx +++ b/airflow/www/static/js/grid/details/index.tsx @@ -31,20 +31,20 @@ import DagContent from './content/Dag'; import useSelection from '../utils/useSelection'; const Details = () => { - const { selected } = useSelection(); + const { selected: { runId, taskId } } = useSelection(); return (
- {!selected.runId && !selected.taskId && } - {selected.runId && !selected.taskId && ( - + {!runId && !taskId && } + {runId && !taskId && ( + )} - {selected.taskId && ( + {taskId && runId && ( )} diff --git a/airflow/www/static/js/grid/index.d.ts b/airflow/www/static/js/grid/index.d.ts new file mode 100644 index 00000000000000..174b4e5e510a4b --- /dev/null +++ b/airflow/www/static/js/grid/index.d.ts @@ -0,0 +1,28 @@ +/*! + * 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. + */ + +// define global variables that come from FAB +declare global { + const autoRefreshInterval: number | undefined; + const stateColors: { + [key: string]: string; + }; +} + +export {}; diff --git a/airflow/www/static/js/grid/renderTaskRows.test.jsx b/airflow/www/static/js/grid/renderTaskRows.test.tsx similarity index 87% rename from airflow/www/static/js/grid/renderTaskRows.test.jsx rename to airflow/www/static/js/grid/renderTaskRows.test.tsx index 7b1596512a8a48..4f5fabc9209a9a 100644 --- a/airflow/www/static/js/grid/renderTaskRows.test.jsx +++ b/airflow/www/static/js/grid/renderTaskRows.test.tsx @@ -24,10 +24,11 @@ import { render } from '@testing-library/react'; import renderTaskRows from './renderTaskRows'; import { TableWrapper } from './utils/testUtils'; +import type { Task } from './types'; describe('Test renderTaskRows', () => { test('Renders name and task instance', () => { - const task = { + const task: Task = { id: null, label: null, children: [ @@ -37,16 +38,11 @@ describe('Test renderTaskRows', () => { label: 'group_1', instances: [ { - dagId: 'dagId', - duration: 0, endDate: '2021-10-26T15:42:03.391939+00:00', - executionDate: '2021-10-25T15:41:09.726436+00:00', - operator: 'DummyOperator', runId: 'run1', startDate: '2021-10-26T15:42:03.391917+00:00', state: 'success', taskId: 'group_1', - tryNumber: 1, }, ], children: [ @@ -56,16 +52,11 @@ describe('Test renderTaskRows', () => { extraLinks: [], instances: [ { - dagId: 'dagId', - duration: 0, endDate: '2021-10-26T15:42:03.391939+00:00', - executionDate: '2021-10-25T15:41:09.726436+00:00', - operator: 'DummyOperator', runId: 'run1', startDate: '2021-10-26T15:42:03.391917+00:00', state: 'success', taskId: 'group_1.task_1', - tryNumber: 1, }, ], }, @@ -110,7 +101,7 @@ describe('Test renderTaskRows', () => { }); test('Still renders correctly if task instance is null', () => { - const task = { + const task: Task = { id: null, label: null, children: [ @@ -118,18 +109,18 @@ describe('Test renderTaskRows', () => { extraLinks: [], id: 'group_1', label: 'group_1', - instances: [null], + instances: [], children: [ { id: 'group_1.task_1', label: 'group_1.task_1', extraLinks: [], - instances: [null], + instances: [], }, ], }, ], - instances: [null], + instances: [], }; const { queryByTestId, getByText } = render( diff --git a/airflow/www/static/js/grid/renderTaskRows.jsx b/airflow/www/static/js/grid/renderTaskRows.tsx similarity index 75% rename from airflow/www/static/js/grid/renderTaskRows.jsx rename to airflow/www/static/js/grid/renderTaskRows.tsx index 87f93d904eae7b..c7f82333f4b4f7 100644 --- a/airflow/www/static/js/grid/renderTaskRows.jsx +++ b/airflow/www/static/js/grid/renderTaskRows.tsx @@ -30,30 +30,54 @@ import { import StatusBox, { boxSize, boxSizePx } from './components/StatusBox'; import TaskName from './components/TaskName'; -import useSelection from './utils/useSelection'; +import useSelection, { SelectionProps } from './utils/useSelection'; +import type { Task, DagRun } from './types'; const boxPadding = 3; const boxPaddingPx = `${boxPadding}px`; const columnWidth = boxSize + 2 * boxPadding; +interface RowProps { + task: Task; + dagRunIds: DagRun['runId'][]; + level?: number; + openParentCount?: number; + openGroupIds?: string[]; + onToggleGroups?: (groupIds: string[]) => void; + hoveredTaskState?: string; +} + const renderTaskRows = ({ task, level = 0, ...rest -}) => task.children && task.children.map((t) => ( - -)); +}: RowProps) => ( + <> + {(task?.children || []).map((t) => ( + // eslint-disable-next-line @typescript-eslint/no-use-before-define + + ))} + +); + +interface TaskInstancesProps { + task: Task; + dagRunIds: string[]; + selectedRunId?: string | null; + onSelect: (selection: SelectionProps) => void; + hoveredTaskState?: string; +} const TaskInstances = ({ - task, dagRunIds, selectedRunId, onSelect, activeTaskState, -}) => ( + task, dagRunIds, selectedRunId, onSelect, hoveredTaskState, +}: TaskInstancesProps) => ( - {dagRunIds.map((runId) => { + {dagRunIds.map((runId: string) => { // Check if an instance exists for the run, or return an empty box - const instance = task.instances.find((gi) => gi && gi.runId === runId); + const instance = task.instances.find((ti) => ti && ti.runId === runId); const isSelected = selectedRunId === runId; return ( {instance ? ( @@ -71,7 +95,7 @@ const TaskInstances = ({ instance={instance} group={task} onSelect={onSelect} - isActive={activeTaskState === undefined || activeTaskState === instance.state} + isActive={hoveredTaskState === undefined || hoveredTaskState === instance.state} /> ) : } @@ -81,10 +105,10 @@ const TaskInstances = ({ ); -const Row = (props) => { +const Row = (props: RowProps) => { const { task, - level, + level = 0, dagRunIds, openParentCount = 0, openGroupIds = [], @@ -103,7 +127,7 @@ const Row = (props) => { // assure the function is the same across renders const memoizedToggle = useCallback( () => { - if (isGroup) { + if (isGroup && task.label) { let newGroupIds = []; if (!isOpen) { newGroupIds = [...openGroupIds, task.label]; @@ -121,16 +145,16 @@ const Row = (props) => { return ( <> { task={task} selectedRunId={selected.runId} onSelect={onSelect} - activeTaskState={hoveredTaskState} + hoveredTaskState={hoveredTaskState} /> diff --git a/airflow/www/static/js/grid/types/index.ts b/airflow/www/static/js/grid/types/index.ts new file mode 100644 index 00000000000000..df1f873ef79c37 --- /dev/null +++ b/airflow/www/static/js/grid/types/index.ts @@ -0,0 +1,75 @@ +/*! + * 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. + */ + +type RunState = 'success' | 'running' | 'queued' | 'failed'; + +type TaskState = RunState +| 'removed' +| 'scheduled' +| 'shutdown' +| 'restarting' +| 'up_for_retry' +| 'up_for_reschedule' +| 'upstream_failed' +| 'skipped' +| 'sensing' +| 'deferred' +| null; + +interface DagRun { + runId: string; + runType: 'manual' | 'backfill' | 'scheduled'; + state: RunState; + executionDate: string; + dataIntervalStart: string; + dataIntervalEnd: string; + startDate: string | null; + endDate: string | null; + lastSchedulingDecision: string | null; +} + +interface TaskInstance { + runId: string; + taskId: string; + startDate: string | null; + endDate: string | null; + state: TaskState | null; + mappedStates?: { + [key: string]: number; + }, + tryNumber?: number; +} + +interface Task { + id: string | null; + label: string | null; + instances: TaskInstance[]; + tooltip?: string; + children?: Task[]; + extraLinks?: string[]; + isMapped?: boolean; +} + +export type { + DagRun, + RunState, + TaskState, + TaskInstance, + Task, +}; diff --git a/airflow/www/static/js/grid/utils/useSelection.test.jsx b/airflow/www/static/js/grid/utils/useSelection.test.tsx similarity index 94% rename from airflow/www/static/js/grid/utils/useSelection.test.jsx rename to airflow/www/static/js/grid/utils/useSelection.test.tsx index 2d2eeeb4db7421..19871c3ff098a1 100644 --- a/airflow/www/static/js/grid/utils/useSelection.test.jsx +++ b/airflow/www/static/js/grid/utils/useSelection.test.tsx @@ -25,7 +25,11 @@ import { MemoryRouter } from 'react-router-dom'; import useSelection from './useSelection'; -const Wrapper = ({ children }) => ( +interface Props { + children: React.ReactNode; +} + +const Wrapper = ({ children }: Props) => ( {children} @@ -47,7 +51,7 @@ describe('Test useSelection hook', () => { test.each([ { taskId: 'task_1', runId: 'run_1' }, - { taskId: null, runId: 'run_1' }, + { runId: 'run_1', taskId: null }, { taskId: 'task_1', runId: null }, ])('Test onSelect() and clearSelection()', async (selected) => { const { result } = renderHook(() => useSelection(), { wrapper: Wrapper }); diff --git a/airflow/www/static/js/grid/utils/useSelection.js b/airflow/www/static/js/grid/utils/useSelection.ts similarity index 91% rename from airflow/www/static/js/grid/utils/useSelection.js rename to airflow/www/static/js/grid/utils/useSelection.ts index c90578837a2e16..c4f5290b110a46 100644 --- a/airflow/www/static/js/grid/utils/useSelection.js +++ b/airflow/www/static/js/grid/utils/useSelection.ts @@ -22,6 +22,11 @@ import { useSearchParams } from 'react-router-dom'; const RUN_ID = 'dag_run_id'; const TASK_ID = 'task_id'; +export interface SelectionProps { + runId?: string | null ; + taskId?: string | null; +} + const useSelection = () => { const [searchParams, setSearchParams] = useSearchParams(); @@ -32,7 +37,7 @@ const useSelection = () => { setSearchParams(searchParams); }; - const onSelect = ({ runId, taskId }) => { + const onSelect = ({ runId, taskId }: SelectionProps) => { const params = new URLSearchParams(searchParams); if (runId) params.set(RUN_ID, runId); diff --git a/airflow/www/tsconfig.json b/airflow/www/tsconfig.json index 5717d624e36b0a..f264a9e3cca57a 100644 --- a/airflow/www/tsconfig.json +++ b/airflow/www/tsconfig.json @@ -25,8 +25,8 @@ "strict": true, "allowJs": true, "importsNotUsedAsValues": "error", - "target": "esnext", - "module": "esnext", + "target": "ES6", + "module": "ES6", "moduleResolution": "node", "isolatedModules": true, "esModuleInterop": true, diff --git a/airflow/www/webpack.config.js b/airflow/www/webpack.config.js index e48b01c687736c..b4019c32386762 100644 --- a/airflow/www/webpack.config.js +++ b/airflow/www/webpack.config.js @@ -106,7 +106,7 @@ const config = { ], }, { - test: /\.[j|t]sx?$/, + test: /\.(js|jsx|tsx|ts)$/, exclude: /node_modules/, use: [ {