This is an automated email from the ASF dual-hosted git repository. ephraimanierobi pushed a commit to branch v2-7-test in repository https://gitbox.apache.org/repos/asf/airflow.git
commit dceeb134b47f55ed8c92806a722da1a0aeddc57a Author: Brent Bovenzi <[email protected]> AuthorDate: Thu Aug 3 07:15:27 2023 +0800 Remove old graph (#32958) (cherry picked from commit 154deed02ecc0be36b1ddfc9144aa05fb77b5f2d) --- airflow/www/forms.py | 6 - airflow/www/static/js/callModal.js | 391 ---------- .../static/js/cluster-activity/nav/FilterBar.tsx | 2 + airflow/www/static/js/components/Time.test.tsx | 11 +- airflow/www/static/js/components/Time.tsx | 6 +- airflow/www/static/js/dag.js | 51 ++ .../js/dag/details/taskInstance/Logs/utils.ts | 1 + .../www/static/js/dag/grid/dagRuns/index.test.tsx | 7 +- airflow/www/static/js/dag/nav/FilterBar.tsx | 1 + airflow/www/static/js/graph.js | 828 --------------------- airflow/www/static/js/task_instances.js | 49 +- airflow/www/templates/airflow/dag.html | 6 +- airflow/www/templates/airflow/graph.html | 146 ---- airflow/www/views.py | 101 +-- airflow/www/webpack.config.js | 2 +- docs/apache-airflow/img/graph.png | Bin 128870 -> 429818 bytes docs/apache-airflow/img/mapping-simple-graph.png | Bin 7676 -> 40118 bytes docs/apache-airflow/img/task_group.gif | Bin 609981 -> 137189 bytes tests/www/views/test_views_decorators.py | 12 +- tests/www/views/test_views_graph.py | 348 --------- tests/www/views/test_views_tasks.py | 35 +- 21 files changed, 119 insertions(+), 1884 deletions(-) diff --git a/airflow/www/forms.py b/airflow/www/forms.py index 9e143ce7b9..4a0213945c 100644 --- a/airflow/www/forms.py +++ b/airflow/www/forms.py @@ -121,12 +121,6 @@ class DateTimeWithNumRunsForm(FlaskForm): ) -class DateTimeWithNumRunsWithDagRunsForm(DateTimeWithNumRunsForm): - """Date time and number of runs and dag runs form for graph and gantt view.""" - - execution_date = SelectField("DAG run") - - class DagRunEditForm(DynamicForm): """Form for editing DAG Run. diff --git a/airflow/www/static/js/callModal.js b/airflow/www/static/js/callModal.js deleted file mode 100644 index 050c7eebff..0000000000 --- a/airflow/www/static/js/callModal.js +++ /dev/null @@ -1,391 +0,0 @@ -/*! - * 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 document, window, $ */ - -import { getMetaValue } from "./utils"; -import { formatDateTime } from "./datetime_utils"; - -function updateQueryStringParameter(uri, key, value) { - const re = new RegExp(`([?&])${key}=.*?(&|$)`, "i"); - const separator = uri.indexOf("?") !== -1 ? "&" : "?"; - if (uri.match(re)) { - return uri.replace(re, `$1${key}=${value}$2`); - } - - return `${uri}${separator}${key}=${value}`; -} - -function updateUriToFilterTasks(uri, taskId, filterUpstream, filterDownstream) { - const uriWithRoot = updateQueryStringParameter(uri, "root", taskId); - const uriWithFilterUpstreamQuery = updateQueryStringParameter( - uriWithRoot, - "filter_upstream", - filterUpstream - ); - return updateQueryStringParameter( - uriWithFilterUpstreamQuery, - "filter_downstream", - filterDownstream - ); -} - -const dagId = getMetaValue("dag_id"); -const logsWithMetadataUrl = getMetaValue("logs_with_metadata_url"); -const externalLogUrl = getMetaValue("external_log_url"); -const extraLinksUrl = getMetaValue("extra_links_url"); -const showExternalLogRedirect = - getMetaValue("show_external_log_redirect") === "True"; - -const buttons = Array.from( - document.querySelectorAll('a[id^="btn_"][data-base-url]') -).reduce((obj, elm) => { - // eslint-disable-next-line no-param-reassign - obj[elm.id.replace("btn_", "")] = elm; - return obj; -}, {}); - -function updateButtonUrl(elm, params) { - let url = elm.dataset.baseUrl; - if (params.dag_id && elm.dataset.baseUrl.indexOf(dagId) !== -1) { - url = url.replace(dagId, params.dag_id); - // eslint-disable-next-line no-param-reassign - delete params.dag_id; - } - if ( - Object.prototype.hasOwnProperty.call(params, "map_index") && - params.map_index === undefined - ) { - // eslint-disable-next-line no-param-reassign - delete params.map_index; - } - elm.setAttribute("href", `${url}?${$.param(params)}`); -} - -function updateModalUrls({ - executionDate, - subDagId, - taskId, - mapIndex, - dagRunId, -}) { - updateButtonUrl(buttons.subdag, { - dag_id: subDagId, - execution_date: executionDate, - }); - - updateButtonUrl(buttons.task, { - dag_id: dagId, - task_id: taskId, - execution_date: executionDate, - map_index: mapIndex, - }); - - updateButtonUrl(buttons.rendered, { - dag_id: dagId, - task_id: taskId, - execution_date: executionDate, - map_index: mapIndex, - }); - - updateButtonUrl(buttons.mapped, { - _flt_3_dag_id: dagId, - _flt_3_task_id: taskId, - _flt_3_run_id: dagRunId, - _oc_TaskInstanceModelView: "map_index", - }); - - if (buttons.rendered_k8s) { - updateButtonUrl(buttons.rendered_k8s, { - dag_id: dagId, - task_id: taskId, - execution_date: executionDate, - map_index: mapIndex, - }); - } - - const tiButtonParams = { - _flt_3_dag_id: dagId, - _flt_3_task_id: taskId, - _oc_TaskInstanceModelView: "dag_run.execution_date", - }; - // eslint-disable-next-line no-underscore-dangle - if (mapIndex >= 0) tiButtonParams._flt_0_map_index = mapIndex; - updateButtonUrl(buttons.ti, tiButtonParams); - - updateButtonUrl(buttons.log, { - dag_id: dagId, - task_id: taskId, - execution_date: executionDate, - map_index: mapIndex, - }); - - updateButtonUrl(buttons.xcom, { - dag_id: dagId, - task_id: taskId, - execution_date: executionDate, - map_index: mapIndex, - }); -} - -function callModal({ - taskId, - executionDate, - extraLinks, - tryNumber, - isSubDag, - dagRunId, - mapIndex = -1, - isMapped = false, - mappedStates = [], -}) { - // Turn off previous event listeners - $(".map_index_item").off("click"); - $("form[data-action]").off("submit"); - - const location = String(window.location); - $("#btn_filter_upstream").on("click", () => { - window.location = updateUriToFilterTasks(location, taskId, "true", "false"); - }); - $("#btn_filter_downstream").on("click", () => { - window.location = updateUriToFilterTasks(location, taskId, "false", "true"); - }); - $("#btn_filter_upstream_downstream").on("click", () => { - window.location = updateUriToFilterTasks(location, taskId, "true", "true"); - }); - $("#dag_run_id").text(dagRunId); - $("#task_id").text(taskId); - $("#execution_date").text(formatDateTime(executionDate)); - $("#taskInstanceModal").modal({}); - $("#taskInstanceModal").css("margin-top", "0"); - $("#extra_links").prev("hr").hide(); - $("#extra_links").empty().hide(); - if (mapIndex >= 0) { - $("#modal_map_index").show(); - $("#modal_map_index .value").text(mapIndex); - } else { - $("#modal_map_index").hide(); - $("#modal_map_index .value").text(""); - } - - let subDagId; - if (isSubDag) { - $("#div_btn_subdag").show(); - subDagId = `${dagId}.${taskId}`; - } else { - $("#div_btn_subdag").hide(); - } - - // Show a span or dropdown for mapIndex - if (mapIndex >= 0 && !mappedStates.length) { - $("#modal_map_index").show(); - $("#modal_map_index .value").text(mapIndex); - $("#mapped_dropdown").hide(); - } else if (mapIndex >= 0 || isMapped) { - $("#modal_map_index").show(); - $("#modal_map_index .value").text(""); - $("#mapped_dropdown").show(); - - const dropdownText = - mapIndex > -1 ? mapIndex : `All ${mappedStates.length} Mapped Instances`; - $("#mapped_dropdown #dropdown-label").text(dropdownText); - $("#mapped_dropdown .dropdown-menu").empty(); - $("#mapped_dropdown .dropdown-menu").append( - `<li><a href="#" class="map_index_item" data-mapIndex="all">All ${mappedStates.length} Mapped Instances</a></li>` - ); - mappedStates.forEach((state, i) => { - $("#mapped_dropdown .dropdown-menu").append( - `<li><a href="#" class="map_index_item" data-mapIndex="${i}">${i} - ${state}</a></li>` - ); - }); - } else { - $("#modal_map_index").hide(); - $("#modal_map_index .value").text(""); - $("#mapped_dropdown").hide(); - } - - if (isMapped) { - $("#task_actions").text( - `Task Actions for all ${mappedStates.length} instances` - ); - $("#btn_mapped").show(); - $("#mapped_dropdown").css("display", "inline-block"); - $("#btn_rendered").hide(); - $("#btn_xcom").hide(); - $("#btn_log").hide(); - $("#btn_task").hide(); - } else { - $("#task_actions").text("Task Actions"); - $("#btn_rendered").show(); - $("#btn_xcom").show(); - $("#btn_log").show(); - $("#btn_mapped").hide(); - $("#btn_task").show(); - } - - $("#dag_dl_logs").hide(); - $("#dag_redir_logs").hide(); - if (tryNumber > 0 && !isMapped) { - $("#dag_dl_logs").show(); - if (showExternalLogRedirect) { - $("#dag_redir_logs").show(); - } - } - - updateModalUrls({ - executionDate, - subDagId, - taskId, - mapIndex, - dagRunId, - }); - - $("#try_index > li").remove(); - $("#redir_log_try_index > li").remove(); - const startIndex = tryNumber > 2 ? 0 : 1; - - const query = new URLSearchParams({ - dag_id: dagId, - task_id: taskId, - execution_date: executionDate, - metadata: "null", - }); - if (mapIndex !== undefined) { - query.set("map_index", mapIndex); - } - for (let index = startIndex; index < tryNumber; index += 1) { - let showLabel = index; - if (index !== 0) { - query.set("try_number", index); - } else { - showLabel = "All"; - } - - $("#try_index").append(`<li role="presentation" style="display:inline"> - <a href="${logsWithMetadataUrl}?${query}&format=file"> ${showLabel} </a> - </li>`); - - if (index !== 0 || showExternalLogRedirect) { - $("#redir_log_try_index") - .append(`<li role="presentation" style="display:inline"> - <a href="${externalLogUrl}?${query}"> ${showLabel} </a> - </li>`); - } - } - query.delete("try_number"); - - if (!isMapped && extraLinks && extraLinks.length > 0) { - const markupArr = []; - extraLinks.sort(); - $.each(extraLinks, (i, link) => { - query.set("link_name", link); - const externalLink = $( - '<a href="#" class="btn btn-primary disabled"></a>' - ); - const linkTooltip = $( - '<span class="tool-tip" data-toggle="tooltip" style="padding-right: 2px; padding-left: 3px" data-placement="top" ' + - 'title="link not yet available"></span>' - ); - linkTooltip.append(externalLink); - externalLink.text(link); - - $.ajax({ - url: `${extraLinksUrl}?${query}`, - cache: false, - success(data) { - externalLink.attr("href", data.url); - // open absolute (external) links in a new tab/window and relative (local) links - // directly - if (/^(?:[a-z]+:)?\/\//.test(data.url)) { - externalLink.attr("target", "_blank"); - } - externalLink.removeClass("disabled"); - linkTooltip.tooltip("disable"); - }, - error(data) { - linkTooltip - .tooltip("hide") - .attr("title", data.responseJSON.error) - .tooltip("fixTitle"); - }, - }); - - markupArr.push(linkTooltip); - }); - - const extraLinksSpan = $("#extra_links"); - extraLinksSpan.prev("hr").show(); - extraLinksSpan.append(markupArr).show(); - extraLinksSpan.find('[data-toggle="tooltip"]').tooltip(); - } - - // Switch the modal from a mapped task summary to a specific mapped task instance - function switchMapItem() { - const mi = $(this).attr("data-mapIndex"); - if (mi === "all") { - callModal({ - taskId, - executionDate, - dagRunId, - extraLinks, - mapIndex: -1, - isMapped: true, - mappedStates, - }); - } else { - callModal({ - taskId, - executionDate, - dagRunId, - extraLinks, - mapIndex: mi, - }); - } - } - - // Task Instance Modal actions - function submit(e) { - e.preventDefault(); - const form = $(this).get(0); - if (dagRunId || executionDate) { - if (form.dag_run_id) { - form.dag_run_id.value = dagRunId; - } - if (form.execution_date) { - form.execution_date.value = executionDate; - } - form.origin.value = window.location; - if (form.task_id) { - form.task_id.value = taskId; - } - if (form.map_index && mapIndex >= 0) { - form.map_index.value = mapIndex; - } else if (form.map_index) { - form.map_index.remove(); - } - form.action = $(this).data("action"); - form.submit(); - } - } - - $("form[data-action]").on("submit", submit); - $(".map_index_item").on("click", switchMapItem); -} - -export default callModal; diff --git a/airflow/www/static/js/cluster-activity/nav/FilterBar.tsx b/airflow/www/static/js/cluster-activity/nav/FilterBar.tsx index 10cc6813f1..6cae7854f1 100644 --- a/airflow/www/static/js/cluster-activity/nav/FilterBar.tsx +++ b/airflow/www/static/js/cluster-activity/nav/FilterBar.tsx @@ -39,7 +39,9 @@ const FilterBar = () => { const startDate = moment(filters.startDate); // @ts-ignore const endDate = moment(filters.endDate); + // @ts-ignore const formattedStartDate = startDate.tz(timezone).format(isoFormatWithoutTZ); + // @ts-ignore const formattedEndDate = endDate.tz(timezone).format(isoFormatWithoutTZ); const inputStyles = { backgroundColor: "white", size: "lg" }; diff --git a/airflow/www/static/js/components/Time.test.tsx b/airflow/www/static/js/components/Time.test.tsx index 92ab8b39af..faef2a0eb3 100644 --- a/airflow/www/static/js/components/Time.test.tsx +++ b/airflow/www/static/js/components/Time.test.tsx @@ -17,12 +17,10 @@ * under the License. */ -/* global describe, test, expect, document, CustomEvent */ +/* global moment, describe, test, expect, document, CustomEvent */ import React from "react"; import { render, fireEvent, act } from "@testing-library/react"; -import moment from "moment-timezone"; - import { defaultFormatWithTZ, TimezoneEvent } from "src/datetime_utils"; import { Wrapper } from "src/utils/testUtils"; @@ -35,6 +33,7 @@ describe("Test Time and TimezoneProvider", () => { wrapper: Wrapper, }); + // @ts-ignore const utcTime = getByText(moment.utc(now).format(defaultFormatWithTZ)); expect(utcTime).toBeDefined(); expect(utcTime.title).toBeFalsy(); @@ -43,15 +42,18 @@ describe("Test Time and TimezoneProvider", () => { test("Displays moment default tz, includes UTC date in title", () => { const now = new Date(); const tz = "US/Samoa"; + // @ts-ignore moment.tz.setDefault(tz); const { getByText } = render(<Time dateTime={now.toISOString()} />, { wrapper: Wrapper, }); + // @ts-ignore const samoaTime = getByText(moment(now).tz(tz).format(defaultFormatWithTZ)); expect(samoaTime).toBeDefined(); expect(samoaTime.title).toEqual( + // @ts-ignore moment.utc(now).format(defaultFormatWithTZ) ); }); @@ -63,6 +65,7 @@ describe("Test Time and TimezoneProvider", () => { { wrapper: Wrapper } ); + // @ts-ignore const utcTime = queryByText(moment.utc(now).format(defaultFormatWithTZ)); expect(utcTime).toBeDefined(); @@ -76,9 +79,11 @@ describe("Test Time and TimezoneProvider", () => { expect(utcTime).toBeNull(); const estTime = getByText( + // @ts-ignore moment(now).tz("EST").format(defaultFormatWithTZ) ); expect(estTime).toBeDefined(); + // @ts-ignore expect(estTime.title).toEqual(moment.utc(now).format(defaultFormatWithTZ)); }); }); diff --git a/airflow/www/static/js/components/Time.tsx b/airflow/www/static/js/components/Time.tsx index c9765d7f05..8c0e2143a8 100644 --- a/airflow/www/static/js/components/Time.tsx +++ b/airflow/www/static/js/components/Time.tsx @@ -17,8 +17,9 @@ * under the License. */ +/* global moment */ + import React from "react"; -import moment from "moment-timezone"; import { useTimezone } from "src/context/timezone"; import { defaultFormatWithTZ } from "src/datetime_utils"; @@ -30,11 +31,14 @@ interface Props { const Time = ({ dateTime, format = defaultFormatWithTZ }: Props) => { const { timezone } = useTimezone(); + // @ts-ignore const time = moment(dateTime); if (!dateTime || !time.isValid()) return null; + // @ts-ignore const formattedTime = time.tz(timezone).format(format); + // @ts-ignore const utcTime = time.tz("UTC").format(defaultFormatWithTZ); return ( diff --git a/airflow/www/static/js/dag.js b/airflow/www/static/js/dag.js index 787c483719..c3d0cd7475 100644 --- a/airflow/www/static/js/dag.js +++ b/airflow/www/static/js/dag.js @@ -41,6 +41,31 @@ const setNextDatasets = (datasets, error) => { nextDatasetsError = error; }; +// Check if there is a highlighted tab and change the active nav button +const onTabChange = () => { + const urlParams = new URLSearchParams(window.location.search); + const isGrid = window.location.href.includes(`${dagId}/grid`); + const tab = urlParams.get("tab"); + const gridNav = document.getElementById("grid-nav"); + const graphNav = document.getElementById("graph-nav"); + const ganttNav = document.getElementById("gantt-nav"); + if (isGrid) { + if (tab === "graph") { + gridNav.classList.remove("active"); + ganttNav.classList.remove("active"); + graphNav.classList.add("active"); + } else if (tab === "gantt") { + gridNav.classList.remove("active"); + graphNav.classList.remove("active"); + ganttNav.classList.add("active"); + } else { + graphNav.classList.remove("active"); + ganttNav.classList.remove("active"); + gridNav.classList.add("active"); + } + } +}; + // Pills highlighting $(window).on("load", function onLoad() { $(`a[href*="${this.location.pathname}"]`).parent().addClass("active"); @@ -50,6 +75,32 @@ $(window).on("load", function onLoad() { if (!singleDatasetUri) { getDatasetTooltipInfo(dagId, run, setNextDatasets); } + + onTabChange(); +}); + +// Dispatch an event whenever history changes that we can then listen to +const LOCATION_CHANGE = "locationchange"; +(function dispatchLocationEvent() { + const { pushState, replaceState } = window.history; + + window.history.pushState = (...args) => { + pushState.apply(window.history, args); + window.dispatchEvent(new Event(LOCATION_CHANGE)); + }; + + window.history.replaceState = (...args) => { + replaceState.apply(window.history, args); + window.dispatchEvent(new Event(LOCATION_CHANGE)); + }; + + window.addEventListener("popstate", () => { + window.dispatchEvent(new Event(LOCATION_CHANGE)); + }); +})(); + +window.addEventListener(LOCATION_CHANGE, () => { + onTabChange(); }); $("#pause_resume").on("change", function onChange() { diff --git a/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts b/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts index 21e215fe1e..80b534ccd5 100644 --- a/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts +++ b/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts @@ -82,6 +82,7 @@ export const parseLogs = ( // @ts-ignore const localDateTime = moment .utc(dateTime) + // @ts-ignore .tz(timezone) .format(defaultFormatWithTZ); parsedLine = line.replace(dateTime, localDateTime); diff --git a/airflow/www/static/js/dag/grid/dagRuns/index.test.tsx b/airflow/www/static/js/dag/grid/dagRuns/index.test.tsx index 2ac20ea3cc..a02df41975 100644 --- a/airflow/www/static/js/dag/grid/dagRuns/index.test.tsx +++ b/airflow/www/static/js/dag/grid/dagRuns/index.test.tsx @@ -17,11 +17,10 @@ * under the License. */ -/* global describe, test, expect, jest */ +/* global describe, test, expect, jest, moment */ import React from "react"; import { render } from "@testing-library/react"; -import moment from "moment-timezone"; import { TableWrapper } from "src/utils/testUtils"; import * as useGridDataModule from "src/api/useGridData"; @@ -105,6 +104,7 @@ describe("Test DagRuns", () => { expect(getByText("00:02:53")).toBeInTheDocument(); expect(getByText("00:01:26")).toBeInTheDocument(); expect( + // @ts-ignore queryByText(moment.utc(dagRuns[0].executionDate).format("MMM DD, HH:mm")) ).toBeNull(); @@ -124,6 +124,7 @@ describe("Test DagRuns", () => { ); const { getByText } = render(<DagRuns />, { wrapper: TableWrapper }); expect( + // @ts-ignore getByText(moment.utc(datestring).format("MMM DD, HH:mm")) ).toBeInTheDocument(); spy.mockRestore(); @@ -142,6 +143,7 @@ describe("Test DagRuns", () => { ); const { queryAllByText } = render(<DagRuns />, { wrapper: TableWrapper }); expect( + // @ts-ignore queryAllByText(moment.utc(datestring).format("MMM DD, HH:mm")) ).toHaveLength(1); spy.mockRestore(); @@ -160,6 +162,7 @@ describe("Test DagRuns", () => { ); const { queryAllByText } = render(<DagRuns />, { wrapper: TableWrapper }); expect( + // @ts-ignore queryAllByText(moment.utc(datestring).format("MMM DD, HH:mm")) ).toHaveLength(2); spy.mockRestore(); diff --git a/airflow/www/static/js/dag/nav/FilterBar.tsx b/airflow/www/static/js/dag/nav/FilterBar.tsx index 9078fe705a..fc3a56f1d5 100644 --- a/airflow/www/static/js/dag/nav/FilterBar.tsx +++ b/airflow/www/static/js/dag/nav/FilterBar.tsx @@ -48,6 +48,7 @@ const FilterBar = () => { const { timezone } = useTimezone(); // @ts-ignore const time = moment(filters.baseDate); + // @ts-ignore const formattedTime = time.tz(timezone).format(isoFormatWithoutTZ); const inputStyles = { backgroundColor: "white", size: "lg" }; diff --git a/airflow/www/static/js/graph.js b/airflow/www/static/js/graph.js deleted file mode 100644 index b61639d3a8..0000000000 --- a/airflow/www/static/js/graph.js +++ /dev/null @@ -1,828 +0,0 @@ -/* eslint-disable no-underscore-dangle */ -/* eslint-disable no-use-before-define */ -/*! - * 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 d3, document, nodes, taskInstances, tasks, edges, dagreD3, localStorage, $, - autoRefreshInterval, moment, convertSecsToHumanReadable, priority -*/ - -import { getMetaValue, finalStatesMap } from "./utils"; -import { escapeHtml } from "./main"; -import tiTooltip, { taskNoInstanceTooltip } from "./task_instances"; -import callModal from "./callModal"; - -// dagId comes from dag.html -const dagId = getMetaValue("dag_id"); -const executionDate = getMetaValue("execution_date"); -const dagRunId = getMetaValue("dag_run_id"); -const arrange = getMetaValue("arrange"); -const taskInstancesUrl = getMetaValue("task_instances_url"); -const isSchedulerRunning = getMetaValue("is_scheduler_running"); - -// This maps the actual taskId to the current graph node id that contains the task -// (because tasks may be grouped into a group node) -const mapTaskToNode = new Map(); - -// Below variables are being used in dag.js - -const getTaskInstanceURL = `${taskInstancesUrl}?dag_id=${encodeURIComponent( - dagId -)}&execution_date=${encodeURIComponent(executionDate)}`; - -const stateFocusMap = { - success: false, - running: false, - failed: false, - skipped: false, - upstream_failed: false, - up_for_reschedule: false, - up_for_retry: false, - queued: false, - deferred: false, - no_status: false, -}; - -const checkRunState = () => { - const states = Object.values(taskInstances).map((ti) => ti.state); - return !states.some( - (state) => - ["success", "failed", "upstream_failed", "skipped", "removed"].indexOf( - state - ) === -1 - ); -}; - -const taskTip = d3 - .tip() - .attr("class", "tooltip d3-tip") - .html((toolTipHtml) => toolTipHtml); - -// Preparation of DagreD3 data structures -// "compound" is set to true to make use of clusters to display TaskGroup. -const g = new dagreD3.graphlib.Graph({ compound: true }) - .setGraph({ - nodesep: 30, - ranksep: 15, - rankdir: arrange, - }) - .setDefaultEdgeLabel(() => ({ lineInterpolate: "basis" })); - -const render = dagreD3.render(); -const svg = d3.select("#graph-svg"); -let innerSvg = d3.select("#graph-svg g"); - -// We modify the label of task map nodes to include the brackets and a count of mapped tasks -// returns true if at least one node is changed -const updateNodeLabels = (node, instances) => { - let haveLabelsChanged = false; - let { label } = node.value; - - const isGroupMapped = node.value.isMapped; - const isTaskMapped = tasks[node.id] && tasks[node.id].is_mapped; - - if (isTaskMapped || isGroupMapped) { - // get count from mapped_states or the first child's mapped_states - const id = isGroupMapped ? node.children[0].id : node.id; - const instance = instances[id]; - let count = " "; - - // TODO: update this count for when we can nest mapped tasks inside of mapped task groups - if (instance && instance.mapped_states) { - count = instance.mapped_states.length; - } - - if (!label.includes(`[${count}]`)) { - label = `${label} [${count}]`; - } - } - if (g.node(node.id) && g.node(node.id).label !== label) { - g.node(node.id).label = label; - haveLabelsChanged = true; - } - - if (node.children) { - // Iterate through children and return true if at least one has been changed - const updatedNodes = node.children.map((n) => - updateNodeLabels(n, instances) - ); - return updatedNodes.some((changed) => changed); - } - - return haveLabelsChanged; -}; - -// Remove the node with this nodeId from g. -function removeNode(nodeId) { - if (g.hasNode(nodeId)) { - const node = g.node(nodeId); - if (node.children !== undefined) { - // If the child is an expanded group node, remove children too. - node.children.forEach((child) => { - removeNode(child.id); - }); - } - } - g.removeNode(nodeId); -} - -// Collapse the children of the given group node. -function collapseGroup(nodeId, node) { - // Remove children nodes - node.children.forEach((child) => { - removeNode(child.id); - }); - // Map task that are under this node to this node's id - - getChildrenIds(node).forEach((childId) => mapTaskToNode.set(childId, nodeId)); - - // eslint-disable-next-line no-param-reassign - node = g.node(nodeId); - - // Set children edges onto the group edge - edges.forEach((edge) => { - const sourceId = mapTaskToNode.get(edge.source_id); - const targetId = mapTaskToNode.get(edge.target_id); - if (sourceId !== targetId && !g.hasEdge(sourceId, targetId)) { - g.setEdge(sourceId, targetId, { - curve: d3.curveBasis, - arrowheadClass: "arrowhead", - }); - } - }); - - draw(); - focusGroup(nodeId); - - removeExpandedGroup(nodeId, node); -} - -// Update the page to show the latest DAG. -function draw() { - innerSvg.remove(); - innerSvg = svg.append("g"); - // Run the renderer. This is what draws the final graph. - innerSvg.call(render, g); - innerSvg.call(taskTip); - - // When an expanded group is clicked, collapse it. - d3.selectAll("g.cluster").on("click", (nodeId) => { - if (d3.event.defaultPrevented) return; - const node = g.node(nodeId); - collapseGroup(nodeId, node); - }); - // When a node is clicked, action depends on the node type. - d3.selectAll("g.node").on("click", (nodeId) => { - const node = g.node(nodeId); - if (node.children !== undefined && Object.keys(node.children).length > 0) { - // A group node - if (d3.event.defaultPrevented) return; - expandGroup(nodeId, node); - updateNodeLabels(nodes, taskInstances); - draw(); - focusGroup(nodeId); - } else if (nodeId in taskInstances) { - // A task node - const task = tasks[nodeId]; - const tryNumber = taskInstances[nodeId].try_number || 0; - const mappedStates = taskInstances[nodeId].mapped_states || []; - - callModal({ - taskId: nodeId, - executionDate, - extraLinks: task.extra_links, - tryNumber, - isSubDag: task.task_type === "SubDagOperator", - dagRunId, - mapIndex: task.map_index, - isMapped: task.is_mapped || !!taskInstances[nodeId].mapped_states, - mappedStates, - }); - } - }); - - d3.selectAll("g.node").on("mouseover", function mousover(d) { - d3.select(this).selectAll("rect").attr("data-highlight", "highlight"); - highlightNodes(g.predecessors(d)); - highlightNodes(g.successors(d)); - const adjacentNodeNames = [d, ...g.predecessors(d), ...g.successors(d)]; - - d3.selectAll("g.nodes g.node") - .filter((x) => !adjacentNodeNames.includes(x)) - .attr("data-highlight", "fade"); - - d3.selectAll("g.edgePath")[0].forEach((x) => { - const val = g.nodeEdges(d).includes(x.__data__) ? "highlight" : "fade"; - d3.select(x).attr("data-highlight", val); - }); - d3.selectAll("g.edgeLabel")[0].forEach((x) => { - if (!g.nodeEdges(d).includes(x.__data__)) { - d3.select(x).attr("data-highlight", "fade"); - } - }); - }); - - d3.selectAll("g.node").on("mouseout", function mouseout(d) { - d3.select(this).selectAll("rect, circle").attr("data-highlight", null); - unHighlightNodes(g.predecessors(d)); - unHighlightNodes(g.successors(d)); - d3.selectAll("g.node, g.edgePath, g.edgeLabel").attr( - "data-highlight", - null - ); - localStorage.removeItem(focusedGroupKey(dagId)); - }); - updateNodesStates(taskInstances); - setUpZoomSupport(); -} - -let zoom = null; -const maxZoom = 0.3; - -function setUpZoomSupport() { - // Set up zoom support for Graph - zoom = d3.behavior.zoom().on("zoom", () => { - innerSvg.attr( - "transform", - `translate(${d3.event.translate})scale(${d3.event.scale})` - ); - }); - svg.call(zoom); - - // Centering the DAG on load - // Get Dagre Graph dimensions - const graphWidth = g.graph().width; - const graphHeight = g.graph().height; - const { width, height } = svg.node().viewBox.animVal; - const padding = width * 0.05; - - // Calculate applicable scale for zoom - - const zoomScale = - Math.min(Math.min(width / graphWidth, height / graphHeight), maxZoom) * 0.8; - - zoom.translate([width / 2 - (graphWidth * zoomScale) / 2 + padding, padding]); - zoom.scale(zoomScale); - zoom.event(innerSvg); - zoom.scaleExtent([0, maxZoom]); -} - -function highlightNodes(nodes) { - nodes.forEach((nodeid) => { - const myNode = g.node(nodeid).elem; - d3.select(myNode) - .selectAll("rect, circle") - .attr("data-highlight", "highlight"); - }); -} - -function unHighlightNodes(nodes) { - nodes.forEach((nodeid) => { - const myNode = g.node(nodeid).elem; - d3.select(myNode).selectAll("rect, circle").attr("data-highlight", null); - }); -} - -d3.selectAll(".js-state-legend-item") - .on("mouseover", function mouseover() { - if (!stateIsSet()) { - const state = $(this).data("state"); - focusState(state); - } - }) - .on("mouseout", () => { - if (!stateIsSet()) { - clearFocus(); - } - }); - -d3.selectAll(".js-state-legend-item").on("click", function click() { - const state = $(this).data("state"); - - clearFocus(); - if (!stateFocusMap[state]) { - const color = d3.select(this).style("border-color"); - focusState(state, this, color); - setFocusMap(state); - } else { - setFocusMap(); - d3.selectAll(".js-state-legend-item").style("background-color", null); - } -}); - -// Returns true if a node's id or its children's id matches searchText -function nodeMatches(nodeId, searchText) { - if (nodeId.indexOf(searchText) > -1) return true; - - // The node's own id does not match, it may have children that match - const node = g.node(nodeId); - if (node.children) { - const children = getChildrenIds(node); - return !!children.find((child) => child.indexOf(searchText) > -1); - } - return false; -} - -d3.select("#searchbox").on("keyup", () => { - const s = document.getElementById("searchbox").value; - - if (s === "") return; - - let match = null; - - if (stateIsSet()) { - clearFocus(); - setFocusMap(); - } - - d3.selectAll("g.nodes g.node").filter(function highlight(d) { - if (s === "") { - d3.selectAll("g.edgePaths, g.edgeLabel").attr("data-highlight", null); - d3.select(this).attr("data-highlight", null); - } else { - d3.selectAll("g.edgePaths, g.edgeLabel").attr("data-highlight", "fade"); - if (nodeMatches(d, s)) { - if (!match) match = this; - d3.select(this).attr("data-highlight", null); - } else { - d3.select(this).attr("data-highlight", "fade"); - } - } - // We don't actually use the returned results from filter - return null; - }); - - // This moves the matched node to the center of the graph area - if (match) { - focusGroup(match.id, false); - } -}); - -function clearFocus() { - d3.selectAll("g.node, g.edgePaths, g.edgeLabel").attr("data-highlight", null); - localStorage.removeItem(focusedGroupKey(dagId)); -} - -function focusState(state, node, color) { - d3.selectAll("g.node, g.edgePaths, g.edgeLabel").attr( - "data-highlight", - "fade" - ); - d3.selectAll(`g.node.${state}`).attr("data-highlight", null); - d3.selectAll(`g.node.${state} rect`).attr("data-highlight", null); - d3.select(node).style("background-color", color); -} - -function setFocusMap(state) { - Object.keys(stateFocusMap).forEach((key) => { - if ({}.hasOwnProperty.call(stateFocusMap, key)) { - stateFocusMap[key] = false; - } - }); - if (state != null) { - stateFocusMap[state] = true; - } -} - -const stateIsSet = () => - !!Object.keys(stateFocusMap).find((key) => stateFocusMap[key]); - -let refreshInterval; - -function startOrStopRefresh() { - if ($("#auto_refresh").is(":checked")) { - refreshInterval = setInterval(() => { - handleRefresh(); - }, autoRefreshInterval * 1000); - } else { - clearInterval(refreshInterval); - } -} - -// pause autorefresh when the page is not active -const handleVisibilityChange = () => { - if (document.hidden) { - clearInterval(refreshInterval); - } else { - initRefresh(); - } -}; - -document.addEventListener("visibilitychange", handleVisibilityChange); - -let prevTis; - -function handleRefresh() { - $("#loading-dots").css("display", "inline-block"); - $.get(getTaskInstanceURL) - .done((tis) => { - // only refresh if the data has changed - if (prevTis !== tis) { - // eslint-disable-next-line no-global-assign - updateNodesStates(tis); - - // Only redraw the graph if labels have changed - const haveLabelsChanged = updateNodeLabels(nodes, tis); - if (haveLabelsChanged) draw(); - - // end refresh if all states are final - const isFinal = checkRunState(); - if (isFinal) { - $("#auto_refresh").prop("checked", false); - clearInterval(refreshInterval); - } - } - prevTis = tis; - setTimeout(() => { - $("#loading-dots").hide(); - }, 500); - $("#error").hide(); - }) - .fail((response, textStatus, err) => { - const description = - (response.responseJSON && response.responseJSON.error) || - "Something went wrong."; - $("#error_msg").text(`${textStatus}: ${err} ${description}`); - $("#error").show(); - setTimeout(() => { - $("#loading-dots").hide(); - }, 500); - $("#chart_section").hide(1000); - $("#datatable_section").hide(1000); - }); -} - -$("#auto_refresh").change(() => { - if ($("#auto_refresh").is(":checked")) { - // Run an initial refresh before starting interval if manually turned on - handleRefresh(); - localStorage.removeItem("disableAutoRefresh"); - } else { - localStorage.setItem("disableAutoRefresh", "true"); - } - startOrStopRefresh(); -}); - -function initRefresh() { - const isDisabled = localStorage.getItem("disableAutoRefresh"); - const isFinal = checkRunState(); - $("#auto_refresh").prop( - "checked", - !(isDisabled || isFinal) && isSchedulerRunning === "True" - ); - startOrStopRefresh(); - d3.select("#refresh_button").on("click", () => handleRefresh()); -} - -// Generate tooltip for a group node -function groupTooltip(node, tis) { - const numMap = finalStatesMap(); - - let minStart; - let maxEnd; - - if (node.isMapped) { - const firstChildId = node.children[0].id; - const mappedLength = tis[firstChildId].mapped_states.length; - [...Array(mappedLength).keys()].forEach((mapIndex) => { - const groupStates = getChildrenIds(node).map( - (child) => tis[child].mapped_states[mapIndex] - ); - const overallState = - priority.find((state) => groupStates.includes(state)) || "no_status"; - if (numMap.has(overallState)) - numMap.set(overallState, numMap.get(overallState) + 1); - }); - } else { - getChildrenIds(node).forEach((child) => { - if (child in tis) { - const ti = tis[child]; - if (!minStart || moment(ti.start_date).isBefore(minStart)) { - minStart = moment(ti.start_date); - } - if (!maxEnd || moment(ti.end_date).isAfter(maxEnd)) { - maxEnd = moment(ti.end_date); - } - const stateKey = ti.state == null ? "no_status" : ti.state; - if (numMap.has(stateKey)) - numMap.set(stateKey, numMap.get(stateKey) + 1); - } - }); - } - - const groupDuration = convertSecsToHumanReadable( - moment(maxEnd).diff(minStart, "second") - ); - const tooltipText = node.tooltip ? `<p>${node.tooltip}</p>` : ""; - - let tt = ` - ${tooltipText} - <strong>Duration:</strong> ${groupDuration} <br><br> - `; - numMap.forEach((key, val) => { - if (key > 0) { - tt += `<strong>${escapeHtml(val)}:</strong> ${key} <br>`; - } - }); - - return tt; -} - -// Assigning css classes based on state to nodes -// Initiating the tooltips -function updateNodesStates(tis) { - g.nodes().forEach((nodeId) => { - const node = g.node(nodeId); - const { elem } = node; - const taskId = nodeId; - - if (elem) { - const classes = `node enter ${getNodeState(nodeId, tis)}`; - elem.setAttribute("class", classes); - elem.setAttribute("data-toggle", "tooltip"); - - elem.onmouseover = (evt) => { - let tt; - if (taskId in tis) { - tt = tiTooltip(tis[taskId], tasks[taskId]); - } else if (node.children) { - tt = groupTooltip(node, tis); - } else if (taskId in tasks) { - tt = taskNoInstanceTooltip(taskId, tasks[taskId]); - elem.setAttribute("class", `${classes} not-allowed`); - } - if (tt) taskTip.show(tt, evt.target); // taskTip is defined in graph.html - }; - elem.onmouseout = taskTip.hide; - elem.onclick = taskTip.hide; - } - }); -} - -// Returns list of children id of the given task group -function getChildrenIds(group) { - const children = []; - Object.values(group.children).forEach((value) => { - if (value.children === undefined) { - // node - children.push(value.id); - } else { - // group - const subGroupChildren = getChildrenIds(value); - subGroupChildren.forEach((id) => children.push(id)); - } - }); - return children; -} - -// Return list of all task group ids in the given task group including the given group itself. -function getAllGroupIds(group) { - const children = [group.id]; - - Object.entries(group.children).forEach(([, val]) => { - if (val.children !== undefined) { - // group - const subGroupChildren = getAllGroupIds(val); - subGroupChildren.forEach((id) => children.push(id)); - } - }); - return children; -} - -// Return the state for the node based on the state of its taskinstance or that of its -// children if it's a group node -function getNodeState(nodeId, tis) { - const node = g.node(nodeId); - - if (node.children === undefined) { - if (nodeId in tis) { - return tis[nodeId].state || "no_status"; - } - return "no_status"; - } - const children = getChildrenIds(node); - - const childrenStates = new Set(); - children.forEach((taskId) => { - if (taskId in tis) { - const { state } = tis[taskId]; - childrenStates.add(state == null ? "no_status" : state); - } - }); - - return priority.find((state) => childrenStates.has(state)) || "no_status"; -} - -// Returns the key used to store expanded task group ids in localStorage -function expandedGroupsKey() { - return `expandedGroups_${dagId}`; -} - -// Returns the key used to store the focused task group id in localStorage -function focusedGroupKey() { - return `focused_group_${dagId}`; -} - -// Focus the graph on the expanded/collapsed node -function focusGroup(nodeId, followMouse = true) { - if (nodeId != null && zoom != null) { - const { x, y } = g.node(nodeId); - // This is the total canvas size. - const { width, height } = svg.node().viewBox.animVal; - - // This is the size of the node or the cluster (i.e. group) - let rect = d3 - .selectAll("g.node") - .filter((n) => n === nodeId) - .select("rect"); - if (rect.empty()) - rect = d3 - .selectAll("g.cluster") - .filter((n) => n === nodeId) - .select("rect"); - - const [mouseX, mouseY] = d3.mouse(svg.node()); - - // Is there a better way to get nodeWidth and nodeHeight ? - const [nodeWidth, nodeHeight] = [ - rect[0][0].attributes.width.value, - rect[0][0].attributes.height.value, - ]; - - // Calculate zoom scale to fill most of the canvas with the node/cluster in focus. - const scale = - Math.min(Math.min(width / nodeWidth, height / nodeHeight), maxZoom) * 0.4; - - // Move the graph so that the node that was expanded/collapsed is centered around - // the mouse click. - const [toX, toY] = followMouse ? [mouseX, mouseY] : [width / 2, height / 5]; - const [deltaX, deltaY] = [ - toX - x * scale, - toY + (nodeHeight / 2 - y) * scale, - ]; - zoom.translate([deltaX, deltaY]); - zoom.scale(scale); - zoom.event(innerSvg); - - const children = new Set(g.children(nodeId)); - // Set data attr to highlight the focused group (via CSS). - d3.selectAll("g.nodes g.node").forEach(function cssHighlight(d) { - if (d === nodeId || children.has(d)) { - d3.select(this).attr("data-highlight", null); - } else { - d3.select(this).attr("data-highlight", "fade"); - } - }); - - localStorage.setItem(focusedGroupKey(dagId), nodeId); - } -} - -// Expands a group node -function expandGroup(nodeId, node) { - node.children.forEach((val) => { - // Set children nodes - g.setNode(val.id, val.value); - mapTaskToNode.set(val.id, val.id); - g.node(val.id).id = val.id; - if (val.children !== undefined) { - // Set children attribute so that the group can be expanded later when needed. - const groupNode = g.node(val.id); - groupNode.children = val.children; - // Map task that are under this node to this node's id - getChildrenIds(val).forEach((childId) => - mapTaskToNode.set(childId, val.id) - ); - } - // Only call setParent if node is not the root node. - if (nodeId != null) g.setParent(val.id, nodeId); - }); - - // Add edges - edges.forEach((edge) => { - const sourceId = mapTaskToNode.get(edge.source_id); - const targetId = mapTaskToNode.get(edge.target_id); - if ( - sourceId !== targetId && - !g.hasEdge(sourceId, targetId) && - sourceId && - targetId - ) { - g.setEdge(sourceId, targetId, { - curve: d3.curveBasis, - arrowheadClass: "arrowhead", - label: edge.label, - }); - } - }); - - g.edges().forEach((edge) => { - // Remove edges that were associated with the expanded group node.. - if (nodeId === edge.v || nodeId === edge.w) { - g.removeEdge(edge.v, edge.w); - } - }); - - saveExpandedGroup(nodeId); -} - -function getSavedGroups() { - let expandedGroups; - try { - expandedGroups = new Set( - JSON.parse(localStorage.getItem(expandedGroupsKey(dagId))) - ); - } catch { - expandedGroups = new Set(); - } - - return expandedGroups; -} - -// Clean up invalid group_ids from saved_group_ids (e.g. due to DAG changes) -function pruneInvalidSavedGroupIds() { - // All the groupIds in the whole DAG - const allGroupIds = new Set(getAllGroupIds(nodes)); - let expandedGroups = getSavedGroups(dagId); - expandedGroups = Array.from(expandedGroups).filter((groupId) => - allGroupIds.has(groupId) - ); - localStorage.setItem( - expandedGroupsKey(dagId), - JSON.stringify(expandedGroups) - ); -} - -// Remember the expanded groups in local storage so that it can be used -// to restore the expanded state of task groups. -function saveExpandedGroup(nodeId) { - // expandedGroups is a Set - const expandedGroups = getSavedGroups(dagId); - expandedGroups.add(nodeId); - localStorage.setItem( - expandedGroupsKey(dagId), - JSON.stringify(Array.from(expandedGroups)) - ); -} - -// Remove the nodeId from the expanded state -function removeExpandedGroup(nodeId, node) { - const expandedGroups = getSavedGroups(dagId); - const childGroupIds = getAllGroupIds(node); - childGroupIds.forEach((childId) => expandedGroups.delete(childId)); - localStorage.setItem( - expandedGroupsKey(dagId), - JSON.stringify(Array.from(expandedGroups)) - ); -} - -// Restore previously expanded task groups -function expandSavedGroups(expandedGroups, node) { - if (node.children === undefined) return; - - node.children.forEach((childNode) => { - if (expandedGroups.has(childNode.id)) { - expandGroup(childNode.id, g.node(childNode.id)); - - expandSavedGroups(expandedGroups, childNode); - } - }); -} - -pruneInvalidSavedGroupIds(); -const focusNodeId = localStorage.getItem(focusedGroupKey(dagId)); -const expandedGroups = getSavedGroups(dagId); - -// Always expand the root node -expandGroup(null, nodes); - -// Expand the node that were previously expanded -expandSavedGroups(expandedGroups, nodes); - -// Draw once after all groups have been expanded -updateNodeLabels(nodes, taskInstances); -draw(); - -// Restore focus (if available) -if (g.hasNode(focusNodeId)) { - focusGroup(focusNodeId); -} - -initRefresh(); diff --git a/airflow/www/static/js/task_instances.js b/airflow/www/static/js/task_instances.js index abadfaab00..47a5dccb6f 100644 --- a/airflow/www/static/js/task_instances.js +++ b/airflow/www/static/js/task_instances.js @@ -17,7 +17,7 @@ * under the License. */ -/* global window, moment, convertSecsToHumanReadable */ +/* global moment, convertSecsToHumanReadable */ // We don't re-import moment again, otherwise webpack will include it twice in the bundle! import { escapeHtml } from "./main"; @@ -147,50 +147,3 @@ export default function tiTooltip(ti, task, { includeTryNumber = false } = {}) { tt += generateTooltipDateTimes(ti.start_date, ti.end_date, dagTZ || "UTC"); return tt; } - -export function taskNoInstanceTooltip(taskId, task) { - let tt = ""; - if (taskId) { - tt += `Task_id: ${escapeHtml(taskId)}<br>`; - } - if (task.operator_name !== undefined) { - tt += `Operator: ${escapeHtml(task.operator_name)}<br>`; - } - tt += "<br><em>DAG has yet to run.</em>"; - return tt; -} - -export function taskQueuedStateTooltip(ti) { - let tt = ""; - tt += "<strong>Status:</strong> Queued<br><br>"; - if (ti.task_id) { - tt += `Task_id: ${escapeHtml(ti.task_id)}<br>`; - } - tt += `Run: ${formatDateTime(ti.execution_date)}<br>`; - if (ti.run_id !== undefined) { - tt += `Run Id: <nobr>${escapeHtml(ti.run_id)}</nobr><br>`; - } - if (ti.operator_name !== undefined) { - tt += `Operator: ${escapeHtml(ti.operator_name)}<br>`; - } - if (ti.start_date && ti.queued_dttm) { - const startDate = - ti.start_date instanceof moment ? ti.start_date : moment(ti.start_date); - const queuedDate = - ti.queued_dttm instanceof moment - ? ti.queued_dttm - : moment(ti.queued_dttm); - const duration = startDate.diff(queuedDate, "second", true); // Set the floating point result flag to true. - tt += `Duration: ${escapeHtml(convertSecsToHumanReadable(duration))}<br>`; - // dagTZ has been defined in dag.html - tt += generateTooltipDateTimes( - ti.queued_dttm, - ti.start_date, - dagTZ || "UTC" - ); - } - return tt; -} - -window.tiTooltip = tiTooltip; -window.taskNoInstanceTooltip = taskNoInstanceTooltip; diff --git a/airflow/www/templates/airflow/dag.html b/airflow/www/templates/airflow/dag.html index 754e17262a..2054433a1b 100644 --- a/airflow/www/templates/airflow/dag.html +++ b/airflow/www/templates/airflow/dag.html @@ -184,11 +184,11 @@ <div class="row"> <div class="col-md-10"> <ul class="nav nav-pills"> - <li><a href="{{ url_for('Airflow.grid', dag_id=dag.dag_id, num_runs=num_runs_arg, root=root, base_date=base_date_arg) }}"> + <li id="grid-nav"><a href="{{ url_for('Airflow.grid', dag_id=dag.dag_id, num_runs=num_runs_arg, root=root, base_date=base_date_arg) }}"> <span class="material-icons" aria-hidden="true">grid_on</span> Grid </a></li> - <li><a href="{{ url_for('Airflow.graph', dag_id=dag.dag_id, root=root, num_runs=num_runs_arg, base_date=base_date_arg, execution_date=execution_date_arg) }}"> + <li id="graph-nav"><a href="{{ url_for('Airflow.graph', dag_id=dag.dag_id, root=root, num_runs=num_runs_arg, base_date=base_date_arg, execution_date=execution_date_arg) }}"> <span class="material-icons" aria-hidden="true">account_tree</span> Graph</a></li> <li><a href="{{ url_for('Airflow.calendar', dag_id=dag.dag_id) }}"> @@ -204,7 +204,7 @@ <li><a href="{{ url_for('Airflow.landing_times', dag_id=dag.dag_id, days=30, root=root, num_runs=num_runs_arg, base_date=base_date_arg) }}"> <span class="material-icons" aria-hidden="true">flight_land</span> Landing Times</a></li> - <li><a href="{{ url_for('Airflow.gantt', dag_id=dag.dag_id, root=root, num_runs=num_runs_arg, base_date=base_date_arg, execution_date=execution_date_arg) }}"> + <li id="gantt-nav"><a href="{{ url_for('Airflow.gantt', dag_id=dag.dag_id, root=root, num_runs=num_runs_arg, base_date=base_date_arg, execution_date=execution_date_arg) }}"> <span class="material-icons" aria-hidden="true">vertical_distribute</span> Gantt</a></li> <li><a href="{{ url_for('Airflow.dag_details', dag_id=dag.dag_id) }}"> diff --git a/airflow/www/templates/airflow/graph.html b/airflow/www/templates/airflow/graph.html deleted file mode 100644 index 64b9a03c73..0000000000 --- a/airflow/www/templates/airflow/graph.html +++ /dev/null @@ -1,146 +0,0 @@ -{# - 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. -#} - -{% extends "airflow/dag.html" %} -{% from 'appbuilder/loading_dots.html' import loading_dots %} - -{% block page_title %}{{ dag.dag_id }} - Graph - {{ appbuilder.app_name }}{% endblock %} - -{% block head_meta %} - {{ super() }} - <meta name="dag_run_id" content="{{ dag_run_id }}"> - <meta name="execution_date" content="{{ execution_date }}"> - <meta name="arrange" content="{{ arrange }}"> - <meta name="task_instances_url" content="{{ url_for('Airflow.task_instances') }}"> -{% endblock %} - -{% block head_css %} - {{ super() }} - <link rel="stylesheet" type="text/css" href="{{ url_for_asset('graph.css') }}"> - <style type="text/css"> - {% for state, state_color in state_color_mapping.items() %} - g.node.{{state}} rect { - stroke: {{state_color}}; - } - {% endfor %} - </style> -{% endblock %} - -{% block content %} - {{ super() }} - <div class="row dag-view-tools"> - <div class="col-md-10"> - <form method="get" class="form-inline"> - <input type="hidden" name="root" value="{{ root }}"> - <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> - <div class="form-group"> - <label class="sr-only" for="execution_date">Run</label> - <div class="input-group"> - <div class="input-group-addon">Run</div> - {{ form.execution_date(class_="form-control", disabled=not(dag.has_dag_runs())) }} - </div> - </div> - <div class="form-group"> - <label class="sr-only" for="arrange">Layout</label> - <div class="input-group"> - <div class="input-group-addon">Layout</div> - {{ form.arrange(class_="form-control") }} - </div> - </div> - <button type="submit" class="btn">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 class="col-md-2 text-right"> - <label class="sr-only" for="searchbox">Search</label> - <input type="search" class="form-control" id="searchbox" placeholder="Find Taskā¦"> - </div> - </div> - - <div class="legend-row"> - <div> - {% for op in operators %}<span class="legend-item" style="color: {{ op.ui_fgcolor }};background: {{ op.ui_color }};"> - {{ op.operator_name }}</span>{% endfor %} - </div> - <div> - {% for state, state_color in state_color_mapping.items() %} - <span class="legend-item legend-item--interactive js-state-legend-item" data-state="{{state}}" style="border-color: {{state_color}};"> - {{state}} - </span> - {% endfor %} - <span class="legend-item legend-item--interactive legend-item--no-border js-state-legend-item" data-state="no_status" style="border-color:white;"> - no_status - </span> - </div> - </div> - <div id="error" style="display: none; margin-top: 10px;" class="alert alert-danger" role="alert"> - <span class="material-icons" aria-hidden="true">error</span> - <span id="error_msg">Oops.</span> - </div> - <br> - <div class="refresh-actions"> - {{ loading_dots(id='loading-dots', classes='refresh-loading') }} - <label class="switch-label"> - <input class="switch-input" id="auto_refresh" type="checkbox" {% if dag_run_state == 'running' %}checked{% endif %}> - <span class="switch" aria-hidden="true"></span> - Auto-refresh - </label> - <button class="btn btn-default btn-sm" id="refresh_button"> - <span class="material-icons" aria-hidden="true">refresh</span> - </button> - </div> - <div class="svg-wrapper"> - <div class="graph-svg-wrap"> - <svg id="graph-svg" viewBox="0 0 100 100"> - <g id="dig" transform="translate(20,20)"></g> - </svg> - </div> - </div> -{% endblock %} - -{% block tail %} - {{ super() }} - <script> - const nodes = {{ nodes|tojson }}; - const edges = {{ edges|tojson }}; - const tasks = {{ tasks|tojson }}; - let taskInstances = {{ task_instances|tojson }}; - const autoRefreshInterval = {{ auto_refresh_interval }}; - const priority = {{ state_priority|tojson }}; - </script> - <script src="{{ url_for_asset('d3.min.js') }}"></script> - <script src="{{ url_for_asset('dagre-d3.min.js') }}"></script> - <script src="{{ url_for_asset('d3-shape.min.js') }}"></script> - <script src="{{ url_for_asset('d3-tip.js') }}"></script> - <script src="{{ url_for_asset('graph.js') }}"></script> -{% endblock %} diff --git a/airflow/www/views.py b/airflow/www/views.py index fd4f8192ca..4227d13eb5 100644 --- a/airflow/www/views.py +++ b/airflow/www/views.py @@ -72,7 +72,7 @@ from pygments.formatters import HtmlFormatter from sqlalchemy import Date, and_, case, desc, func, inspect, or_, select, union_all from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload -from wtforms import SelectField, validators +from wtforms import validators import airflow from airflow import models, plugins_manager, settings @@ -136,7 +136,6 @@ from airflow.www.forms import ( DagRunEditForm, DateTimeForm, DateTimeWithNumRunsForm, - DateTimeWithNumRunsWithDagRunsForm, TaskInstanceEditForm, create_connection_form_class, ) @@ -3125,105 +3124,21 @@ class Airflow(AirflowBaseView): @action_logging @provide_session def graph(self, dag_id: str, session: Session = NEW_SESSION): - """Get DAG as Graph.""" + """Redirect to the replacement - grid + graph. Kept for backwards compatibility.""" dag = get_airflow_app().dag_bag.get_dag(dag_id, session=session) - dag_model = DagModel.get_dagmodel(dag_id, session=session) - if not dag: - flash(f'DAG "{dag_id}" seems to be missing.', "error") - return redirect(url_for("Airflow.index")) - - wwwutils.check_import_errors(dag.fileloc, session) - wwwutils.check_dag_warnings(dag.dag_id, session) - - root = request.args.get("root") - if root: - filter_upstream = request.args.get("filter_upstream") == "true" - filter_downstream = request.args.get("filter_downstream") == "true" - dag = dag.partial_subset( - task_ids_or_regex=root, include_upstream=filter_upstream, include_downstream=filter_downstream - ) - arrange = request.args.get("arrange", dag.orientation) - - nodes = task_group_to_dict(dag.task_group) - edges = dag_edges(dag) - dt_nr_dr_data = get_date_time_num_runs_dag_runs_form_data(request, session, dag) - - dt_nr_dr_data["arrange"] = arrange dttm = dt_nr_dr_data["dttm"] dag_run = dag.get_dagrun(execution_date=dttm) dag_run_id = dag_run.run_id if dag_run else None - class GraphForm(DateTimeWithNumRunsWithDagRunsForm): - """Graph Form class.""" - - arrange = SelectField( - "Layout", - choices=( - ("LR", "Left > Right"), - ("RL", "Right > Left"), - ("TB", "Top > Bottom"), - ("BT", "Bottom > Top"), - ), - ) - - form = GraphForm(data=dt_nr_dr_data) - form.execution_date.choices = dt_nr_dr_data["dr_choices"] - - task_instances = {} - for ti in dag.get_task_instances(dttm, dttm): - if ti.task_id not in task_instances: - task_instances[ti.task_id] = wwwutils.get_instance_with_map(ti, session) - # Need to add operator_name explicitly because it's not a column in task_instances model. - task_instances[ti.task_id]["operator_name"] = ti.operator_name - tasks = { - t.task_id: { - "dag_id": t.dag_id, - "task_type": t.task_type, - "operator_name": t.operator_name, - "extra_links": t.extra_links, - "is_mapped": isinstance(t, MappedOperator), - "trigger_rule": t.trigger_rule, - } - for t in dag.tasks + kwargs = { + **sanitize_args(request.args), + "dag_id": dag_id, + "tab": "graph", + "dag_run_id": dag_run_id, } - if not tasks: - flash("No tasks found", "error") - session.commit() - doc_md = wwwutils.wrapped_markdown(getattr(dag, "doc_md", None)) - - task_log_reader = TaskLogReader() - if task_log_reader.supports_external_link: - external_log_name = task_log_reader.log_handler.log_name - else: - external_log_name = None - - state_priority = ["no_status" if p is None else p for p in wwwutils.priority] - return self.render_template( - "airflow/graph.html", - dag=dag, - form=form, - dag_run_id=dag_run_id, - execution_date=dttm.isoformat(), - state_token=wwwutils.state_token(dt_nr_dr_data["dr_state"]), - doc_md=doc_md, - arrange=arrange, - operators=sorted( - {op.operator_name: op for op in dag.tasks}.values(), key=lambda x: x.operator_name - ), - root=root or "", - task_instances=task_instances, - tasks=tasks, - nodes=nodes, - edges=edges, - show_external_log_redirect=task_log_reader.supports_external_link, - external_log_name=external_log_name, - dag_run_state=dt_nr_dr_data["dr_state"], - dag_model=dag_model, - auto_refresh_interval=conf.getint("webserver", "auto_refresh_interval"), - state_priority=state_priority, - ) + return redirect(url_for("Airflow.grid", **kwargs)) @expose("/duration") @auth.has_access( diff --git a/airflow/www/webpack.config.js b/airflow/www/webpack.config.js index 98ef0bc3bf..2483faea32 100644 --- a/airflow/www/webpack.config.js +++ b/airflow/www/webpack.config.js @@ -65,7 +65,7 @@ const config = { dagDependencies: `${JS_DIR}/dag_dependencies.js`, dags: [`${CSS_DIR}/dags.css`, `${JS_DIR}/dags.js`], flash: `${CSS_DIR}/flash.css`, - graph: [`${CSS_DIR}/graph.css`, `${JS_DIR}/graph.js`], + graph: `${CSS_DIR}/graph.css`, loadingDots: `${CSS_DIR}/loading-dots.css`, main: [`${CSS_DIR}/main.css`, `${JS_DIR}/main.js`], materialIcons: `${CSS_DIR}/material-icons.css`, diff --git a/docs/apache-airflow/img/graph.png b/docs/apache-airflow/img/graph.png index 860fbff735..2c909c66c3 100644 Binary files a/docs/apache-airflow/img/graph.png and b/docs/apache-airflow/img/graph.png differ diff --git a/docs/apache-airflow/img/mapping-simple-graph.png b/docs/apache-airflow/img/mapping-simple-graph.png index 9e01d027ec..36ec7f9bc1 100644 Binary files a/docs/apache-airflow/img/mapping-simple-graph.png and b/docs/apache-airflow/img/mapping-simple-graph.png differ diff --git a/docs/apache-airflow/img/task_group.gif b/docs/apache-airflow/img/task_group.gif index ac4f6e943c..b7844f862d 100644 Binary files a/docs/apache-airflow/img/task_group.gif and b/docs/apache-airflow/img/task_group.gif differ diff --git a/tests/www/views/test_views_decorators.py b/tests/www/views/test_views_decorators.py index 13ede6273d..80eb588f29 100644 --- a/tests/www/views/test_views_decorators.py +++ b/tests/www/views/test_views_decorators.py @@ -100,36 +100,36 @@ def some_view_action_which_requires_dag_edit_access(*args) -> bool: def test_action_logging_get(session, admin_client): url = ( - f"dags/example_bash_operator/graph?" + f"dags/example_bash_operator/grid?" f"execution_date={urllib.parse.quote_plus(str(EXAMPLE_DAG_DEFAULT_DATE))}" ) resp = admin_client.get(url, follow_redirects=True) - check_content_in_response("runme_1", resp) + check_content_in_response("success", resp) # In mysql backend, this commit() is needed to write down the logs session.commit() _check_last_log( session, dag_id="example_bash_operator", - event="graph", + event="grid", execution_date=EXAMPLE_DAG_DEFAULT_DATE, ) def test_action_logging_get_legacy_view(session, admin_client): url = ( - f"graph?dag_id=example_bash_operator&" + f"tree?dag_id=example_bash_operator&" f"execution_date={urllib.parse.quote_plus(str(EXAMPLE_DAG_DEFAULT_DATE))}" ) resp = admin_client.get(url, follow_redirects=True) - check_content_in_response("runme_1", resp) + check_content_in_response("success", resp) # In mysql backend, this commit() is needed to write down the logs session.commit() _check_last_log( session, dag_id="example_bash_operator", - event="legacy_graph", + event="legacy_tree", execution_date=EXAMPLE_DAG_DEFAULT_DATE, ) diff --git a/tests/www/views/test_views_graph.py b/tests/www/views/test_views_graph.py deleted file mode 100644 index 3a49404d63..0000000000 --- a/tests/www/views/test_views_graph.py +++ /dev/null @@ -1,348 +0,0 @@ -# -# 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. -from __future__ import annotations - -from datetime import timedelta -from urllib.parse import quote - -import pytest - -from airflow.configuration import conf -from airflow.models import DAG, DagRun -from airflow.models.baseoperator import BaseOperator -from airflow.utils import timezone -from airflow.utils.session import create_session, provide_session -from airflow.utils.state import State, TaskInstanceState - -DAG_ID = "dag_for_testing_dt_nr_dr_form" -DEFAULT_DATE = timezone.datetime(2017, 9, 1) -RUNS_DATA = [ - ("dag_run_for_testing_dt_nr_dr_form_4", timezone.datetime(2018, 4, 4)), - ("dag_run_for_testing_dt_nr_dr_form_3", timezone.datetime(2018, 3, 3)), - ("dag_run_for_testing_dt_nr_dr_form_2", timezone.datetime(2018, 2, 2)), - ("dag_run_for_testing_dt_nr_dr_form_1", timezone.datetime(2018, 1, 1)), -] -VERY_CLOSE_RUNS_DATE = timezone.datetime(2020, 1, 1, 0, 0, 0) - -ENDPOINTS = [ - "/graph?dag_id=dag_for_testing_dt_nr_dr_form", -] - - [email protected](scope="module") -def dag(app): - dag = DAG(DAG_ID, start_date=DEFAULT_DATE) - app.dag_bag.bag_dag(dag=dag, root_dag=dag) - return dag - - -@provide_session [email protected](scope="module") -def runs(dag, session): - dag_runs = [ - dag.create_dagrun( - run_id=run_id, - execution_date=execution_date, - data_interval=(execution_date, execution_date), - state=State.SUCCESS, - external_trigger=True, - ) - for run_id, execution_date, in RUNS_DATA - ] - yield dag_runs - for dag_run in dag_runs: - session.delete(dag_run) - - -def _assert_run_is_in_dropdown_not_selected(run, data): - exec_date = run.execution_date.isoformat() - assert f'<option value="{exec_date}">{run.run_id}</option>' in data - - -def _assert_run_is_selected(run, data): - exec_date = run.execution_date.isoformat() - assert f'<option selected value="{exec_date}">{run.run_id}</option>' in data - - -def _assert_base_date(base_date, data): - assert f'name="base_date" required type="text" value="{base_date.isoformat()}"' in data - - -def _assert_base_date_and_num_runs(base_date, num, data): - assert f'name="base_date" value="{base_date}"' not in data - assert f'<option selected="" value="{num}">{num}</option>' not in data - - -def _assert_run_is_not_in_dropdown(run, data): - assert run.execution_date.isoformat() not in data - assert run.run_id not in data - - [email protected]("endpoint", ENDPOINTS) -def test_with_default_parameters(admin_client, runs, endpoint): - """ - Tests view with no URL parameter. - Should show all dag runs in the drop down. - Should select the latest dag run. - Should set base date to current date (not asserted) - """ - response = admin_client.get( - endpoint, data={"username": "test", "password": "test"}, follow_redirects=True - ) - assert response.status_code == 200 - - data = response.data.decode() - assert '<label class="sr-only" for="base_date">Base date</label>' in data - assert '<label class="sr-only" for="num_runs">Number of runs</label>' in data - - _assert_run_is_selected(runs[0], data) - _assert_run_is_in_dropdown_not_selected(runs[1], data) - _assert_run_is_in_dropdown_not_selected(runs[2], data) - _assert_run_is_in_dropdown_not_selected(runs[3], data) - - [email protected]("endpoint", ENDPOINTS) -def test_with_execution_date_parameter_only(admin_client, runs, endpoint): - """ - Tests view with execution_date URL parameter. - Scenario: click link from dag runs view. - Should only show dag runs older than execution_date in the drop down. - Should select the particular dag run. - Should set base date to execution date. - """ - response = admin_client.get( - f"{endpoint}&execution_date={runs[1].execution_date.isoformat()}", - data={"username": "test", "password": "test"}, - follow_redirects=True, - ) - assert response.status_code == 200 - - data = response.data.decode() - _assert_base_date_and_num_runs( - runs[1].execution_date, - conf.getint("webserver", "default_dag_run_display_number"), - data, - ) - _assert_run_is_not_in_dropdown(runs[0], data) - _assert_run_is_selected(runs[1], data) - _assert_run_is_in_dropdown_not_selected(runs[2], data) - _assert_run_is_in_dropdown_not_selected(runs[3], data) - - [email protected]("endpoint", ENDPOINTS) -def test_with_base_date_and_num_runs_parameters_only(admin_client, runs, endpoint): - """ - Tests view with base_date and num_runs URL parameters. - Should only show dag runs older than base_date in the drop down, - limited to num_runs. - Should select the latest dag run. - Should set base date and num runs to submitted values. - """ - response = admin_client.get( - f"{endpoint}&base_date={runs[1].execution_date.isoformat()}&num_runs=2", - data={"username": "test", "password": "test"}, - follow_redirects=True, - ) - assert response.status_code == 200 - - data = response.data.decode() - _assert_base_date_and_num_runs(runs[1].execution_date, 2, data) - _assert_run_is_not_in_dropdown(runs[0], data) - _assert_run_is_selected(runs[1], data) - _assert_run_is_in_dropdown_not_selected(runs[2], data) - _assert_run_is_not_in_dropdown(runs[3], data) - - [email protected]("endpoint", ENDPOINTS) -def test_with_base_date_and_num_runs_and_execution_date_outside(admin_client, runs, endpoint): - """ - Tests view with base_date and num_runs and execution-date URL parameters. - Scenario: change the base date and num runs and press "Go", - the selected execution date is outside the new range. - Should only show dag runs older than base_date in the drop down. - Should select the latest dag run within the range. - Should set base date and num runs to submitted values. - """ - base_date = runs[1].execution_date.isoformat() - exec_date = runs[0].execution_date.isoformat() - response = admin_client.get( - f"{endpoint}&base_date={base_date}&num_runs=42&execution_date={exec_date}", - data={"username": "test", "password": "test"}, - follow_redirects=True, - ) - assert response.status_code == 200 - - data = response.data.decode() - _assert_base_date_and_num_runs(runs[1].execution_date, 42, data) - _assert_run_is_not_in_dropdown(runs[0], data) - _assert_run_is_selected(runs[1], data) - _assert_run_is_in_dropdown_not_selected(runs[2], data) - _assert_run_is_in_dropdown_not_selected(runs[3], data) - - [email protected]("endpoint", ENDPOINTS) -def test_with_base_date_and_num_runs_and_execution_date_within(admin_client, runs, endpoint): - """ - Tests view with base_date and num_runs and execution-date URL parameters. - Scenario: change the base date and num runs and press "Go", - the selected execution date is within the new range. - Should only show dag runs older than base_date in the drop down. - Should select the dag run with the execution date. - Should set base date and num runs to submitted values. - """ - base_date = runs[2].execution_date.isoformat() - exec_date = runs[3].execution_date.isoformat() - response = admin_client.get( - f"{endpoint}&base_date={base_date}&num_runs=5&execution_date={exec_date}", - data={"username": "test", "password": "test"}, - follow_redirects=True, - ) - assert response.status_code == 200 - - data = response.data.decode() - _assert_base_date_and_num_runs(runs[2].execution_date, 5, data) - _assert_run_is_not_in_dropdown(runs[0], data) - _assert_run_is_not_in_dropdown(runs[1], data) - _assert_run_is_in_dropdown_not_selected(runs[2], data) - _assert_run_is_selected(runs[3], data) - - -@provide_session [email protected] -def very_close_dagruns(dag, session): - dag_runs = [] - for idx, (run_id, _) in enumerate(RUNS_DATA): - execution_date = VERY_CLOSE_RUNS_DATE.replace(microsecond=idx) - dag_runs.append( - dag.create_dagrun( - run_id=run_id + "_close", - execution_date=execution_date, - data_interval=(execution_date, execution_date), - state=State.SUCCESS, - external_trigger=True, - ) - ) - yield dag_runs - for dag_run in dag_runs: - session.delete(dag_run) - session.commit() - - [email protected]("endpoint", ENDPOINTS) -def test_rounds_base_date_but_queries_with_execution_date(admin_client, very_close_dagruns, endpoint): - exec_date = quote(very_close_dagruns[1].execution_date.isoformat()) - response = admin_client.get( - f"{endpoint}&num_runs=2&execution_date={exec_date}", - data={"username": "test", "password": "test"}, - follow_redirects=True, - ) - assert response.status_code == 200 - - data = response.data.decode() - _assert_base_date(VERY_CLOSE_RUNS_DATE + timedelta(seconds=1), data) - _assert_run_is_in_dropdown_not_selected(very_close_dagruns[0], data) - _assert_run_is_selected(very_close_dagruns[1], data) - _assert_run_is_not_in_dropdown(very_close_dagruns[2], data) - _assert_run_is_not_in_dropdown(very_close_dagruns[3], data) - - [email protected]("endpoint", ENDPOINTS) -def test_uses_execution_date_on_filter_application_if_base_date_hasnt_changed( - admin_client, very_close_dagruns, endpoint -): - base_date = quote((VERY_CLOSE_RUNS_DATE + timedelta(seconds=1)).isoformat()) - exec_date = quote(very_close_dagruns[1].execution_date.isoformat()) - response = admin_client.get( - f"{endpoint}&base_date={base_date}&num_runs=2&execution_date={exec_date}", - data={"username": "test", "password": "test"}, - follow_redirects=True, - ) - assert response.status_code == 200 - - data = response.data.decode() - _assert_base_date(VERY_CLOSE_RUNS_DATE + timedelta(seconds=1), data) - _assert_run_is_in_dropdown_not_selected(very_close_dagruns[0], data) - _assert_run_is_selected(very_close_dagruns[1], data) - _assert_run_is_not_in_dropdown(very_close_dagruns[2], data) - _assert_run_is_not_in_dropdown(very_close_dagruns[3], data) - - [email protected]("endpoint", ENDPOINTS) -def test_uses_base_date_if_changed_away_from_execution_date(admin_client, very_close_dagruns, endpoint): - base_date = quote((VERY_CLOSE_RUNS_DATE + timedelta(seconds=2)).isoformat()) - exec_date = quote(very_close_dagruns[1].execution_date.isoformat()) - response = admin_client.get( - f"{endpoint}&base_date={base_date}&num_runs=2&execution_date={exec_date}", - data={"username": "test", "password": "test"}, - follow_redirects=True, - ) - assert response.status_code == 200 - - data = response.data.decode() - _assert_base_date(VERY_CLOSE_RUNS_DATE + timedelta(seconds=2), data) - _assert_run_is_not_in_dropdown(very_close_dagruns[0], data) - _assert_run_is_not_in_dropdown(very_close_dagruns[1], data) - _assert_run_is_in_dropdown_not_selected(very_close_dagruns[2], data) - _assert_run_is_selected(very_close_dagruns[3], data) - - [email protected]("endpoint", ENDPOINTS) -def test_view_works_with_deleted_tasks(request, admin_client, app, endpoint): - task_to_state = { - "existing-task": TaskInstanceState.SUCCESS, - "deleted-task-success": TaskInstanceState.SUCCESS, - "deleted-task-failed": TaskInstanceState.FAILED, - } - dag = DAG(DAG_ID, start_date=DEFAULT_DATE) - for task_id in task_to_state.keys(): - BaseOperator(task_id=task_id, dag=dag) - - execution_date = timezone.datetime(2022, 3, 14) - dag_run_id = "test-deleted-tasks-dag-run" - with create_session() as session: - dag_run = dag.create_dagrun( - run_id=dag_run_id, - execution_date=execution_date, - data_interval=(execution_date, execution_date + timedelta(minutes=5)), - state=State.SUCCESS, - external_trigger=True, - session=session, - ) - for ti in dag_run.task_instances: - ti.refresh_from_task(dag.get_task(ti.task_id)) - ti.state = task_to_state[ti.task_id] - ti.start_date = execution_date - ti.end_date = execution_date + timedelta(minutes=5) - session.merge(ti) - - def cleanup_database(): - with create_session() as session: - session.query(DagRun).filter_by(run_id=dag_run_id).delete() - - request.addfinalizer(cleanup_database) - - dag = DAG(DAG_ID, start_date=DEFAULT_DATE) - BaseOperator(task_id="existing-task", dag=dag) - app.dag_bag.bag_dag(dag=dag, root_dag=dag) - - response = admin_client.get( - f"{endpoint}&execution_date={execution_date.isoformat()}", - data={"username": "test", "password": "test"}, - follow_redirects=True, - ) - assert response.status_code == 200 diff --git a/tests/www/views/test_views_tasks.py b/tests/www/views/test_views_tasks.py index 32df99c047..3685f2323c 100644 --- a/tests/www/views/test_views_tasks.py +++ b/tests/www/views/test_views_tasks.py @@ -174,14 +174,14 @@ def client_ti_without_dag_edit(app): id="dag-details-subdag", ), pytest.param( - "graph?dag_id=example_bash_operator", + "object/graph_data?dag_id=example_bash_operator", ["runme_1"], - id="graph-url-param", + id="graph-data", ), pytest.param( - "dags/example_bash_operator/graph", - ["runme_1"], - id="graph", + "object/graph_data?dag_id=example_subdag_operator.section-1", + ["section-1-task-1"], + id="graph-data-subdag", ), pytest.param( "object/grid_data?dag_id=example_bash_operator", @@ -370,7 +370,7 @@ def test_tree_trigger_origin_tree_view(app, admin_client): check_content_in_response(href, resp) -def test_graph_trigger_origin_graph_view(app, admin_client): +def test_graph_trigger_origin_grid_view(app, admin_client): app.dag_bag.get_dag("test_tree_view").create_dagrun( run_type=DagRunType.SCHEDULED, execution_date=DEFAULT_DATE, @@ -381,7 +381,23 @@ def test_graph_trigger_origin_graph_view(app, admin_client): url = "/dags/test_tree_view/graph" resp = admin_client.get(url, follow_redirects=True) - params = {"origin": "/dags/test_tree_view/graph"} + params = {"origin": "/dags/test_tree_view/grid?tab=graph"} + href = f"/dags/test_tree_view/trigger?{html.escape(urllib.parse.urlencode(params))}" + check_content_in_response(href, resp) + + +def test_gantt_trigger_origin_grid_view(app, admin_client): + app.dag_bag.get_dag("test_tree_view").create_dagrun( + run_type=DagRunType.SCHEDULED, + execution_date=DEFAULT_DATE, + data_interval=(DEFAULT_DATE, DEFAULT_DATE), + start_date=timezone.utcnow(), + state=State.RUNNING, + ) + + url = "/dags/test_tree_view/gantt" + resp = admin_client.get(url, follow_redirects=True) + params = {"origin": "/dags/test_tree_view/grid?tab=gantt"} href = f"/dags/test_tree_view/trigger?{html.escape(urllib.parse.urlencode(params))}" check_content_in_response(href, resp) @@ -390,7 +406,10 @@ def test_graph_view_without_dag_permission(app, one_dag_perm_user_client): url = "/dags/example_bash_operator/graph" resp = one_dag_perm_user_client.get(url, follow_redirects=True) assert resp.status_code == 200 - assert resp.request.url == "http://localhost/dags/example_bash_operator/graph" + assert ( + resp.request.url + == "http://localhost/dags/example_bash_operator/grid?tab=graph&dag_run_id=TEST_DAGRUN" + ) check_content_in_response("example_bash_operator", resp) url = "/dags/example_xcom/graph"
