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 46c1c006f7 19943 Grid view status filters (#23392)
46c1c006f7 is described below
commit 46c1c006f747798f7c80f615d791553f25dd1770
Author: pierrejeambrun <[email protected]>
AuthorDate: Mon May 9 22:32:02 2022 +0200
19943 Grid view status filters (#23392)
* Move tree filtering inside react and add some filters
* Move filters from context to utils
* Fix tests for useTreeData
* Fix last tests.
* Add tests for useFilters
* Refact to use existing SimpleStatus component
* Additional fix after rebase.
* Update following bbovenzi code review
* Update following code review
* Fix tests.
* Fix page flickering issues from react-query
* Fix side panel and small changes.
* Use default_dag_run_display_number in the filter options
* Handle timezone
* Fix flaky test
Co-authored-by: Brent Bovenzi <[email protected]>
---
airflow/settings.py | 2 +-
airflow/utils/state.py | 2 +-
airflow/www/jest-setup.js | 6 +-
airflow/www/static/js/datetime_utils.js | 1 +
airflow/www/static/js/grid/FilterBar.jsx | 127 +++++++++++++++++++++
airflow/www/static/js/grid/Grid.jsx | 14 ++-
.../js/grid/LegendRow.jsx} | 38 +++---
airflow/www/static/js/grid/StatusBox.jsx | 3 +
airflow/www/static/js/grid/api/useGridData.js | 41 ++++---
.../www/static/js/grid/api/useGridData.test.jsx | 4 +-
airflow/www/static/js/grid/dagRuns/index.test.jsx | 31 ++++-
airflow/www/static/js/grid/details/Header.jsx | 10 +-
.../js/grid/details/content/taskInstance/index.jsx | 7 +-
airflow/www/static/js/grid/index.jsx | 1 -
airflow/www/static/js/grid/renderTaskRows.jsx | 2 +-
airflow/www/static/js/grid/utils/testUtils.jsx | 6 +
airflow/www/static/js/grid/utils/useFilters.js | 93 +++++++++++++++
.../www/static/js/grid/utils/useFilters.test.jsx | 80 +++++++++++++
.../utils/{useSelection.jsx => useSelection.js} | 0
airflow/www/templates/airflow/grid.html | 43 +------
airflow/www/views.py | 36 +++++-
tests/www/views/test_views_home.py | 2 +-
22 files changed, 451 insertions(+), 98 deletions(-)
diff --git a/airflow/settings.py b/airflow/settings.py
index 86b22fab93..5693575ed9 100644
--- a/airflow/settings.py
+++ b/airflow/settings.py
@@ -95,7 +95,7 @@ STATE_COLORS = {
"up_for_retry": "gold",
"up_for_reschedule": "turquoise",
"upstream_failed": "orange",
- "skipped": "pink",
+ "skipped": "hotpink",
"scheduled": "tan",
"deferred": "mediumpurple",
}
diff --git a/airflow/utils/state.py b/airflow/utils/state.py
index e9b3610c9d..8415dd1666 100644
--- a/airflow/utils/state.py
+++ b/airflow/utils/state.py
@@ -120,7 +120,7 @@ class State:
TaskInstanceState.UP_FOR_RETRY: 'gold',
TaskInstanceState.UP_FOR_RESCHEDULE: 'turquoise',
TaskInstanceState.UPSTREAM_FAILED: 'orange',
- TaskInstanceState.SKIPPED: 'pink',
+ TaskInstanceState.SKIPPED: 'hotpink',
TaskInstanceState.REMOVED: 'lightgrey',
TaskInstanceState.SCHEDULED: 'tan',
TaskInstanceState.DEFERRED: 'mediumpurple',
diff --git a/airflow/www/jest-setup.js b/airflow/www/jest-setup.js
index b4605d3191..5ffa8890d5 100644
--- a/airflow/www/jest-setup.js
+++ b/airflow/www/jest-setup.js
@@ -21,16 +21,18 @@
import '@testing-library/jest-dom';
-// Mock a global object we use across the app
+// Mock global objects we use across the app
global.stateColors = {
deferred: 'mediumpurple',
failed: 'red',
queued: 'gray',
running: 'lime',
scheduled: 'tan',
- skipped: 'pink',
+ skipped: 'hotpink',
success: 'green',
up_for_reschedule: 'turquoise',
up_for_retry: 'gold',
upstream_failed: 'orange',
};
+
+global.defaultDagRunDisplayNumber = 245;
diff --git a/airflow/www/static/js/datetime_utils.js
b/airflow/www/static/js/datetime_utils.js
index 0601971114..3e1befa203 100644
--- a/airflow/www/static/js/datetime_utils.js
+++ b/airflow/www/static/js/datetime_utils.js
@@ -19,6 +19,7 @@
/* global moment, $, document */
export const defaultFormat = 'YYYY-MM-DD, HH:mm:ss';
+export const isoFormatWithoutTZ = 'YYYY-MM-DDTHH:mm:ss.SSS';
export const defaultFormatWithTZ = 'YYYY-MM-DD, HH:mm:ss z';
export const defaultTZFormat = 'z (Z)';
export const dateTimeAttrFormat = 'YYYY-MM-DDThh:mm:ssTZD';
diff --git a/airflow/www/static/js/grid/FilterBar.jsx
b/airflow/www/static/js/grid/FilterBar.jsx
new file mode 100644
index 0000000000..26d99f1d2e
--- /dev/null
+++ b/airflow/www/static/js/grid/FilterBar.jsx
@@ -0,0 +1,127 @@
+/*!
+ * 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.
+ */
+
+/* global filtersOptions, moment */
+
+import {
+ Box,
+ Button,
+ Flex,
+ Input,
+ Select,
+} from '@chakra-ui/react';
+import React from 'react';
+import { useTimezone } from './context/timezone';
+import { isoFormatWithoutTZ } from '../datetime_utils';
+
+import useFilters from './utils/useFilters';
+
+const FilterBar = () => {
+ const {
+ filters,
+ onBaseDateChange,
+ onNumRunsChange,
+ onRunTypeChange,
+ onRunStateChange,
+ onTaskStateChange,
+ clearFilters,
+ } = useFilters();
+
+ const { timezone } = useTimezone();
+ const time = moment(filters.baseDate);
+ const formattedTime = time.tz(timezone).format(isoFormatWithoutTZ);
+
+ const inputStyles = { backgroundColor: 'white', size: 'lg' };
+
+ return (
+ <Flex backgroundColor="#f0f0f0" mt={0} mb={2} p={4}>
+ <Box px={2}>
+ <Input
+ {...inputStyles}
+ type="datetime-local"
+ value={formattedTime || ''}
+ onChange={onBaseDateChange}
+ />
+ </Box>
+ <Box px={2}>
+ <Select
+ {...inputStyles}
+ placeholder="Runs"
+ value={filters.numRuns || ''}
+ onChange={onNumRunsChange}
+ >
+ {filtersOptions.numRuns.map((value) => (
+ <option value={value} key={value}>{value}</option>
+ ))}
+ </Select>
+ </Box>
+ <Box px={2}>
+ <Select
+ {...inputStyles}
+ value={filters.runType || ''}
+ onChange={onRunTypeChange}
+ >
+ <option value="" key="all">All Run Types</option>
+ {filtersOptions.runTypes.map((value) => (
+ <option value={value} key={value}>{value}</option>
+ ))}
+ </Select>
+ </Box>
+ <Box />
+ <Box px={2}>
+ <Select
+ {...inputStyles}
+ value={filters.runState || ''}
+ onChange={onRunStateChange}
+ >
+ <option value="" key="all">All Run States</option>
+ {filtersOptions.dagStates.map((value) => (
+ <option value={value} key={value}>{value}</option>
+ ))}
+ </Select>
+ </Box>
+ <Box px={2}>
+ <Select
+ {...inputStyles}
+ value={filters.taskState || ''}
+ onChange={onTaskStateChange}
+ >
+ <option value="" key="all">All Task States</option>
+ {filtersOptions.taskStates.map((value) => (
+ <option value={value} key={value}>{value}</option>
+ ))}
+ </Select>
+ </Box>
+ <Box px={2}>
+ <Button
+ colorScheme="cyan"
+ aria-label="Reset filters"
+ background="white"
+ variant="outline"
+ onClick={clearFilters}
+ size="lg"
+ >
+ Clear Filters
+ </Button>
+ </Box>
+ </Flex>
+ );
+};
+
+export default FilterBar;
diff --git a/airflow/www/static/js/grid/Grid.jsx
b/airflow/www/static/js/grid/Grid.jsx
index 72fc0f3e2b..79447ac683 100644
--- a/airflow/www/static/js/grid/Grid.jsx
+++ b/airflow/www/static/js/grid/Grid.jsx
@@ -32,6 +32,7 @@ import {
Flex,
useDisclosure,
Button,
+ Divider,
} from '@chakra-ui/react';
import { useGridData } from './api';
@@ -42,17 +43,21 @@ import Details from './details';
import useSelection from './utils/useSelection';
import { useAutoRefresh } from './context/autorefresh';
import ToggleGroups from './ToggleGroups';
+import FilterBar from './FilterBar';
+import LegendRow from './LegendRow';
const sidePanelKey = 'hideSidePanel';
const Grid = () => {
const scrollRef = useRef();
const tableRef = useRef();
+
const { data: { groups, dagRuns } } = useGridData();
+ const dagRunIds = dagRuns.map((dr) => dr.runId);
+
const { isRefreshOn, toggleRefresh, isPaused } = useAutoRefresh();
const isPanelOpen = localStorage.getItem(sidePanelKey) !== 'true';
const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: isPanelOpen });
- const dagRunIds = dagRuns.map((dr) => dr.runId);
const { clearSelection } = useSelection();
const toggleSidePanel = () => {
@@ -86,7 +91,10 @@ const Grid = () => {
}, [tableRef, scrollOnResize]);
return (
- <Box>
+ <Box mt={3}>
+ <FilterBar />
+ <LegendRow />
+ <Divider mb={5} borderBottomWidth={2} />
<Flex flexGrow={1} justifyContent="space-between" alignItems="center">
<Flex alignItems="center">
<FormControl display="flex" width="auto" mr={2}>
@@ -129,7 +137,7 @@ const Grid = () => {
<Thead display="block" pr="10px" position="sticky" top={0}
zIndex={2} bg="white">
<DagRuns />
</Thead>
- {/* TODO: remove hardcoded values. 665px is roughly the total
heade+footer height */}
+ {/* TODO: remove hardcoded values. 665px is roughly the total
header+footer height */}
<Tbody display="block" width="100%" maxHeight="calc(100vh -
665px)" minHeight="500px" ref={tableRef} pr="10px">
{renderTaskRows({
task: groups, dagRunIds,
diff --git a/airflow/www/jest-setup.js
b/airflow/www/static/js/grid/LegendRow.jsx
similarity index 61%
copy from airflow/www/jest-setup.js
copy to airflow/www/static/js/grid/LegendRow.jsx
index b4605d3191..75fba14358 100644
--- a/airflow/www/jest-setup.js
+++ b/airflow/www/static/js/grid/LegendRow.jsx
@@ -1,5 +1,3 @@
-// We need this lint rule for now because these are only dev-dependencies
-/* eslint-disable import/no-extraneous-dependencies */
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -19,18 +17,26 @@
* under the License.
*/
-import '@testing-library/jest-dom';
+/* global stateColors */
-// Mock a global object we use across the app
-global.stateColors = {
- deferred: 'mediumpurple',
- failed: 'red',
- queued: 'gray',
- running: 'lime',
- scheduled: 'tan',
- skipped: 'pink',
- success: 'green',
- up_for_reschedule: 'turquoise',
- up_for_retry: 'gold',
- upstream_failed: 'orange',
-};
+import {
+ Flex,
+ Text,
+} from '@chakra-ui/react';
+import React from 'react';
+import { SimpleStatus } from './StatusBox';
+
+const LegendRow = () => (
+ <Flex mt={0} mb={2} p={4} flexWrap="wrap">
+ {
+ Object.entries(stateColors).map(([state, stateColor]) => (
+ <Flex alignItems="center" mr={3} key={stateColor}>
+ <SimpleStatus mr={1} state={state} />
+ <Text fontSize="md">{state}</Text>
+ </Flex>
+ ))
+ }
+ </Flex>
+);
+
+export default LegendRow;
diff --git a/airflow/www/static/js/grid/StatusBox.jsx
b/airflow/www/static/js/grid/StatusBox.jsx
index d3b5e9f1ab..14831562c0 100644
--- a/airflow/www/static/js/grid/StatusBox.jsx
+++ b/airflow/www/static/js/grid/StatusBox.jsx
@@ -29,6 +29,7 @@ import {
import InstanceTooltip from './InstanceTooltip';
import { useContainerRef } from './context/containerRef';
+import useFilters from './utils/useFilters';
export const boxSize = 10;
export const boxSizePx = `${boxSize}px`;
@@ -51,6 +52,7 @@ const StatusBox = ({
const { runId, taskId } = instance;
const { colors } = useTheme();
const hoverBlue = `${colors.blue[100]}50`;
+ const { filters } = useFilters();
// Fetch the corresponding column element and set its background color when
hovering
const onMouseEnter = () => {
@@ -87,6 +89,7 @@ const StatusBox = ({
zIndex={1}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
+ opacity={(filters.taskState && filters.taskState !== instance.state)
? 0.30 : 1}
/>
</Box>
</Tooltip>
diff --git a/airflow/www/static/js/grid/api/useGridData.js
b/airflow/www/static/js/grid/api/useGridData.js
index 8c16947c8c..d31712989b 100644
--- a/airflow/www/static/js/grid/api/useGridData.js
+++ b/airflow/www/static/js/grid/api/useGridData.js
@@ -17,22 +17,25 @@
* under the License.
*/
-/* global gridData, autoRefreshInterval */
+/* global autoRefreshInterval, gridData */
import { useQuery } from 'react-query';
import axios from 'axios';
import { getMetaValue } from '../../utils';
import { useAutoRefresh } from '../context/autorefresh';
-import { formatData, areActiveRuns } from '../utils/gridData';
+import { areActiveRuns, formatData } from '../utils/gridData';
import useErrorToast from '../utils/useErrorToast';
+import useFilters, {
+ BASE_DATE_PARAM, NUM_RUNS_PARAM, RUN_STATE_PARAM, RUN_TYPE_PARAM, now,
+} from '../utils/useFilters';
+
+const DAG_ID_PARAM = 'dag_id';
// dagId comes from dag.html
-const dagId = getMetaValue('dag_id');
+const dagId = getMetaValue(DAG_ID_PARAM);
const gridDataUrl = getMetaValue('grid_data_url') || '';
-const numRuns = getMetaValue('num_runs');
const urlRoot = getMetaValue('root');
-const baseDate = getMetaValue('base_date');
const emptyData = {
dagRuns: [],
@@ -43,15 +46,22 @@ const useGridData = () => {
const initialData = formatData(gridData, emptyData);
const { isRefreshOn, stopRefresh } = useAutoRefresh();
const errorToast = useErrorToast();
- return useQuery('gridData', async () => {
- try {
- const params = new URLSearchParams({
- dag_id: dagId,
- });
- if (numRuns && numRuns !== 25) params.append('num_runs', numRuns);
- if (urlRoot) params.append('root', urlRoot);
- if (baseDate) params.append('base_date', baseDate);
+ const {
+ filters: {
+ baseDate, numRuns, runType, runState,
+ },
+ } = useFilters();
+ return useQuery(['gridData', baseDate, numRuns, runType, runState], async ()
=> {
+ try {
+ const params = {
+ root: urlRoot || undefined,
+ [DAG_ID_PARAM]: dagId,
+ [BASE_DATE_PARAM]: baseDate === now ? undefined : baseDate,
+ [NUM_RUNS_PARAM]: numRuns,
+ [RUN_TYPE_PARAM]: runType,
+ [RUN_STATE_PARAM]: runState,
+ };
const newData = await axios.get(gridDataUrl, { params });
// turn off auto refresh if there are no active runs
if (!areActiveRuns(newData.dagRuns)) stopRefresh();
@@ -65,10 +75,11 @@ const useGridData = () => {
throw (error);
}
}, {
- // only refetch if the refresh switch is on
- refetchInterval: isRefreshOn && autoRefreshInterval * 1000,
initialData,
placeholderData: emptyData,
+ // only refetch if the refresh switch is on
+ refetchInterval: isRefreshOn && autoRefreshInterval * 1000,
+ keepPreviousData: true,
});
};
diff --git a/airflow/www/static/js/grid/api/useGridData.test.jsx
b/airflow/www/static/js/grid/api/useGridData.test.jsx
index 06a1cb9a08..19c2e9276e 100644
--- a/airflow/www/static/js/grid/api/useGridData.test.jsx
+++ b/airflow/www/static/js/grid/api/useGridData.test.jsx
@@ -17,12 +17,12 @@
* under the License.
*/
+/* global describe, test, expect, beforeAll */
+
import { renderHook } from '@testing-library/react-hooks';
import useGridData from './useGridData';
import { Wrapper } from '../utils/testUtils';
-/* global describe, test, expect, beforeAll */
-
const pendingGridData = {
groups: {},
dag_runs: [
diff --git a/airflow/www/static/js/grid/dagRuns/index.test.jsx
b/airflow/www/static/js/grid/dagRuns/index.test.jsx
index 7cc52355eb..a507df522c 100644
--- a/airflow/www/static/js/grid/dagRuns/index.test.jsx
+++ b/airflow/www/static/js/grid/dagRuns/index.test.jsx
@@ -17,7 +17,7 @@
* under the License.
*/
-/* global describe, test, expect */
+/* global describe, test, expect, jest */
import React from 'react';
import { render } from '@testing-library/react';
@@ -25,6 +25,7 @@ import moment from 'moment-timezone';
import DagRuns from './index';
import { TableWrapper } from '../utils/testUtils';
+import * as useGridDataModule from '../api/useGridData';
const dagRuns = [
{
@@ -52,23 +53,29 @@ const dagRuns = [
describe('Test DagRuns', () => {
test('Durations and manual run arrow render correctly, but without any date
ticks', () => {
- global.gridData = JSON.stringify({
+ const data = {
groups: {},
dagRuns,
- });
+ };
+
+ const spy = jest.spyOn(useGridDataModule, 'default').mockImplementation(()
=> ({
+ data,
+ }));
const { queryAllByTestId, getByText, queryByText } = render(
<DagRuns />, { wrapper: TableWrapper },
);
+
expect(queryAllByTestId('run')).toHaveLength(2);
expect(queryAllByTestId('manual-run')).toHaveLength(1);
-
expect(getByText('00:02:53')).toBeInTheDocument();
expect(getByText('00:01:26')).toBeInTheDocument();
expect(queryByText(moment.utc(dagRuns[0].executionDate).format('MMM DD,
HH:mm'))).toBeNull();
+
+ spy.mockRestore();
});
test('Top date ticks appear when there are 4 or more runs', () => {
- global.gridData = JSON.stringify({
+ const data = {
groups: {},
dagRuns: [
...dagRuns,
@@ -93,11 +100,15 @@ describe('Test DagRuns', () => {
endDate: '2021-11-09T00:22:18.607167+00:00',
},
],
- });
+ };
+ const spy = jest.spyOn(useGridDataModule, 'default').mockImplementation(()
=> ({
+ data,
+ }));
const { getByText } = render(
<DagRuns />, { wrapper: TableWrapper },
);
expect(getByText(moment.utc(dagRuns[0].executionDate).format('MMM DD,
HH:mm'))).toBeInTheDocument();
+ spy.mockRestore();
});
test('Handles empty data correctly', () => {
@@ -107,4 +118,12 @@ describe('Test DagRuns', () => {
);
expect(queryByTestId('run')).toBeNull();
});
+
+ test('Handles no data correctly', () => {
+ global.gridData = {};
+ const { queryByTestId } = render(
+ <DagRuns />, { wrapper: TableWrapper },
+ );
+ expect(queryByTestId('run')).toBeNull();
+ });
});
diff --git a/airflow/www/static/js/grid/details/Header.jsx
b/airflow/www/static/js/grid/details/Header.jsx
index e2041b3665..d656ec26b8 100644
--- a/airflow/www/static/js/grid/details/Header.jsx
+++ b/airflow/www/static/js/grid/details/Header.jsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import React from 'react';
+import React, { useEffect } from 'react';
import {
Breadcrumb,
BreadcrumbItem,
@@ -49,6 +49,14 @@ const Header = () => {
const dagRun = dagRuns.find((r) => r.runId === runId);
const task = tasks.find((t) => t.taskId === taskId);
+ // clearSelection if the current selected dagRun is
+ // filtered out.
+ useEffect(() => {
+ if (runId && !dagRun) {
+ clearSelection();
+ }
+ }, [clearSelection, dagRun, runId]);
+
let runLabel;
if (dagRun) {
if (runId.includes('manual__') || runId.includes('scheduled__') ||
runId.includes('backfill__')) {
diff --git a/airflow/www/static/js/grid/details/content/taskInstance/index.jsx
b/airflow/www/static/js/grid/details/content/taskInstance/index.jsx
index d30f65acc2..b6aad7d4d3 100644
--- a/airflow/www/static/js/grid/details/content/taskInstance/index.jsx
+++ b/airflow/www/static/js/grid/details/content/taskInstance/index.jsx
@@ -57,11 +57,14 @@ const getTask = ({ taskId, runId, task }) => {
const TaskInstance = ({ taskId, runId }) => {
const [selectedRows, setSelectedRows] = useState([]);
const { data: { groups, dagRuns } } = useGridData();
+ const { data: { tasks } } = useTasks(dagId);
+
const group = getTask({ taskId, runId, task: groups });
const run = dagRuns.find((r) => r.runId === runId);
+
+ if (!group || !run) return null;
+
const { executionDate } = run;
- const { data: { tasks } } = useTasks(dagId);
- if (!group) return null;
const task = tasks.find((t) => t.taskId === taskId);
const operator = task && task.classRef && task.classRef.className ?
task.classRef.className : '';
diff --git a/airflow/www/static/js/grid/index.jsx
b/airflow/www/static/js/grid/index.jsx
index 15eaa4a669..8c67ec00f8 100644
--- a/airflow/www/static/js/grid/index.jsx
+++ b/airflow/www/static/js/grid/index.jsx
@@ -48,7 +48,6 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: false,
retry: 1,
retryDelay: 500,
- staleTime: 60 * 1000, // one minute
refetchOnMount: true, // Refetches stale queries, not "always"
},
mutations: {
diff --git a/airflow/www/static/js/grid/renderTaskRows.jsx
b/airflow/www/static/js/grid/renderTaskRows.jsx
index e16f05dd4a..85c0e55f87 100644
--- a/airflow/www/static/js/grid/renderTaskRows.jsx
+++ b/airflow/www/static/js/grid/renderTaskRows.jsx
@@ -45,7 +45,7 @@ const dagId = getMetaValue('dag_id');
const renderTaskRows = ({
task, level = 0, ...rest
-}) => task.children.map((t) => (
+}) => task.children && task.children.map((t) => (
<Row
{...rest}
key={t.id}
diff --git a/airflow/www/static/js/grid/utils/testUtils.jsx
b/airflow/www/static/js/grid/utils/testUtils.jsx
index 946fe0dd88..436288c8d5 100644
--- a/airflow/www/static/js/grid/utils/testUtils.jsx
+++ b/airflow/www/static/js/grid/utils/testUtils.jsx
@@ -65,3 +65,9 @@ export const TableWrapper = ({ children }) => (
</Table>
</Wrapper>
);
+
+export const RouterWrapper = ({ children }) => (
+ <MemoryRouter>
+ {children}
+ </MemoryRouter>
+);
diff --git a/airflow/www/static/js/grid/utils/useFilters.js
b/airflow/www/static/js/grid/utils/useFilters.js
new file mode 100644
index 0000000000..f253d32ba5
--- /dev/null
+++ b/airflow/www/static/js/grid/utils/useFilters.js
@@ -0,0 +1,93 @@
+/*!
+ * 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.
+ */
+
+/* global defaultDagRunDisplayNumber, moment */
+
+import { useSearchParams } from 'react-router-dom';
+
+// Params names
+export const BASE_DATE_PARAM = 'base_date';
+export const NUM_RUNS_PARAM = 'num_runs';
+export const RUN_TYPE_PARAM = 'run_type';
+export const RUN_STATE_PARAM = 'run_state';
+export const TASK_STATE_PARAM = 'task_state';
+
+const date = new Date();
+date.setMilliseconds(0);
+
+export const now = date.toISOString();
+
+const useFilters = () => {
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const baseDate = searchParams.get(BASE_DATE_PARAM) || now;
+ const numRuns = searchParams.get(NUM_RUNS_PARAM) ||
defaultDagRunDisplayNumber;
+ const runType = searchParams.get(RUN_TYPE_PARAM);
+ const runState = searchParams.get(RUN_STATE_PARAM);
+ // taskState is only used to change the opacity of the tasks,
+ // it is not send to the api for filtering.
+ const taskState = searchParams.get(TASK_STATE_PARAM);
+
+ const makeOnChangeFn = (paramName, formatFn) => (e) => {
+ let { value } = e.target;
+ if (formatFn) {
+ value = formatFn(value);
+ }
+ const params = new URLSearchParams(searchParams);
+
+ if (value) params.set(paramName, value);
+ else params.delete(paramName);
+
+ setSearchParams(params);
+ };
+
+ const onBaseDateChange = makeOnChangeFn(BASE_DATE_PARAM,
+ (localDate) => moment(localDate).utc().format());
+ const onNumRunsChange = makeOnChangeFn(NUM_RUNS_PARAM);
+ const onRunTypeChange = makeOnChangeFn(RUN_TYPE_PARAM);
+ const onRunStateChange = makeOnChangeFn(RUN_STATE_PARAM);
+ const onTaskStateChange = makeOnChangeFn(TASK_STATE_PARAM);
+
+ const clearFilters = () => {
+ searchParams.delete(BASE_DATE_PARAM);
+ searchParams.delete(NUM_RUNS_PARAM);
+ searchParams.delete(RUN_TYPE_PARAM);
+ searchParams.delete(RUN_STATE_PARAM);
+ searchParams.delete(TASK_STATE_PARAM);
+ setSearchParams(searchParams);
+ };
+
+ return {
+ filters: {
+ baseDate,
+ numRuns,
+ runType,
+ runState,
+ taskState,
+ },
+ onBaseDateChange,
+ onNumRunsChange,
+ onRunTypeChange,
+ onRunStateChange,
+ onTaskStateChange,
+ clearFilters,
+ };
+};
+
+export default useFilters;
diff --git a/airflow/www/static/js/grid/utils/useFilters.test.jsx
b/airflow/www/static/js/grid/utils/useFilters.test.jsx
new file mode 100644
index 0000000000..cbf5824519
--- /dev/null
+++ b/airflow/www/static/js/grid/utils/useFilters.test.jsx
@@ -0,0 +1,80 @@
+/*!
+ * 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.
+ */
+
+/* global describe, expect, jest, test, moment */
+import { act, renderHook } from '@testing-library/react-hooks';
+
+import { RouterWrapper } from './testUtils';
+
+const date = new Date();
+date.setMilliseconds(0);
+jest.useFakeTimers().setSystemTime(date);
+
+// eslint-disable-next-line import/first
+import useFilters from './useFilters';
+
+describe('Test useFilters hook', () => {
+ test('Initial values when url does not have query params', async () => {
+ const { result } = renderHook(() => useFilters(), { wrapper: RouterWrapper
});
+ const {
+ filters: {
+ baseDate,
+ numRuns,
+ runType,
+ runState,
+ taskState,
+ },
+ } = result.current;
+
+ expect(baseDate).toBe(date.toISOString());
+ expect(numRuns).toBe(global.defaultDagRunDisplayNumber);
+ expect(runType).toBeNull();
+ expect(runState).toBeNull();
+ expect(taskState).toBeNull();
+ });
+
+ test.each([
+ { fnName: 'onBaseDateChange', paramName: 'baseDate', paramValue:
moment.utc().format() },
+ { fnName: 'onNumRunsChange', paramName: 'numRuns', paramValue: '10' },
+ { fnName: 'onRunTypeChange', paramName: 'runType', paramValue: 'manual' },
+ { fnName: 'onRunStateChange', paramName: 'runState', paramValue: 'success'
},
+ { fnName: 'onTaskStateChange', paramName: 'taskState', paramValue:
'deferred' },
+ ])('Test $fnName functions', async ({ fnName, paramName, paramValue }) => {
+ const { result } = renderHook(() => useFilters(), { wrapper: RouterWrapper
});
+
+ await act(async () => {
+ result.current[fnName]({ target: { value: paramValue } });
+ });
+
+ expect(result.current.filters[paramName]).toBe(paramValue);
+
+ // clearFilters
+ await act(async () => {
+ result.current.clearFilters();
+ });
+
+ if (paramName === 'baseDate') {
+ expect(result.current.filters[paramName]).toBe(date.toISOString());
+ } else if (paramName === 'numRuns') {
+
expect(result.current.filters[paramName]).toBe(global.defaultDagRunDisplayNumber);
+ } else {
+ expect(result.current.filters[paramName]).toBeNull();
+ }
+ });
+});
diff --git a/airflow/www/static/js/grid/utils/useSelection.jsx
b/airflow/www/static/js/grid/utils/useSelection.js
similarity index 100%
rename from airflow/www/static/js/grid/utils/useSelection.jsx
rename to airflow/www/static/js/grid/utils/useSelection.js
diff --git a/airflow/www/templates/airflow/grid.html
b/airflow/www/templates/airflow/grid.html
index 06461d42b4..46d52f7d27 100644
--- a/airflow/www/templates/airflow/grid.html
+++ b/airflow/www/templates/airflow/grid.html
@@ -30,45 +30,6 @@
{% block content %}
{{ super() }}
- <div class="row dag-view-tools">
- <div class="col-md-12">
- <form method="get" class="form-inline">
- <input type="hidden" name="root" value="{{ root if root else '' }}">
- <input type="hidden" value="{{ dag.dag_id }}" name="dag_id">
- <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
- <div class="form-group">
- <label class="sr-only" for="base_date">Base date</label>
- <div class="input-group">
- {{ form.base_date(class_="form-control",
disabled=not(dag.has_dag_runs())) }}
- </div>
- </div>
- <div class="form-group">
- <label class="sr-only" for="num_runs">Number of runs</label>
- <div class="input-group">
- <div class="input-group-addon">Runs</div>
- {{ form.num_runs(class_="form-control",
disabled=not(dag.has_dag_runs())) }}
- </div>
- </div>
- <button type="submit" class="btn"{{' disabled' if not
dag.has_dag_runs() else ''}}>Update</button>
- {% if not dag.has_dag_runs() %}<span class="text-warning"
style="margin-left:16px;">No DAG runs yet.</span>{% endif %}
- </form>
- </div>
- </div>
- <div class="legend-row">
- <div>
- {% for state, state_color in state_color_mapping.items() %}
- <span class="legend-item legend-item--no-border">
- <span class="legend-item__swatch legend-item__swatch--no-border"
style="background: {{ state_color }};"></span>
- {{state}}
- </span>
- {% endfor %}
- <span class="legend-item legend-item--no-border">
- <span class="legend-item__swatch"></span>
- no_status
- </span>
- </div>
- </div>
- <hr>
<div id="root">
<div id="react-container"></div>
{{ loading_dots(id='react-loading') }}
@@ -76,11 +37,13 @@
{% endblock %}
{% block tail_js %}
- {{ super() }}
+ {{ super()}}
<script>
const gridData = {{ data|tojson }};
const stateColors = {{ state_color_mapping|tojson }};
const autoRefreshInterval = {{ auto_refresh_interval }};
+ const defaultDagRunDisplayNumber = {{ default_dag_run_display_number }};
+ const filtersOptions = {{ filters_drop_down_values }};
</script>
<script src="{{ url_for_asset('grid.js') }}"></script>
{% endblock %}
diff --git a/airflow/www/views.py b/airflow/www/views.py
index 991b47297c..5964015457 100644
--- a/airflow/www/views.py
+++ b/airflow/www/views.py
@@ -26,6 +26,7 @@ import socket
import sys
import traceback
import warnings
+from bisect import insort_left
from collections import defaultdict
from datetime import timedelta
from functools import wraps
@@ -2638,6 +2639,13 @@ class Airflow(AirflowBaseView):
# avoid spaces to reduce payload size
data = htmlsafe_json_dumps(data, separators=(',', ':'))
+ default_dag_run_display_number = conf.getint('webserver',
'default_dag_run_display_number')
+
+ num_runs_options = [5, 25, 50, 100, 365]
+
+ if default_dag_run_display_number not in num_runs_options:
+ insort_left(num_runs_options, default_dag_run_display_number)
+
return self.render_template(
'airflow/grid.html',
operators=sorted({op.task_type: op for op in dag.tasks}.values(),
key=lambda x: x.task_type),
@@ -2651,6 +2659,16 @@ class Airflow(AirflowBaseView):
external_log_name=external_log_name,
dag_model=dag_model,
auto_refresh_interval=conf.getint('webserver',
'auto_refresh_interval'),
+ default_dag_run_display_number=default_dag_run_display_number,
+ task_instances=tis,
+ filters_drop_down_values=htmlsafe_json_dumps(
+ {
+ "taskStates": [state.value for state in TaskInstanceState],
+ "dagStates": [state.value for state in State.dag_states],
+ "runTypes": [run_type.value for run_type in DagRunType],
+ "numRuns": num_runs_options,
+ }
+ ),
)
@expose('/calendar')
@@ -3493,13 +3511,19 @@ class Airflow(AirflowBaseView):
base_date = dag.get_latest_execution_date() or timezone.utcnow()
with create_session() as session:
- dag_runs = (
- session.query(DagRun)
- .filter(DagRun.dag_id == dag.dag_id, DagRun.execution_date <=
base_date)
- .order_by(DagRun.execution_date.desc())
- .limit(num_runs)
- .all()
+ query = session.query(DagRun).filter(
+ DagRun.dag_id == dag.dag_id, DagRun.execution_date <= base_date
)
+
+ run_type = request.args.get("run_type")
+ if run_type:
+ query = query.filter(DagRun.run_type == run_type)
+
+ run_state = request.args.get("run_state")
+ if run_state:
+ query = query.filter(DagRun.state == run_state)
+
+ dag_runs =
query.order_by(DagRun.execution_date.desc()).limit(num_runs).all()
dag_runs.reverse()
encoded_runs = [wwwutils.encode_dag_run(dr) for dr in dag_runs]
dag_run_dates = {dr.execution_date: alchemy_to_dict(dr) for dr in
dag_runs}
diff --git a/tests/www/views/test_views_home.py
b/tests/www/views/test_views_home.py
index 1d4c126e5d..a6d8dcac0c 100644
--- a/tests/www/views/test_views_home.py
+++ b/tests/www/views/test_views_home.py
@@ -56,7 +56,7 @@ def test_home(capture_templates, admin_client):
'"null": "lightblue", "queued": "gray", '
'"removed": "lightgrey", "restarting": "violet", "running":
"lime", '
'"scheduled": "tan", "sensing": "mediumpurple", '
- '"shutdown": "blue", "skipped": "pink", '
+ '"shutdown": "blue", "skipped": "hotpink", '
'"success": "green", "up_for_reschedule": "turquoise", '
'"up_for_retry": "gold", "upstream_failed": "orange"};'
)