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 e6e5fdb104 Delete all old dag pages and redirect to grid view (#37988)
e6e5fdb104 is described below
commit e6e5fdb1048a3fb9c1b38b83a95ca8f87c6b2bf4
Author: Brent Bovenzi <[email protected]>
AuthorDate: Mon Mar 25 08:11:33 2024 -0700
Delete all old dag pages and redirect to grid view (#37988)
* Delete all old dag pages and redirect to grid view
* Fix linting
* Fix session
* Relax type to appease Mypy
* cleanup dagjs and forward audit log params
* Remove nvd3
* Remove python nvd3 in a separate pr
* newsfragment
* Fix static check in main
* remove extraneous auth checks
---------
Co-authored-by: Tzu-ping Chung <[email protected]>
---
airflow/www/forms.py | 20 -
airflow/www/package.json | 1 -
airflow/www/static/css/calendar.css | 52 ---
airflow/www/static/js/calendar.js | 377 ----------------
airflow/www/static/js/dag.js | 61 ---
airflow/www/templates/airflow/calendar.html | 56 ---
airflow/www/templates/airflow/dag.html | 145 +++---
airflow/www/templates/airflow/dag_audit_log.html | 119 -----
airflow/www/templates/airflow/duration_chart.html | 71 ---
airflow/www/utils.py | 11 -
airflow/www/views.py | 510 ++--------------------
airflow/www/webpack.config.js | 6 -
airflow/www/yarn.lock | 5 -
newsfragments/37988.significant.rst | 1 +
tests/www/views/test_views.py | 12 -
tests/www/views/test_views_decorators.py | 11 -
tests/www/views/test_views_home.py | 10 -
tests/www/views/test_views_tasks.py | 46 --
18 files changed, 91 insertions(+), 1423 deletions(-)
diff --git a/airflow/www/forms.py b/airflow/www/forms.py
index aa5d3a6249..9068445fc7 100644
--- a/airflow/www/forms.py
+++ b/airflow/www/forms.py
@@ -39,7 +39,6 @@ from wtforms.validators import InputRequired, Optional
from airflow.compat.functools import cache
from airflow.configuration import conf
from airflow.providers_manager import ProvidersManager
-from airflow.utils import timezone
from airflow.utils.types import DagRunType
from airflow.www.validators import ReadOnly, ValidConnID
from airflow.www.widgets import (
@@ -99,25 +98,6 @@ class DateTimeForm(FlaskForm):
execution_date = DateTimeWithTimezoneField("Logical date",
widget=AirflowDateTimePickerWidget())
-class DateTimeWithNumRunsForm(FlaskForm):
- """Date time and number of runs form for tree view, task duration and
landing times."""
-
- base_date = DateTimeWithTimezoneField(
- "Anchor date", widget=AirflowDateTimePickerWidget(),
default=timezone.utcnow()
- )
- num_runs = SelectField(
- "Number of runs",
- default=25,
- choices=(
- (5, "5"),
- (25, "25"),
- (50, "50"),
- (100, "100"),
- (365, "365"),
- ),
- )
-
-
class DagRunEditForm(DynamicForm):
"""Form for editing DAG Run.
diff --git a/airflow/www/package.json b/airflow/www/package.json
index 0f6fb546c9..22b6f882d3 100644
--- a/airflow/www/package.json
+++ b/airflow/www/package.json
@@ -128,7 +128,6 @@
"jshint": "^2.13.4",
"lodash": "^4.17.21",
"moment-timezone": "^0.5.43",
- "nvd3": "^1.8.6",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-icons": "^4.9.0",
diff --git a/airflow/www/static/css/calendar.css
b/airflow/www/static/css/calendar.css
deleted file mode 100644
index 6307391ea4..0000000000
--- a/airflow/www/static/css/calendar.css
+++ /dev/null
@@ -1,52 +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.
- */
-
-text.title {
- font-size: 16px;
- font-weight: bold;
-}
-
-text.year-label {
- font-size: 16px;
- font-weight: bold;
-}
-
-text.day-label {
- font-size: 12px;
-}
-
-text.status-label {
- font-size: 11px;
-}
-
-path.month {
- stroke: #a3a3a3;
- stroke-width: 2px;
-}
-
-rect.day {
- stroke: #ccc;
- stroke-width: 1px;
- cursor: pointer;
-}
-
-.legend-item__swatch {
- background-color: #fff;
- border: 1px solid #ccc;
-}
diff --git a/airflow/www/static/js/calendar.js
b/airflow/www/static/js/calendar.js
deleted file mode 100644
index 330bd47344..0000000000
--- a/airflow/www/static/js/calendar.js
+++ /dev/null
@@ -1,377 +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 calendarData, statesColors, document, window, $, d3, moment */
-import { getMetaValue } from "./utils";
-
-const gridUrl = getMetaValue("grid_url");
-
-function getGridViewURL(d) {
- return `${gridUrl}?base_date=${encodeURIComponent(d.toISOString())}`;
-}
-
-// date helpers
-function formatDay(d) {
- return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d];
-}
-
-function toMoment(y, m, d) {
- return moment.utc([y, m, d]);
-}
-
-function weekOfMonth(y, m, d) {
- const monthOffset = toMoment(y, m, 1).day();
- const dayOfMonth = toMoment(y, m, d).date();
- return Math.floor((dayOfMonth + monthOffset - 1) / 7);
-}
-
-function weekOfYear(y, m) {
- const yearOffset = toMoment(y, 0, 1).day();
- const dayOfYear = toMoment(y, m, 1).dayOfYear();
- return Math.floor((dayOfYear + yearOffset - 1) / 7);
-}
-
-function daysInMonth(y, m) {
- const lastDay = toMoment(y, m, 1).add(1, "month").subtract(1, "day");
- return lastDay.date();
-}
-
-function weeksInMonth(y, m) {
- const firstDay = toMoment(y, m, 1);
- const monthOffset = firstDay.day();
- return Math.floor((daysInMonth(y, m) + monthOffset) / 7) + 1;
-}
-
-const dateFormat = "YYYY-MM-DD";
-
-document.addEventListener("DOMContentLoaded", () => {
- $("span.status_square").tooltip({ html: true });
-
- // JSON.parse is faster for large payloads than an object literal
- const rootData = JSON.parse(calendarData);
-
- const dayTip = d3
- .tip()
- .attr("class", "tooltip d3-tip")
- .html((toolTipHtml) => toolTipHtml);
-
- // draw the calendar
- function draw() {
- // display constants
- const leftRightMargin = 32;
- const titleHeight = 24;
- const yearLabelWidth = 34;
- const dayLabelWidth = 14;
- const dayLabelPadding = 4;
- const yearPadding = 20;
- const cellSize = 16;
- const yearHeight = cellSize * 7 + 2;
- const maxWeeksInYear = 53;
- const legendHeight = 30;
- const legendSwatchesPadding = 4;
- const legendSwtchesTextWidth = 44;
-
- // group dag run stats by year -> month -> day -> state
- let dagStates = d3
- .nest()
- .key((dr) => moment.utc(dr.date, dateFormat).year())
- .key((dr) => moment.utc(dr.date, dateFormat).month())
- .key((dr) => moment.utc(dr.date, dateFormat).date())
- .key((dr) => dr.state)
- .map(rootData.dag_states);
-
- // Make sure we have one year displayed for each year between the start
and end dates.
- // This also ensures we do not have show an empty calendar view when no
dag runs exist.
- const startYear = moment.utc(rootData.start_date, dateFormat).year();
- const endYear = moment.utc(rootData.end_date, dateFormat).year();
- for (let y = startYear; y <= endYear; y += 1) {
- dagStates[y] = dagStates[y] || {};
- }
-
- dagStates = d3
- .entries(dagStates)
- .map((keyVal) => ({
- year: keyVal.key,
- dagStates: keyVal.value,
- }))
- .sort((data) => data.year);
-
- // root SVG element
- const fullWidth =
- leftRightMargin * 2 +
- yearLabelWidth +
- dayLabelWidth +
- maxWeeksInYear * cellSize;
- const yearsHeight =
- (yearHeight + yearPadding) * dagStates.length + yearPadding;
- const fullHeight = titleHeight + legendHeight + yearsHeight;
-
- const svg = d3
- .select("#calendar-svg")
- .attr("width", fullWidth)
- .attr("height", fullHeight)
- .call(dayTip);
-
- // Add the legend
- const legend = svg
- .append("g")
- .attr("transform", `translate(0, ${titleHeight + legendHeight / 2})`);
-
- let legendXOffset = fullWidth - leftRightMargin;
-
- function drawLegend(
- rightState,
- leftState,
- numSwatches = 1,
- swatchesWidth = cellSize
- ) {
- const startColor = statesColors[leftState || rightState];
- const endColor = statesColors[rightState];
-
- legendXOffset -= legendSwtchesTextWidth;
- legend
- .append("text")
- .attr("x", legendXOffset)
- .attr("y", cellSize / 2)
- .attr("text-anchor", "start")
- .attr("class", "status-label")
- .attr("alignment-baseline", "middle")
- .text(rightState);
- legendXOffset -= legendSwatchesPadding;
-
- legendXOffset -= swatchesWidth;
- legend
- .append("g")
- .attr("transform", `translate(${legendXOffset}, 0)`)
- .selectAll("g")
- .data(d3.range(numSwatches))
- .enter()
- .append("rect")
- .attr("x", (v) => v * (swatchesWidth / numSwatches))
- .attr("width", swatchesWidth / numSwatches)
- .attr("height", cellSize)
- .attr("class", "day")
- .attr("fill", (v) =>
- startColor.startsWith("url")
- ? startColor
- : d3.interpolateHsl(startColor, endColor)(v / numSwatches)
- );
- legendXOffset -= legendSwatchesPadding;
-
- if (leftState !== undefined) {
- legend
- .append("text")
- .attr("x", legendXOffset)
- .attr("y", cellSize / 2)
- .attr("text-anchor", "end")
- .attr("class", "status-label")
- .attr("alignment-baseline", "middle")
- .text(leftState);
- legendXOffset -= legendSwtchesTextWidth;
- }
- }
-
- drawLegend("no_status");
- drawLegend("planned");
- drawLegend("running");
- drawLegend("failed", "success", 10, 100);
-
- // Add the years groups, each holding one year of data.
- const years = svg
- .append("g")
- .attr(
- "transform",
- `translate(${leftRightMargin}, ${titleHeight + legendHeight})`
- );
-
- const year = years
- .selectAll("g")
- .data(dagStates)
- .enter()
- .append("g")
- .attr(
- "transform",
- (d, i) =>
- `translate(0, ${yearPadding + (yearHeight + yearPadding) * i})`
- );
-
- year
- .append("text")
- .attr("x", -yearHeight * 0.5)
- .attr("transform", "rotate(270)")
- .attr("text-anchor", "middle")
- .attr("class", "year-label")
- .text((d) => d.year);
-
- // write day names
- year
- .append("g")
- .attr("transform", `translate(${yearLabelWidth}, ${dayLabelPadding})`)
- .attr("text-anchor", "end")
- .selectAll("g")
- .data(d3.range(7))
- .enter()
- .append("text")
- .attr("y", (i) => (i + 0.5) * cellSize)
- .attr("class", "day-label")
- .text(formatDay);
-
- // create months groups to old the individual day cells & month outline
for each month.
- const months = year
- .append("g")
- .attr("transform", `translate(${yearLabelWidth + dayLabelWidth}, 0)`);
-
- const month = months
- .append("g")
- .selectAll("g")
- .data((data) =>
- d3.range(12).map((i) => ({
- year: data.year,
- month: i,
- dagStates: data.dagStates[i] || {},
- }))
- )
- .enter()
- .append("g")
- .attr(
- "transform",
- (data) =>
- `translate(${weekOfYear(data.year, data.month) * cellSize}, 0)`
- );
-
- const tipHtml = (data) => {
- const stateCounts = d3
- .entries(data.dagStates)
- .map((kv) => `${kv.value[0].count} ${kv.key}`);
- const date = toMoment(data.year, data.month, data.day);
- const daySr = formatDay(date.day());
- const dateStr = date.format(dateFormat);
- return `<strong>${daySr} ${dateStr}</strong><br>${stateCounts.join(
- "<br>"
- )}`;
- };
-
- // Create the day cells
- month
- .selectAll("g")
- .data((data) =>
- d3.range(daysInMonth(data.year, data.month)).map((i) => {
- const day = i + 1;
- const dagRunsByState = data.dagStates[day] || {};
- return {
- year: data.year,
- month: data.month,
- day,
- dagStates: dagRunsByState,
- };
- })
- )
- .enter()
- .append("rect")
- .attr(
- "x",
- (data) => weekOfMonth(data.year, data.month, data.day) * cellSize
- )
- .attr(
- "y",
- (data) => toMoment(data.year, data.month, data.day).day() * cellSize
- )
- .attr("width", cellSize)
- .attr("height", cellSize)
- .attr("class", "day")
- .attr("fill", (data) => {
- const getCount = (state) =>
- (data.dagStates[state] || [{ count: 0 }])[0].count;
- const runningCount = getCount("running");
- if (runningCount > 0) return statesColors.running;
-
- const successCount = getCount("success");
- const failedCount = getCount("failed");
- if (successCount + failedCount === 0) {
- const plannedCount = getCount("planned");
- if (plannedCount > 0) return statesColors.planned;
- return statesColors.no_status;
- }
-
- let ratioFailures;
- if (failedCount === 0) ratioFailures = 0;
- else {
- // We use a minimum color interpolation floor, so that days with low
failures ratios
- // don't appear almost as green as days with not failure at all.
- const floor = 0.5;
- ratioFailures =
- floor + (failedCount / (failedCount + successCount)) * (1 - floor);
- }
- return d3.interpolateHsl(
- statesColors.success,
- statesColors.failed
- )(ratioFailures);
- })
- .on("click", (data) => {
- window.location.href = getGridViewURL(
- // add 1 day and subtract 1 ms to not show any run from the next day.
- toMoment(data.year, data.month, data.day)
- .add(1, "day")
- .subtract(1, "ms")
- );
- })
- .on("mouseover", function showTip(data) {
- const tt = tipHtml(data);
- dayTip.direction("n");
- dayTip.show(tt, this);
- })
- .on("mouseout", function hideTip(data) {
- dayTip.hide(data, this);
- });
-
- // add outline (path) around month
- month
- .selectAll("g")
- .data((data) => [data])
- .enter()
- .append("path")
- .attr("class", "month")
- .style("fill", "none")
- .attr("d", (data) => {
- const firstDayOffset = toMoment(data.year, data.month, 1).day();
- const lastDayOffset = toMoment(data.year, data.month, 1)
- .add(1, "month")
- .day();
- const weeks = weeksInMonth(data.year, data.month);
- return d3.svg.line()([
- [0, firstDayOffset * cellSize],
- [cellSize, firstDayOffset * cellSize],
- [cellSize, 0],
- [weeks * cellSize, 0],
- [weeks * cellSize, lastDayOffset * cellSize],
- [(weeks - 1) * cellSize, lastDayOffset * cellSize],
- [(weeks - 1) * cellSize, 7 * cellSize],
- [0, 7 * cellSize],
- [0, firstDayOffset * cellSize],
- ]);
- });
- }
-
- function update() {
- $("#loading").remove();
- draw();
- }
-
- update();
-});
diff --git a/airflow/www/static/js/dag.js b/airflow/www/static/js/dag.js
index b70587a70c..3893063739 100644
--- a/airflow/www/static/js/dag.js
+++ b/airflow/www/static/js/dag.js
@@ -41,41 +41,6 @@ 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");
- const codeNav = document.getElementById("code-nav");
- if (isGrid) {
- if (tab === "graph") {
- gridNav.classList.remove("active");
- ganttNav.classList.remove("active");
- codeNav.classList.remove("active");
- graphNav.classList.add("active");
- } else if (tab === "gantt") {
- gridNav.classList.remove("active");
- graphNav.classList.remove("active");
- codeNav.classList.remove("active");
- ganttNav.classList.add("active");
- } else if (tab === "code") {
- gridNav.classList.remove("active");
- graphNav.classList.remove("active");
- ganttNav.classList.remove("active");
- codeNav.classList.add("active");
- } else {
- graphNav.classList.remove("active");
- ganttNav.classList.remove("active");
- codeNav.classList.remove("active");
- gridNav.classList.add("active");
- }
- }
-};
-
-// Pills highlighting
$(window).on("load", function onLoad() {
$(`a[href*="${this.location.pathname}"]`).parent().addClass("active");
$(".never_active").removeClass("active");
@@ -84,32 +49,6 @@ $(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/templates/airflow/calendar.html
b/airflow/www/templates/airflow/calendar.html
deleted file mode 100644
index 719adcee5a..0000000000
--- a/airflow/www/templates/airflow/calendar.html
+++ /dev/null
@@ -1,56 +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" %}
-{% block page_title %}{{ dag.dag_id }} - Calendar - {{ appbuilder.app_name
}}{% endblock %}
-
-{% block head_css %}
- {{ super() }}
- <link rel="stylesheet" type="text/css" href="{{
url_for_asset('calendar.css') }}">
-{% endblock %}
-
-{% block content %}
- {{ super() }}
- <hr>
- <div id="svg_container">
- <img id='loading' width="50" src="{{ url_for('static',
filename='loading.gif') }}">
- <svg id="calendar-svg">
- <pattern id="calendar-svg-greydot" patternUnits="userSpaceOnUse"
width="16" height="16">
- <circle cx="8" cy="8" r="2" fill="#a3a3a3"></circle>
- </pattern>
- </svg>
- </div>
-{% endblock %}
-
-{% block tail_js %}
- {{ super() }}
- <script src="{{ url_for_asset('d3.min.js') }}"></script>
- <script src="{{ url_for_asset('d3-tip.js') }}"></script>
- <script src="{{ url_for_asset('calendar.js') }}"></script>
- <script>
- const statesColors = {};
- statesColors["no_status"] = "white";
- statesColors["planned"] = "url(#calendar-svg-greydot)";
- {% for state in ['failed', 'success', 'running'] %}
- statesColors["{{state}}"] = "{{state_color_mapping[state]}}";
- {% endfor %}
-
- const calendarData = {{ data|tojson }};
- </script>
-{% endblock %}
diff --git a/airflow/www/templates/airflow/dag.html
b/airflow/www/templates/airflow/dag.html
index 4a6ce9f2d8..9d57082dbb 100644
--- a/airflow/www/templates/airflow/dag.html
+++ b/airflow/www/templates/airflow/dag.html
@@ -109,9 +109,9 @@
DAG: {{ dag.parent_dag.dag_id }}</a>
{% endif %}
- <div>
+ <div style="display: flex; align-items: center; justify-content:
space-between;">
<div>
- <h3 class="pull-left">
+ <h3>
{% if dag.parent_dag is defined and dag.parent_dag %}
<span class="text-muted">SUBDAG:</span> {{ dag.dag_id }}
{% else %}
@@ -139,7 +139,7 @@
</h3>
</div>
<div>
- <h4 class="pull-right js-dataset-triggered" style="user-select:
none;-moz-user-select: auto;">
+ <h4 class="js-dataset-triggered" style="user-select:
none;-moz-user-select: auto; display: inline-block;">
{% if state_token is defined and state_token %}
{{ state_token }}
{% endif %}
@@ -184,104 +184,61 @@
{%- endwith -%}
{% endif %}
</h4>
- </div>
- </div>
- <div class="clearfix"></div>
- <br>
- <div>
- <div class="row">
- <div class="col-md-10">
- <ul class="nav nav-pills">
- <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 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) }}">
- <span class="material-icons" aria-hidden="true">event</span>
- Calendar
- </a></li>
- <li><a href="{{ url_for('Airflow.duration', 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">hourglass_bottom</span>
- Task Duration</a></li>
- <li><a href="{{ url_for('Airflow.tries', 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">repeat</span>
- Task Tries</a></li>
- <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 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)
}}">
- <span class="material-icons" aria-hidden="true">details</span>
- Details</a></li>
- <li id="code-nav"><a href="{{ url_for('Airflow.code',
dag_id=dag.dag_id, root=root) }}">
- <span class="material-icons" aria-hidden="true">code</span>
- Code</a></li>
- <li><a href="{{ url_for('Airflow.audit_log', dag_id=dag.dag_id,
root=root) }}">
- <span class="material-icons" aria-hidden="true">plagiarism</span>
- Audit Log</a></li>
- </ul>
- </div>
- <div class="col-md-2">
- <div class="btn-group pull-right">
- {% if show_trigger_form_if_no_params %}
- <div class="dropdown">
- <a aria-label="Trigger DAG" class="btn btn-default btn-icon-only{{
' disabled' if not dag.can_trigger }} trigger-dropdown-btn"
data-toggle="dropdown">
- <span class="material-icons" aria-hidden="true">play_arrow</span>
- </a>
- <ul class="dropdown-menu trigger-dropdown-menu">
- <li>
- <form method="POST" action="{{ url_for('Airflow.trigger',
dag_id=dag.dag_id) }}">
- <input type="hidden" name="csrf_token" value="{{
csrf_token() }}">
- <input type="hidden" name="dag_id" value="{{ dag.dag_id }}">
- <input type="hidden" name="unpause" value="True">
- <input type="hidden" name="conf" value="{}">
- <!-- for task instance detail pages, dag_id is still a query
param -->
- {% if 'dag_id' in request.args %}
- <input type="hidden" name="origin" value="{{
url_for(request.endpoint, **request.args) }}">
- {% else %}
- <input type="hidden" name="origin" value="{{
url_for(request.endpoint, dag_id=dag.dag_id, **request.args) }}">
- {% endif %}
- <button type="submit" class="dropdown-form-btn">Trigger
DAG</button>
- </form>
- </li>
- <li>
+ <div class="btn-group">
+ {% if show_trigger_form_if_no_params %}
+ <div class="dropdown">
+ <a aria-label="Trigger DAG" class="btn btn-default btn-icon-only{{ '
disabled' if not dag.can_trigger }} trigger-dropdown-btn"
data-toggle="dropdown">
+ <span class="material-icons" aria-hidden="true">play_arrow</span>
+ </a>
+ <ul class="dropdown-menu trigger-dropdown-menu">
+ <li>
+ <form method="POST" action="{{ url_for('Airflow.trigger',
dag_id=dag.dag_id) }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token()
}}">
+ <input type="hidden" name="dag_id" value="{{ dag.dag_id }}">
+ <input type="hidden" name="unpause" value="True">
+ <input type="hidden" name="conf" value="{}">
+ <!-- for task instance detail pages, dag_id is still a query
param -->
{% if 'dag_id' in request.args %}
- <a href="{{ url_for('Airflow.trigger', dag_id=dag.dag_id,
origin=url_for(request.endpoint, **request.args)) }}">
+ <input type="hidden" name="origin" value="{{
url_for(request.endpoint, **request.args) }}">
{% else %}
- <a href="{{ url_for('Airflow.trigger', dag_id=dag.dag_id,
origin=url_for(request.endpoint, dag_id=dag.dag_id, **request.args)) }}">
+ <input type="hidden" name="origin" value="{{
url_for(request.endpoint, dag_id=dag.dag_id, **request.args) }}">
{% endif %}
- Trigger DAG w/ config</a></li>
- </ul>
- </div>
- {% else %}
- {% if 'dag_id' in request.args %}
- <a href="{{ url_for('Airflow.trigger', dag_id=dag.dag_id,
origin=url_for(request.endpoint, **request.args)) }}"
- {% else %}
- <a href="{{ url_for('Airflow.trigger', dag_id=dag.dag_id,
origin=url_for(request.endpoint, dag_id=dag.dag_id, **request.args)) }}"
- {% endif %}
- onclick="return {{ 'triggerDag' if not
appbuilder.require_confirmation_dag_change else 'confirmTriggerDag' }}(this,
'{{ dag.dag_id }}')"
- title="Trigger DAG"
- aria-label="Trigger DAG"
- class="btn btn-default btn-icon-only{{ ' disabled' if not
dag.can_trigger }} trigger-dropdown-btn">
- <span class="material-icons" aria-hidden="true">play_arrow</span>
- </a>
- {% endif %}
- <a href="{{ url_for('Airflow.delete', dag_id=dag.dag_id,
redirect_url=url_for(request.endpoint, dag_id=dag.dag_id)) }}"
- title="Delete DAG"
- aria-label="Delete DAG"
- class="btn btn-default btn-icon-only{{ ' disabled' if not
dag.can_delete }}"
- onclick="return confirmDeleteDag(this, '{{ dag.safe_dag_id }}')">
- <span class="material-icons text-danger"
aria-hidden="true">delete_outline</span>
- </a>
+ <button type="submit" class="dropdown-form-btn">Trigger
DAG</button>
+ </form>
+ </li>
+ <li>
+ {% if 'dag_id' in request.args %}
+ <a href="{{ url_for('Airflow.trigger', dag_id=dag.dag_id,
origin=url_for(request.endpoint, **request.args)) }}">
+ {% else %}
+ <a href="{{ url_for('Airflow.trigger', dag_id=dag.dag_id,
origin=url_for(request.endpoint, dag_id=dag.dag_id, **request.args)) }}">
+ {% endif %}
+ Trigger DAG w/ config</a></li>
+ </ul>
</div>
+ {% else %}
+ {% if 'dag_id' in request.args %}
+ <a href="{{ url_for('Airflow.trigger', dag_id=dag.dag_id,
origin=url_for(request.endpoint, **request.args)) }}"
+ {% else %}
+ <a href="{{ url_for('Airflow.trigger', dag_id=dag.dag_id,
origin=url_for(request.endpoint, dag_id=dag.dag_id, **request.args)) }}"
+ {% endif %}
+ onclick="return {{ 'triggerDag' if not
appbuilder.require_confirmation_dag_change else 'confirmTriggerDag' }}(this,
'{{ dag.dag_id }}')"
+ title="Trigger DAG"
+ aria-label="Trigger DAG"
+ class="btn btn-default btn-icon-only{{ ' disabled' if not
dag.can_trigger }} trigger-dropdown-btn">
+ <span class="material-icons" aria-hidden="true">play_arrow</span>
+ </a>
+ {% endif %}
+ <a href="{{ url_for('Airflow.delete', dag_id=dag.dag_id,
redirect_url=url_for(request.endpoint, dag_id=dag.dag_id)) }}"
+ title="Delete DAG"
+ aria-label="Delete DAG"
+ class="btn btn-default btn-icon-only{{ ' disabled' if not
dag.can_delete }}"
+ onclick="return confirmDeleteDag(this, '{{ dag.safe_dag_id }}')">
+ <span class="material-icons text-danger"
aria-hidden="true">delete_outline</span>
+ </a>
</div>
</div>
</div>
+ <div class="clearfix"></div>
{{ dag_docs(doc_md) }}
<!-- Modal for dataset-triggered next run -->
{{ dataset_next_run_modal(id='dataset-next-run-modal') }}
diff --git a/airflow/www/templates/airflow/dag_audit_log.html
b/airflow/www/templates/airflow/dag_audit_log.html
deleted file mode 100644
index 07766ea32d..0000000000
--- a/airflow/www/templates/airflow/dag_audit_log.html
+++ /dev/null
@@ -1,119 +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" %}
-{% block title %}Dag Audit Log{% endblock %}
-
-{%- macro sortable_column(display_name, attribute_name) -%}
- {% set curr_ordering_direction = (request.args.get('sorting_direction',
'desc')) %}
- {% set new_ordering_direction = ('asc' if (request.args.get('sorting_key')
!= attribute_name or curr_ordering_direction == 'desc') else 'desc') %}
- <a href="{{ url_for('Airflow.audit_log',
- dag_id=dag_id,
- sorting_key=attribute_name,
- sorting_direction=new_ordering_direction
- ) }}"
- class="js-tooltip"
- role="tooltip"
- title="Sort by {{ new_ordering_direction }} {{ attribute_name }}."
- >
- {{ display_name }}
-
- <span class="material-icons" aria-hidden="true"
aria-describedby="sorting-tip-{{ display_name }}">
- {% if curr_ordering_direction == 'desc' and
request.args.get('sorting_key') == attribute_name %}
- expand_more
- {% elif curr_ordering_direction == 'asc' and
request.args.get('sorting_key') == attribute_name %}
- expand_less
- {% else %}
- unfold_more
- {% endif %}
- </span>
- </a>
-{%- endmacro -%}
-
-{% block head_css %}
-{{ super() }}
-<link href="{{ url_for_asset('dataTables.bootstrap.min.css') }}"
rel="stylesheet" type="text/css" >
-{% endblock %}
-
-{% block content %}
- {{ super() }}
- <h2>Dag Audit Log</h2>
- <h4 style="display: block; padding-top: 10px; padding-bottom: 4px">
- <p>
- This view displays selected events and operations that have been taken
on this dag.
- The included and excluded events are set in the Airflow configuration,
- which by default remove view only actions. For a full list of events
regarding this DAG, click
- <a href="{{ url_for('LogModelView.list', _flt_3_dag_id=dag_id)
}}">here</a>.
- </p>
- </h4>
- <table id="dag_audit_log_table" class="table table-striped table-bordered
table-hover">
- <thead>
- <tr>
- <th>{{ sortable_column("Time", "dttm") }}</th>
- <th>{{ sortable_column("Task ID", "task_id") }}</th>
- <th>{{ sortable_column("Event", "event") }}</th>
- <th>{{ sortable_column("Logical Date", "execution_date")
}}</th>
- <th>{{ sortable_column("Run ID", "run_id") }}</th>
- <th>Owner
- <span class="material-icons text-muted js-tooltip"
aria-hidden="true" data-original-title="This is the user who triggered the
event.">info</span>
- </th>
- <th>Details</th>
- </tr>
- </thead>
- <tbody>
- {% for log in dag_logs %}
- <tr>
- <!-- Time -->
- <td><time datetime="{{ log.dttm }}">{{ log.dttm }}</time></td>
- <!-- Task Id: task id is None for Dag level events -->
- <td>{{ log.task_id if log.task_id else None }}</td>
- <!-- Event -->
- <td>{{ log.event if log.event else None }}</td>
- <!-- Execution Date -->
- <td>{{ log.execution_date if log.execution_date else None }}</td>
- <!-- Dagrun ID -->
- <td>{{ log.run_id if log.run_id else None }}</td>
- <!-- By User -->
- <td>{{ log.owner if log.owner else None }}</td>
- <!-- Details -->
- <td>{{ log.extra if log.extra else None }}</td>
- </tr>
- {% endfor %}
- </tbody>
- </table>
- <div class="row">
- <div class="col-sm-6">
- {{ paging }}
- </div>
- <div class="col-sm-6 text-right">
- Showing <strong>{{ num_log_from }}-{{ num_log_to }}</strong> of
<strong>{{ audit_logs_count }}</strong> Dag Audit Log
- </div>
- </div>
- </div>
- <div id="svg-tooltip" class="tooltip top" style="position: fixed; display:
none; opacity: 1; pointer-events: none;">
- <div class="tooltip-arrow"></div>
- <div class="tooltip-inner"></div>
- </div>
-{% endblock %}
-
-{% block tail %}
- {{ super() }}
- <script src="{{ url_for_asset('jquery.dataTables.min.js') }}"></script>
- <script src="{{ url_for_asset('dataTables.bootstrap.min.js') }}"></script>
-{% endblock %}
diff --git a/airflow/www/templates/airflow/duration_chart.html
b/airflow/www/templates/airflow/duration_chart.html
deleted file mode 100644
index 97f7d0b03f..0000000000
--- a/airflow/www/templates/airflow/duration_chart.html
+++ /dev/null
@@ -1,71 +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" %}
-{% block page_title %}{{ dag.dag_id }} - Duration chart - {{
appbuilder.app_name }}{% endblock %}
-
-{% block head_css %}
- {{ super() }}
- <link rel="stylesheet" type="text/css" href="{{ url_for_asset('chart.css')
}}">
- <link rel="stylesheet" type="text/css" href="{{
url_for_asset('nv.d3.min.css') }}">
- <script src="{{ url_for_asset('d3.min.js') }}"></script>
- <script src="{{ url_for_asset('nv.d3.min.js') }}"></script>
-{% 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 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 class="col-md-2 text-right">
- <div class="checkbox">
- <label for="isCumulative">
- <input type="checkbox" id="isCumulative" value="1"> Cumulative
Duration
- </label>
- </div>
- </div>
- </div>
- <div id="dur_chart">{{ chart }}</div>
- <div id="cum_dur_chart">{{ cum_chart }}</div>
-{% endblock %}
-
-{% block tail %}
- <script src="{{ url_for_asset('durationChart.js') }}"></script>
- {{ super() }}
-{% endblock %}
diff --git a/airflow/www/utils.py b/airflow/www/utils.py
index c7e64fdba5..8cbb19d176 100644
--- a/airflow/www/utils.py
+++ b/airflow/www/utils.py
@@ -655,17 +655,6 @@ def get_attr_renderer():
}
-def get_chart_height(dag):
- """
- Use the number of tasks in the DAG to approximate the size of generated
chart.
-
- Without this the charts are tiny and unreadable when DAGs have a large
number of tasks).
- Ideally nvd3 should allow for dynamic-height charts, that is charts that
take up space
- based on the size of the components within.
- """
- return 600 + len(dag.tasks) * 10
-
-
class UtcAwareFilterMixin:
"""Mixin for filter for UTC time."""
diff --git a/airflow/www/views.py b/airflow/www/views.py
index 00361f397e..5a8da5f28a 100644
--- a/airflow/www/views.py
+++ b/airflow/www/views.py
@@ -41,7 +41,6 @@ from urllib.parse import unquote, urljoin, urlsplit
import configupdater
import flask.json
import lazy_object_proxy
-import nvd3
import re2
import sqlalchemy as sqla
from croniter import croniter
@@ -102,7 +101,7 @@ from airflow.hooks.base import BaseHook
from airflow.jobs.job import Job
from airflow.jobs.scheduler_job_runner import SchedulerJobRunner
from airflow.jobs.triggerer_job_runner import TriggererJobRunner
-from airflow.models import Connection, DagModel, DagTag, Log, SlaMiss,
TaskFail, Trigger, XCom, errors
+from airflow.models import Connection, DagModel, DagTag, Log, SlaMiss,
Trigger, XCom, errors
from airflow.models.dag import get_dataset_triggered_next_run_info
from airflow.models.dagrun import RUN_ID_REGEX, DagRun, DagRunType
from airflow.models.dataset import DagScheduleDatasetReference,
DatasetDagRunQueue, DatasetEvent, DatasetModel
@@ -120,7 +119,6 @@ from airflow.timetables.simple import ContinuousTimetable
from airflow.utils import json as utils_json, timezone, yaml
from airflow.utils.airflow_flask_app import get_airflow_app
from airflow.utils.dag_edges import dag_edges
-from airflow.utils.dates import infer_time_unit, scale_time_units
from airflow.utils.db import get_query_count
from airflow.utils.docs import get_doc_url_for_provider, get_docs_url
from airflow.utils.helpers import exactly_one
@@ -140,7 +138,6 @@ from airflow.www.extensions.init_auth_manager import
get_auth_manager
from airflow.www.forms import (
DagRunEditForm,
DateTimeForm,
- DateTimeWithNumRunsForm,
TaskInstanceEditForm,
create_connection_form_class,
)
@@ -167,7 +164,7 @@ SENSITIVE_FIELD_PLACEHOLDER =
"RATHER_LONG_SENSITIVE_FIELD_PLACEHOLDER"
logger = logging.getLogger(__name__)
-def sanitize_args(args: dict[str, str]) -> dict[str, str]:
+def sanitize_args(args: dict[str, Any]) -> dict[str, Any]:
"""
Remove all parameters starting with `_`.
@@ -2835,111 +2832,11 @@ class Airflow(AirflowBaseView):
return redirect(url_for("Airflow.calendar",
**sanitize_args(request.args)))
@expose("/dags/<string:dag_id>/calendar")
- @auth.has_access_dag("GET", DagAccessEntity.RUN)
- @gzipped
- @provide_session
- def calendar(self, dag_id: str, session: Session = NEW_SESSION):
- """Get DAG runs as calendar."""
- 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 from DagBag.', "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:
- dag = dag.partial_subset(task_ids_or_regex=root,
include_downstream=False, include_upstream=True)
-
- dag_states = session.execute(
- select(
- func.date(DagRun.execution_date).label("date"),
- DagRun.state,
-
func.max(DagRun.data_interval_start).label("data_interval_start"),
- func.max(DagRun.data_interval_end).label("data_interval_end"),
- func.count("*").label("count"),
- )
- .where(DagRun.dag_id == dag.dag_id)
- .group_by(func.date(DagRun.execution_date), DagRun.state)
- .order_by(func.date(DagRun.execution_date).asc())
- ).all()
-
- data_dag_states = [
- {
- # DATE() in SQLite and MySQL behave differently:
- # SQLite returns a string, MySQL returns a date.
- "date": dr.date if isinstance(dr.date, str) else
dr.date.isoformat(),
- "state": dr.state,
- "count": dr.count,
- }
- for dr in dag_states
- ]
-
- if dag_states and dag_states[-1].data_interval_start and
dag_states[-1].data_interval_end:
- last_automated_data_interval = DataInterval(
- timezone.coerce_datetime(dag_states[-1].data_interval_start),
- timezone.coerce_datetime(dag_states[-1].data_interval_end),
- )
-
- year = last_automated_data_interval.end.year
- restriction = TimeRestriction(dag.start_date, dag.end_date, False)
- dates: dict[datetime.date, int] = collections.Counter()
-
- if isinstance(dag.timetable, CronMixin):
- # Optimized calendar generation for timetables based on a cron
expression.
- dates_iter: Iterator[datetime.datetime | None] = croniter(
- dag.timetable._expression,
- start_time=last_automated_data_interval.end,
- ret_type=datetime.datetime,
- )
- for dt in dates_iter:
- if dt is None:
- break
- if dt.year != year:
- break
- if dag.end_date and dt > dag.end_date:
- break
- dates[dt.date()] += 1
- else:
- prev_logical_date = DateTime.min
- while True:
- curr_info = dag.timetable.next_dagrun_info(
-
last_automated_data_interval=last_automated_data_interval,
- restriction=restriction,
- )
- if curr_info is None:
- break # Reached the end.
- if curr_info.logical_date <= prev_logical_date:
- break # We're not progressing. Maybe a malformed
timetable? Give up.
- if curr_info.logical_date.year != year:
- break # Crossed the year boundary.
- last_automated_data_interval = curr_info.data_interval
- dates[curr_info.logical_date] += 1
- prev_logical_date = curr_info.logical_date
-
- data_dag_states.extend(
- {"date": date.isoformat(), "state": "planned", "count": count}
- for (date, count) in dates.items()
- )
+ def calendar(self, dag_id: str):
+ """Redirect to the replacement - grid + calendar. Kept for backwards
compatibility."""
+ kwargs = {**sanitize_args(request.args), "dag_id": dag_id, "tab":
"calendar"}
- now = timezone.utcnow()
- data = {
- "dag_states": data_dag_states,
- "start_date": (dag.start_date or now).date().isoformat(),
- "end_date": (dag.end_date or now).date().isoformat(),
- }
-
- return self.render_template(
- "airflow/calendar.html",
- dag=dag,
- show_trigger_form_if_no_params=conf.getboolean("webserver",
"show_trigger_form_if_no_params"),
- doc_md=wwwutils.wrapped_markdown(getattr(dag, "doc_md", None)),
- data=htmlsafe_json_dumps(data, separators=(",", ":")), # Avoid
spaces to reduce payload size.
- root=root,
- dag_model=dag_model,
- )
+ return redirect(url_for("Airflow.grid", **kwargs))
@expose("/object/calendar_data")
@auth.has_access_dag("GET", DagAccessEntity.RUN)
@@ -3079,150 +2976,9 @@ class Airflow(AirflowBaseView):
return redirect(url_for("Airflow.duration",
**sanitize_args(request.args)))
@expose("/dags/<string:dag_id>/duration")
- @auth.has_access_dag("GET", DagAccessEntity.TASK_INSTANCE)
- @provide_session
- def duration(self, dag_id: str, session: Session = NEW_SESSION):
- """Get Dag as duration graph."""
- 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)
-
- default_dag_run = conf.getint("webserver",
"default_dag_run_display_number")
- base_date_str = request.args.get("base_date")
- num_runs = request.args.get("num_runs", default=default_dag_run,
type=int)
-
- if base_date_str:
- base_date = _safe_parse_datetime(base_date_str)
- else:
- base_date = dag.get_latest_execution_date() or timezone.utcnow()
-
- root = request.args.get("root")
- if root:
- dag = dag.partial_subset(task_ids_or_regex=root,
include_upstream=True, include_downstream=False)
- chart_height = wwwutils.get_chart_height(dag)
- chart = nvd3.lineChart(
- name="lineChart",
- x_custom_format=True,
- x_axis_date=True,
- x_axis_format=LINECHART_X_AXIS_TICKFORMAT,
- height=chart_height,
- chart_attr=self.line_chart_attr,
- )
- cum_chart = nvd3.lineChart(
- name="cumLineChart",
- x_custom_format=True,
- x_axis_date=True,
- x_axis_format=LINECHART_X_AXIS_TICKFORMAT,
- height=chart_height,
- chart_attr=self.line_chart_attr,
- )
-
- y_points = defaultdict(list)
- x_points = defaultdict(list)
-
- task_instances = dag.get_task_instances_before(base_date, num_runs,
session=session)
- if task_instances:
- min_date = task_instances[0].execution_date
- else:
- min_date = timezone.utc_epoch()
- ti_fails = (
- select(TaskFail)
- .join(TaskFail.dag_run)
- .where(
- TaskFail.dag_id == dag.dag_id,
- DagRun.execution_date >= min_date,
- DagRun.execution_date <= base_date,
- )
- )
- if dag.partial:
- ti_fails = ti_fails.where(TaskFail.task_id.in_([t.task_id for t in
dag.tasks]))
- ti_fails = session.scalars(ti_fails)
- fails_totals: dict[tuple[str, str, str], int] = defaultdict(int)
- for failed_task_instance in ti_fails:
- dict_key = (
- failed_task_instance.dag_id,
- failed_task_instance.task_id,
- failed_task_instance.run_id,
- )
- if failed_task_instance.duration:
- fails_totals[dict_key] += failed_task_instance.duration
-
- # We must group any mapped TIs by dag_id, task_id, run_id
- def grouping_key(ti: TaskInstance):
- return ti.dag_id, ti.task_id, ti.run_id
-
- mapped_tis = set()
- for _, group in itertools.groupby(sorted(task_instances,
key=grouping_key), key=grouping_key):
- tis = list(group)
- duration = sum(x.duration for x in tis if x.duration)
- if duration:
- first_ti = tis[0]
- if first_ti.map_index >= 0:
- mapped_tis.add(first_ti.task_id)
- date_time = wwwutils.epoch(first_ti.execution_date)
- x_points[first_ti.task_id].append(date_time)
- fails_dict_key = (first_ti.dag_id, first_ti.task_id,
first_ti.run_id)
- fails_total = fails_totals[fails_dict_key]
- y_points[first_ti.task_id].append(float(duration +
fails_total))
-
- cumulative_y = {k: list(itertools.accumulate(v)) for k, v in
y_points.items()}
-
- # determine the most relevant time unit for the set of task instance
- # durations for the DAG
- y_unit = infer_time_unit([d for t in y_points.values() for d in t])
- cum_y_unit = infer_time_unit([d for t in cumulative_y.values() for d
in t])
- # update the y Axis on both charts to have the correct time units
- chart.create_y_axis("yAxis", format=".02f", custom_format=False,
label=f"Duration ({y_unit})")
- chart.axislist["yAxis"]["axisLabelDistance"] = "-15"
- cum_chart.create_y_axis("yAxis", format=".02f", custom_format=False,
label=f"Duration ({cum_y_unit})")
- cum_chart.axislist["yAxis"]["axisLabelDistance"] = "-15"
-
- for task_id in x_points:
- chart.add_serie(
- name=task_id + "[]" if task_id in mapped_tis else task_id,
- x=x_points[task_id],
- y=scale_time_units(y_points[task_id], y_unit),
- )
- cum_chart.add_serie(
- name=task_id + "[]" if task_id in mapped_tis else task_id,
- x=x_points[task_id],
- y=scale_time_units(cumulative_y[task_id], cum_y_unit),
- )
-
- max_date = max((ti.execution_date for ti in task_instances),
default=None)
-
- session.commit()
-
- form = DateTimeWithNumRunsForm(
- data={
- "base_date": max_date or timezone.utcnow(),
- "num_runs": num_runs,
- }
- )
- chart.buildcontent()
- cum_chart.buildcontent()
- s_index = cum_chart.htmlcontent.rfind("});")
- cum_chart.htmlcontent = (
- f"{cum_chart.htmlcontent[:s_index]}"
- "$( document ).trigger('chartload')"
- f"{cum_chart.htmlcontent[s_index:]}"
- )
-
- return self.render_template(
- "airflow/duration_chart.html",
- dag=dag,
- show_trigger_form_if_no_params=conf.getboolean("webserver",
"show_trigger_form_if_no_params"),
- root=root,
- form=form,
- chart=Markup(chart.htmlcontent),
- cum_chart=Markup(cum_chart.htmlcontent),
- dag_model=dag_model,
- )
+ def duration(self, dag_id: str):
+ """Redirect to Grid view."""
+ return redirect(url_for("Airflow.grid", dag_id=dag_id))
@expose("/tries")
def legacy_tries(self):
@@ -3230,80 +2986,13 @@ class Airflow(AirflowBaseView):
return redirect(url_for("Airflow.tries",
**sanitize_args(request.args)))
@expose("/dags/<string:dag_id>/tries")
- @auth.has_access_dag("GET", DagAccessEntity.TASK_INSTANCE)
- @provide_session
- def tries(self, dag_id: str, session: Session = NEW_SESSION):
- """Show all tries."""
- 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)
-
- default_dag_run = conf.getint("webserver",
"default_dag_run_display_number")
- base_date_str = request.args.get("base_date")
- num_runs = request.args.get("num_runs", default=default_dag_run,
type=int)
-
- if base_date_str:
- base_date = _safe_parse_datetime(base_date_str)
- else:
- base_date = dag.get_latest_execution_date() or timezone.utcnow()
-
- root = request.args.get("root")
- if root:
- dag = dag.partial_subset(task_ids_or_regex=root,
include_upstream=True, include_downstream=False)
-
- chart_height = wwwutils.get_chart_height(dag)
- chart = nvd3.lineChart(
- name="lineChart",
- x_custom_format=True,
- x_axis_date=True,
- x_axis_format=LINECHART_X_AXIS_TICKFORMAT,
- height=chart_height,
- chart_attr=self.line_chart_attr,
- )
-
- tis = dag.get_task_instances_before(base_date, num_runs,
session=session)
- for task in dag.tasks:
- y_points = []
- x_points = []
- for ti in tis:
- if ti.task_id == task.task_id:
- dttm = wwwutils.epoch(ti.execution_date)
- x_points.append(dttm)
- # y value should reflect completed tries to have a 0
baseline.
- y_points.append(ti.prev_attempted_tries)
- if x_points:
- chart.add_serie(name=task.task_id, x=x_points, y=y_points)
-
- max_date = max((ti.execution_date for ti in tis), default=None)
- chart.create_y_axis("yAxis", format=".02f", custom_format=False,
label="Tries")
- chart.axislist["yAxis"]["axisLabelDistance"] = "-15"
-
- session.commit()
-
- form = DateTimeWithNumRunsForm(
- data={
- "base_date": max_date or timezone.utcnow(),
- "num_runs": num_runs,
- }
- )
-
- chart.buildcontent()
-
- return self.render_template(
- "airflow/chart.html",
- dag=dag,
- show_trigger_form_if_no_params=conf.getboolean("webserver",
"show_trigger_form_if_no_params"),
- root=root,
- form=form,
- chart=Markup(chart.htmlcontent),
- tab_title="Tries",
- dag_model=dag_model,
- )
+ def tries(self, dag_id: str):
+ """Redirect to grid view."""
+ kwargs = {
+ **sanitize_args(request.args),
+ "dag_id": dag_id,
+ }
+ return redirect(url_for("Airflow.grid", **kwargs))
@expose("/landing_times")
def legacy_landing_times(self):
@@ -3311,93 +3000,15 @@ class Airflow(AirflowBaseView):
return redirect(url_for("Airflow.landing_times",
**sanitize_args(request.args)))
@expose("/dags/<string:dag_id>/landing-times")
- @auth.has_access_dag("GET", DagAccessEntity.TASK_INSTANCE)
- @provide_session
- def landing_times(self, dag_id: str, session: Session = NEW_SESSION):
- """Show landing times."""
- 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)
-
- default_dag_run = conf.getint("webserver",
"default_dag_run_display_number")
- base_date_str = request.args.get("base_date")
- num_runs = request.args.get("num_runs", default=default_dag_run,
type=int)
-
- if base_date_str:
- base_date = _safe_parse_datetime(base_date_str)
- else:
- base_date = dag.get_latest_execution_date() or timezone.utcnow()
-
- root = request.args.get("root")
- if root:
- dag = dag.partial_subset(task_ids_or_regex=root,
include_upstream=True, include_downstream=False)
-
- tis = dag.get_task_instances_before(base_date, num_runs,
session=session)
-
- chart_height = wwwutils.get_chart_height(dag)
- chart = nvd3.lineChart(
- name="lineChart",
- x_custom_format=True,
- x_axis_date=True,
- x_axis_format=LINECHART_X_AXIS_TICKFORMAT,
- height=chart_height,
- chart_attr=self.line_chart_attr,
- )
-
- y_points: dict[str, list[float]] = defaultdict(list)
- x_points: dict[str, list[tuple[int]]] = defaultdict(list)
- for task in dag.tasks:
- task_id = task.task_id
- for ti in tis:
- if ti.task_id == task.task_id:
- ts = dag.get_run_data_interval(ti.dag_run).end
- if ti.end_date:
- dttm = wwwutils.epoch(ti.execution_date)
- secs = (ti.end_date - ts).total_seconds()
- x_points[task_id].append(dttm)
- y_points[task_id].append(secs)
-
- # determine the most relevant time unit for the set of landing times
- # for the DAG
- y_unit = infer_time_unit([d for t in y_points.values() for d in t])
- # update the y Axis to have the correct time units
- chart.create_y_axis("yAxis", format=".02f", custom_format=False,
label=f"Landing Time ({y_unit})")
- chart.axislist["yAxis"]["axisLabelDistance"] = "-15"
-
- for task_id in x_points:
- chart.add_serie(
- name=task_id,
- x=x_points[task_id],
- y=scale_time_units(y_points[task_id], y_unit),
- )
- max_date = max(ti.execution_date for ti in tis) if tis else None
-
- session.commit()
-
- form = DateTimeWithNumRunsForm(
- data={
- "base_date": max_date or timezone.utcnow(),
- "num_runs": num_runs,
- }
- )
- chart.buildcontent()
+ def landing_times(self, dag_id: str):
+ """Redirect to run duration page."""
+ kwargs = {
+ **sanitize_args(request.args),
+ "dag_id": dag_id,
+ "tab": "run_duration",
+ }
- return self.render_template(
- "airflow/chart.html",
- dag=dag,
- show_trigger_form_if_no_params=conf.getboolean("webserver",
"show_trigger_form_if_no_params"),
- chart=Markup(chart.htmlcontent),
- height=f"{chart_height + 100}px",
- root=root,
- form=form,
- tab_title="Landing times",
- dag_model=dag_model,
- )
+ return redirect(url_for("Airflow.grid", **kwargs))
@expose("/paused", methods=["POST"])
@auth.has_access_dag("PUT")
@@ -3861,65 +3472,22 @@ class Airflow(AirflowBaseView):
return redirect(url_for("Airflow.audit_log",
**sanitize_args(request.args)))
@expose("/dags/<string:dag_id>/audit_log")
- @auth.has_access_dag("GET", DagAccessEntity.AUDIT_LOG)
- @provide_session
- def audit_log(self, dag_id: str, session: Session = NEW_SESSION):
- 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 from DagBag.', "error")
- return redirect(url_for("Airflow.index"))
-
- included_events_raw = conf.get("webserver",
"audit_view_included_events", fallback=None)
- excluded_events_raw = conf.get("webserver",
"audit_view_excluded_events", fallback=None)
-
- query = select(Log).where(Log.dag_id == dag_id)
- if included_events_raw:
- included_events = {event.strip() for event in
included_events_raw.split(",")}
- query = query.where(Log.event.in_(included_events))
- elif excluded_events_raw:
- excluded_events = {event.strip() for event in
excluded_events_raw.split(",")}
- query = query.where(Log.event.notin_(excluded_events))
-
- current_page = request.args.get("page", default=0, type=int)
- arg_sorting_key = request.args.get("sorting_key", "dttm")
- arg_sorting_direction = request.args.get("sorting_direction",
default="desc")
-
- logs_per_page = PAGE_SIZE
- audit_logs_count = get_query_count(query, session=session)
- num_of_pages = math.ceil(audit_logs_count / logs_per_page)
-
- start = current_page * logs_per_page
- end = start + logs_per_page
-
- sort_column = Log.__table__.c.get(arg_sorting_key)
- if sort_column is not None:
- if arg_sorting_direction == "desc":
- sort_column = sort_column.desc()
- query = query.order_by(sort_column)
+ def audit_log(self, dag_id: str):
+ current_page = request.args.get("page")
+ arg_sorting_key = request.args.get("sorting_key")
+ arg_sorting_direction = request.args.get("sorting_direction")
+ sort_args = {
+ "offset": current_page,
+ f"sort.{arg_sorting_key}": arg_sorting_direction,
+ "limit": PAGE_SIZE,
+ }
+ kwargs = {
+ **sanitize_args(sort_args),
+ "dag_id": dag_id,
+ "tab": "audit_log",
+ }
- dag_audit_logs =
session.scalars(query.offset(start).limit(logs_per_page)).all()
- return self.render_template(
- "airflow/dag_audit_log.html",
- dag=dag,
- show_trigger_form_if_no_params=conf.getboolean("webserver",
"show_trigger_form_if_no_params"),
- dag_model=dag_model,
- root=request.args.get("root"),
- dag_id=dag_id,
- dag_logs=dag_audit_logs,
- num_log_from=min(start + 1, audit_logs_count),
- num_log_to=min(end, audit_logs_count),
- audit_logs_count=audit_logs_count,
- page_size=PAGE_SIZE,
- paging=wwwutils.generate_pages(
- current_page,
- num_of_pages,
- sorting_key=arg_sorting_key or None,
- sorting_direction=arg_sorting_direction or None,
- ),
- sorting_key=arg_sorting_key,
- sorting_direction=arg_sorting_direction,
- )
+ return redirect(url_for("Airflow.grid", **kwargs))
class ConfigurationView(AirflowBaseView):
diff --git a/airflow/www/webpack.config.js b/airflow/www/webpack.config.js
index 4e3de410bc..c690749ad9 100644
--- a/airflow/www/webpack.config.js
+++ b/airflow/www/webpack.config.js
@@ -76,8 +76,6 @@ const config = {
grid: `${JS_DIR}/dag/index.tsx`,
clusterActivity: `${JS_DIR}/cluster-activity/index.tsx`,
datasets: `${JS_DIR}/datasets/index.tsx`,
- calendar: [`${CSS_DIR}/calendar.css`, `${JS_DIR}/calendar.js`],
- durationChart: `${JS_DIR}/duration_chart.js`,
trigger: `${JS_DIR}/trigger.js`,
variableEdit: `${JS_DIR}/variable_edit.js`,
},
@@ -202,10 +200,6 @@ const config = {
// we'll have the dependencies imported within the custom JS
new CopyWebpackPlugin({
patterns: [
- {
- from: "node_modules/nvd3/build/*.min.*",
- flatten: true,
- },
{
from: "node_modules/d3/d3.min.*",
flatten: true,
diff --git a/airflow/www/yarn.lock b/airflow/www/yarn.lock
index 62dd14d358..600a4b7a3f 100644
--- a/airflow/www/yarn.lock
+++ b/airflow/www/yarn.lock
@@ -9020,11 +9020,6 @@ nth-check@^2.0.1:
dependencies:
boolbase "^1.0.0"
-nvd3@^1.8.6:
- version "1.8.6"
- resolved
"https://registry.yarnpkg.com/nvd3/-/nvd3-1.8.6.tgz#2d3eba74bf33363b5101ebf1d093c59a53ae73c4"
- integrity
sha512-YGQ9hAQHuQCF0JmYkT2GhNMHb5pA+vDfQj6C2GdpQPzdRPj/srPG3mh/3fZzUFt+at1NusLk/RqICUWkxm4viQ==
-
nwsapi@^2.2.0:
version "2.2.0"
resolved
"https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7"
diff --git a/newsfragments/37988.significant.rst
b/newsfragments/37988.significant.rst
new file mode 100644
index 0000000000..2cf6ced182
--- /dev/null
+++ b/newsfragments/37988.significant.rst
@@ -0,0 +1 @@
+Remove all remaining flask DAG views and redirect to their React equivalents.
Page navigation now happens grid view selection and the tabs to the right of
the grid.
diff --git a/tests/www/views/test_views.py b/tests/www/views/test_views.py
index 79d205c335..a588cb1864 100644
--- a/tests/www/views/test_views.py
+++ b/tests/www/views/test_views.py
@@ -507,18 +507,6 @@ INVALID_DATETIME_RESPONSE = re.compile(r"Invalid datetime:
&#x?\d+;invalid&#x?\d
"dags/example_bash_operator/graph?execution_date=invalid",
INVALID_DATETIME_RESPONSE,
),
- (
- "dags/example_bash_operator/duration?base_date=invalid",
- INVALID_DATETIME_RESPONSE,
- ),
- (
- "dags/example_bash_operator/tries?base_date=invalid",
- INVALID_DATETIME_RESPONSE,
- ),
- (
- "dags/example_bash_operator/landing-times?base_date=invalid",
- INVALID_DATETIME_RESPONSE,
- ),
(
"dags/example_bash_operator/gantt?execution_date=invalid",
INVALID_DATETIME_RESPONSE,
diff --git a/tests/www/views/test_views_decorators.py
b/tests/www/views/test_views_decorators.py
index a8f199f595..508e7ef32d 100644
--- a/tests/www/views/test_views_decorators.py
+++ b/tests/www/views/test_views_decorators.py
@@ -151,14 +151,3 @@ def test_action_logging_variables_masked_secrets(session,
admin_client):
admin_client.post("/variable/add", data=form)
session.commit()
_check_last_log_masked_variable(session, dag_id=None,
event="variable.create", execution_date=None)
-
-
-def test_calendar(admin_client, dagruns):
- url = "calendar?dag_id=example_bash_operator"
- resp = admin_client.get(url, follow_redirects=True)
-
- bash_dagrun, _, _ = dagruns
-
- datestr = bash_dagrun.execution_date.date().isoformat()
- expected = rf"{{\"date\":\"{datestr}\",\"state\":\"running\",\"count\":1}}"
- check_content_in_response(expected, resp)
diff --git a/tests/www/views/test_views_home.py
b/tests/www/views/test_views_home.py
index a60e44c342..5ddcb65a87 100644
--- a/tests/www/views/test_views_home.py
+++ b/tests/www/views/test_views_home.py
@@ -436,16 +436,6 @@ def test_dashboard_flash_messages_type(user_client):
check_content_in_response("alert-foo", resp)
-def test_audit_log_view_admin(admin_client, working_dags):
- resp = admin_client.get("/dags/filter_test_1/audit_log")
- check_content_in_response("Dag Audit Log", resp)
-
-
-def test_audit_log_view_user(user_client, working_dags):
- resp = user_client.get("/dags/filter_test_1/audit_log")
- check_content_not_in_response("Dag Audit Log", resp, resp_code=302)
-
-
@pytest.mark.parametrize(
"url, lower_key, greater_key",
[
diff --git a/tests/www/views/test_views_tasks.py
b/tests/www/views/test_views_tasks.py
index 1998dffc58..61661e8941 100644
--- a/tests/www/views/test_views_tasks.py
+++ b/tests/www/views/test_views_tasks.py
@@ -19,7 +19,6 @@ from __future__ import annotations
import html
import json
-import re
import unittest.mock
import urllib.parse
from getpass import getuser
@@ -29,11 +28,9 @@ import pytest
import time_machine
from airflow import settings
-from airflow.exceptions import AirflowException
from airflow.models.dag import DAG, DagModel
from airflow.models.dagbag import DagBag
from airflow.models.dagcode import DagCode
-from airflow.models.taskfail import TaskFail
from airflow.models.taskinstance import TaskInstance
from airflow.models.taskreschedule import TaskReschedule
from airflow.models.xcom import XCom
@@ -1012,49 +1009,6 @@ def test_action_muldelete_task_instance(session,
admin_client, task_search_tuple
assert session.query(TaskReschedule).count() == 0
-def test_task_fail_duration(app, admin_client, dag_maker, session):
- """Task duration page with a TaskFail entry should render without error."""
- with dag_maker() as dag:
- op1 = BashOperator(task_id="fail", bash_command="exit 1")
- op2 = BashOperator(task_id="success", bash_command="exit 0")
-
- with pytest.raises(AirflowException):
- op1.run()
- op2.run()
-
- op1_fails = (
- session.query(TaskFail)
- .filter(
- TaskFail.task_id == "fail",
- TaskFail.dag_id == dag.dag_id,
- )
- .all()
- )
-
- op2_fails = (
- session.query(TaskFail)
- .filter(
- TaskFail.task_id == "success",
- TaskFail.dag_id == dag.dag_id,
- )
- .all()
- )
-
- assert len(op1_fails) == 1
- assert len(op2_fails) == 0
-
- with unittest.mock.patch.object(app, "dag_bag") as mocked_dag_bag:
- mocked_dag_bag.get_dag.return_value = dag
- resp = admin_client.get(f"dags/{dag.dag_id}/duration",
follow_redirects=True)
- html = resp.get_data().decode()
- cumulative_chart = json.loads(re.search("data_cumlinechart=(.*);",
html).group(1))
- line_chart = json.loads(re.search("data_linechart=(.*);",
html).group(1))
-
- assert resp.status_code == 200
- assert sorted(item["key"] for item in cumulative_chart) == ["fail",
"success"]
- assert sorted(item["key"] for item in line_chart) == ["fail",
"success"]
-
-
def test_graph_view_doesnt_fail_on_recursion_error(app, dag_maker,
admin_client):
"""Test that the graph view doesn't fail on a recursion error."""
from airflow.models.baseoperator import chain