Skip to content

Commit

Permalink
Add calendar view to react (#37909)
Browse files Browse the repository at this point in the history
  • Loading branch information
bbovenzi committed Mar 7, 2024
1 parent 4d41eb5 commit 354e633
Show file tree
Hide file tree
Showing 6 changed files with 361 additions and 0 deletions.
2 changes: 2 additions & 0 deletions airflow/www/static/js/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import useDagRuns from "./useDagRuns";
import useHistoricalMetricsData from "./useHistoricalMetricsData";
import { useTaskXcomEntry, useTaskXcomCollection } from "./useTaskXcom";
import useEventLogs from "./useEventLogs";
import useCalendarData from "./useCalendarData";

axios.interceptors.request.use((config) => {
config.paramsSerializer = {
Expand Down Expand Up @@ -98,4 +99,5 @@ export {
useTaskXcomEntry,
useTaskXcomCollection,
useEventLogs,
useCalendarData,
};
48 changes: 48 additions & 0 deletions airflow/www/static/js/api/useCalendarData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { useQuery } from "react-query";
import axios, { AxiosResponse } from "axios";

import { getMetaValue } from "src/utils";

const DAG_ID_PARAM = "dag_id";

const dagId = getMetaValue(DAG_ID_PARAM);
const calendarDataUrl = getMetaValue("calendar_data_url");

interface DagState {
count: number;
date: string;
state: string;
}

interface CalendarData {
dagStates: DagState[];
}

const useCalendarData = () =>
useQuery(["calendarData"], async () => {
const params = {
[DAG_ID_PARAM]: dagId,
};
return axios.get<AxiosResponse, CalendarData>(calendarDataUrl, { params });
});

export default useCalendarData;
195 changes: 195 additions & 0 deletions airflow/www/static/js/dag/details/dag/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*!
* 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 moment */

import React from "react";
import type { EChartsOption } from "echarts";
import { Spinner } from "@chakra-ui/react";

import ReactECharts from "src/components/ReactECharts";
import { useCalendarData } from "src/api";
import useFilters from "src/dag/useFilters";

const Calendar = () => {
const { onBaseDateChange } = useFilters();
const { data: calendarData, isLoading } = useCalendarData();

if (isLoading) return <Spinner />;
if (!calendarData) return null;

const { dagStates } = calendarData;

const startDate = dagStates[0].date;
const endDate = dagStates[dagStates.length - 1].date;
// @ts-ignore
const startYear = moment(startDate).year();
// @ts-ignore
const endYear = moment(endDate).year();

const calendarOption: EChartsOption["calendar"] = [];
const seriesOption: EChartsOption["series"] = [];

const flatDates: Record<string, any> = {};
const plannedDates: Record<string, any> = {};
dagStates.forEach((ds) => {
if (ds.state !== "planned") {
flatDates[ds.date] = {
...flatDates[ds.date],
[ds.state]: ds.count,
};
} else {
plannedDates[ds.date] = {
[ds.state]: ds.count,
};
}
});

const proportions = Object.keys(flatDates).map((key) => {
const date = key;
const states = flatDates[key];
const total =
(states.failed || 0) + (states.success || 0) + (states.running || 0);
const percent = ((states.success || 0) + (states.running || 0)) / total;
return [date, Math.round(percent * 100)];
});

// We need to split the data into multiple years of calendars
if (startYear !== endYear) {
for (let y = startYear; y <= endYear; y += 1) {
const index = y - startYear;
const yearStartDate = y === startYear ? startDate : `${y}-01-01`;
const yearEndDate = `${y}-12-31`;
calendarOption.push({
left: 100,
top: index * 150 + 20,
range: [yearStartDate, yearEndDate],
cellSize: 15,
});
seriesOption.push({
calendarIndex: index,
type: "heatmap",
coordinateSystem: "calendar",
data: proportions.filter(
(p) => typeof p[0] === "string" && p[0].startsWith(y.toString())
),
});
seriesOption.push({
calendarIndex: index,
type: "scatter",
coordinateSystem: "calendar",
symbolSize: 4,
data: dagStates
.filter(
(ds) => ds.date.startsWith(y.toString()) && ds.state === "planned"
)
.map((ds) => [ds.date, ds.count]),
});
}
} else {
calendarOption.push({
top: 20,
left: 100,
range: [startDate, `${endYear}-12-31`],
cellSize: 15,
});
seriesOption.push({
type: "heatmap",
coordinateSystem: "calendar",
data: proportions,
});
seriesOption.push({
type: "scatter",
coordinateSystem: "calendar",
symbolSize: () => 4,
data: dagStates
.filter((ds) => ds.state === "planned")
.map((ds) => [ds.date, ds.count]),
});
}

const scatterIndexes: number[] = [];
const heatmapIndexes: number[] = [];

seriesOption.forEach((s, i) => {
if (s.type === "heatmap") heatmapIndexes.push(i);
else if (s.type === "scatter") scatterIndexes.push(i);
});

const option: EChartsOption = {
tooltip: {
formatter: (p: any) => {
const date = p.data[0];
const states = flatDates[date];
const plannedCount =
p.componentSubType === "scatter"
? p.data[1]
: plannedDates[date]?.planned || 0;
// @ts-ignore
const formattedDate = moment(date).format("ddd YYYY-MM-DD");

return `
<strong>${formattedDate}</strong> <br>
${plannedCount ? `Planned ${plannedCount} <br>` : ""}
${states?.failed ? `Failed ${states.failed} <br>` : ""}
${states?.running ? `Running ${states.running} <br>` : ""}
${states?.success ? `Success ${states.success} <br>` : ""}
`;
},
},
visualMap: [
{
min: 0,
max: 100,
text: ["% Success", "Failed"],
calculable: true,
orient: "vertical",
left: "0",
top: "0",
seriesIndex: heatmapIndexes,
inRange: {
color: [
stateColors.failed,
stateColors.up_for_retry,
stateColors.success,
],
},
},
{
seriesIndex: scatterIndexes,
inRange: {
color: "gray",
opacity: 0.6,
},
},
],
calendar: calendarOption,
series: seriesOption,
};

const events = {
click(p: any) {
onBaseDateChange(p.data[0]);
},
};

return <ReactECharts option={option} events={events} />;
};

export default Calendar;
17 changes: 17 additions & 0 deletions airflow/www/static/js/dag/details/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
MdSyncAlt,
MdHourglassBottom,
MdPlagiarism,
MdEvent,
} from "react-icons/md";
import { BiBracket } from "react-icons/bi";
import URLSearchParamsWrapper from "src/utils/URLSearchParamWrapper";
Expand All @@ -66,6 +67,7 @@ import XcomCollection from "./taskInstance/Xcom";
import TaskDetails from "./task";
import AuditLog from "./AuditLog";
import RunDuration from "./dag/RunDuration";
import Calendar from "./dag/Calendar";

const dagId = getMetaValue("dag_id")!;

Expand All @@ -92,6 +94,7 @@ const tabToIndex = (tab?: string) => {
case "run_duration":
return 5;
case "xcom":
case "calendar":
return 6;
case "details":
default:
Expand Down Expand Up @@ -129,6 +132,7 @@ const indexToTab = (
if (!runId && !taskId) return "run_duration";
return undefined;
case 6:
if (!runId && !taskId) return "calendar";
if (isTaskInstance) return "xcom";
return undefined;
default:
Expand Down Expand Up @@ -323,6 +327,14 @@ const Details = ({
</Text>
</Tab>
)}
{isDag && (
<Tab>
<MdEvent size={16} />
<Text as="strong" ml={1}>
Calendar
</Text>
</Tab>
)}
{isTaskInstance && (
<Tab>
<MdReorder size={16} />
Expand Down Expand Up @@ -418,6 +430,11 @@ const Details = ({
<RunDuration />
</TabPanel>
)}
{isDag && (
<TabPanel height="100%" width="100%">
<Calendar />
</TabPanel>
)}
{isTaskInstance && run && (
<TabPanel
pt={mapIndex !== undefined ? "0px" : undefined}
Expand Down
1 change: 1 addition & 0 deletions airflow/www/templates/airflow/dag.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
<meta name="confirm_url" content="{{ url_for('Airflow.confirm') }}">
<meta name="grid_data_url" content="{{ url_for('Airflow.grid_data') }}">
<meta name="graph_data_url" content="{{ url_for('Airflow.graph_data') }}">
<meta name="calendar_data_url" content="{{ url_for('Airflow.calendar_data') }}">
<meta name="next_run_datasets_url" content="{{ url_for('Airflow.next_run_datasets', dag_id=dag.dag_id) }}">
<meta name="grid_url" content="{{ url_for('Airflow.grid', dag_id=dag.dag_id) }}">
<meta name="datasets_url" content="{{ url_for('Airflow.datasets') }}">
Expand Down

0 comments on commit 354e633

Please sign in to comment.