diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json index 8dda576063e0b..f7fa6cbea5ab0 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json @@ -170,6 +170,7 @@ "failed": "Failed", "no_status": "No Status", "none": "No Status", + "planned": "Planned", "queued": "Queued", "removed": "Removed", "restarting": "Restarting", diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json index a3086158bc405..37fc56222d14d 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json @@ -5,6 +5,33 @@ "reason": "Reason", "title": "Dependencies Blocking Task From Getting Scheduled" }, + "calendar": { + "daily": "Daily", + "hourly": "Hourly", + "legend": { + "less": "Less", + "more": "More" + }, + "navigation": { + "nextMonth": "Next month", + "nextYear": "Next year", + "previousMonth": "Previous month", + "previousYear": "Previous year" + }, + "noData": "No data available", + "noRuns": "No runs", + "totalRuns": "Total Runs", + "week": "Week {{weekNumber}}", + "weekdays": { + "friday": "Fri", + "monday": "Mon", + "saturday": "Sat", + "sunday": "Sun", + "thursday": "Thu", + "tuesday": "Tue", + "wednesday": "Wed" + } + }, "code": { "bundleUrl": "Bundle Url", "noCode": "No Code Found", @@ -101,6 +128,7 @@ "assetEvents": "Asset Events", "auditLog": "Audit Log", "backfills": "Backfills", + "calendar": "Calendar", "code": "Code", "details": "Details", "logs": "Logs", diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json index 1423588781385..01bb01728d4cb 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json @@ -170,6 +170,7 @@ "failed": "失敗", "no_status": "無狀態", "none": "無狀態", + "planned": "已計劃", "queued": "排隊中", "removed": "已移除", "restarting": "重啟中", diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json index 622b088679bb0..aefd59566eec3 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json @@ -5,6 +5,34 @@ "reason": "原因", "title": "依賴 (Dependencies) 阻礙任務排程" }, + "calendar": { + "daily": "每日", + "error": "載入日曆資料時發生錯誤", + "hourly": "每小時", + "legend": { + "less": "較少", + "more": "較多" + }, + "navigation": { + "nextMonth": "下一月", + "nextYear": "下一年", + "previousMonth": "上一月", + "previousYear": "上一年" + }, + "noData": "沒有可用的資料", + "noRuns": "沒有執行", + "totalRuns": "總執行數", + "week": "第 {{weekNumber}} 週", + "weekdays": { + "friday": "五", + "monday": "一", + "saturday": "六", + "sunday": "日", + "thursday": "四", + "tuesday": "二", + "wednesday": "三" + } + }, "code": { "bundleUrl": "套件包網址", "noCode": "找不到程式碼", @@ -101,6 +129,7 @@ "assetEvents": "資源事件", "auditLog": "審計日誌", "backfills": "回填", + "calendar": "日曆", "code": "程式碼", "details": "詳細資訊", "logs": "日誌", diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx new file mode 100644 index 0000000000000..ec72c813ab133 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx @@ -0,0 +1,270 @@ +/*! + * 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 { Box, HStack, Text, IconButton, Button, ButtonGroup } from "@chakra-ui/react"; +import { keyframes } from "@emotion/react"; +import dayjs from "dayjs"; +import { useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { FiChevronLeft, FiChevronRight } from "react-icons/fi"; +import { useParams } from "react-router-dom"; +import { useLocalStorage } from "usehooks-ts"; + +import { useCalendarServiceGetCalendar } from "openapi/queries"; +import { ErrorAlert } from "src/components/ErrorAlert"; + +import { CalendarLegend } from "./CalendarLegend"; +import { DailyCalendarView } from "./DailyCalendarView"; +import { HourlyCalendarView } from "./HourlyCalendarView"; + +const spin = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +`; + +export const Calendar = () => { + const { dagId = "" } = useParams(); + const { t: translate } = useTranslation("dag"); + const [selectedDate, setSelectedDate] = useState(dayjs()); + const [granularity, setGranularity] = useLocalStorage<"daily" | "hourly">("calendar-granularity", "hourly"); + const [viewMode, setViewMode] = useLocalStorage<"failed" | "total">("calendar-view-mode", "total"); + + const currentDate = dayjs(); + + const dateRange = useMemo(() => { + if (granularity === "daily") { + const yearStart = selectedDate.startOf("year"); + const yearEnd = selectedDate.endOf("year"); + + return { + logicalDateGte: yearStart.format("YYYY-MM-DD[T]HH:mm:ss[Z]"), + logicalDateLte: yearEnd.format("YYYY-MM-DD[T]HH:mm:ss[Z]"), + }; + } else { + const monthStart = selectedDate.startOf("month"); + const monthEnd = selectedDate.endOf("month"); + + return { + logicalDateGte: monthStart.format("YYYY-MM-DD[T]HH:mm:ss[Z]"), + logicalDateLte: monthEnd.format("YYYY-MM-DD[T]HH:mm:ss[Z]"), + }; + } + }, [granularity, selectedDate]); + + const { data, error, isLoading } = useCalendarServiceGetCalendar( + { + dagId, + granularity, + ...dateRange, + }, + undefined, + { enabled: Boolean(dagId) }, + ); + + if (!data && !isLoading) { + return ( + + {translate("calendar.noData")} + + ); + } + + return ( + + + + + {granularity === "daily" ? ( + + setSelectedDate(selectedDate.subtract(1, "year"))} + size="sm" + variant="ghost" + > + + + { + if (selectedDate.year() !== currentDate.year()) { + setSelectedDate(currentDate.startOf("year")); + } + }} + textAlign="center" + > + {selectedDate.year()} + + setSelectedDate(selectedDate.add(1, "year"))} + size="sm" + variant="ghost" + > + + + + ) : ( + + setSelectedDate(selectedDate.subtract(1, "month"))} + size="sm" + variant="ghost" + > + + + { + if ( + !(selectedDate.isSame(currentDate, "month") && selectedDate.isSame(currentDate, "year")) + ) { + setSelectedDate(currentDate.startOf("month")); + } + }} + textAlign="center" + > + {selectedDate.format("MMM YYYY")} + + setSelectedDate(selectedDate.add(1, "month"))} + size="sm" + variant="ghost" + > + + + + )} + + + + + + + + + + + + + + + {isLoading ? ( + + + + + + ) : undefined} + {granularity === "daily" ? ( + <> + + + + ) : ( + + + + + + + + + )} + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx new file mode 100644 index 0000000000000..d10864fc15ea1 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx @@ -0,0 +1,50 @@ +/*! + * 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 { Box } from "@chakra-ui/react"; + +import { CalendarTooltip } from "./CalendarTooltip"; +import { useDelayedTooltip } from "./useDelayedTooltip"; + +type Props = { + readonly backgroundColor: Record | string; + readonly content: string; + readonly index?: number; + readonly marginRight?: string; +}; + +export const CalendarCell = ({ backgroundColor, content, index, marginRight }: Props) => { + const { handleMouseEnter, handleMouseLeave } = useDelayedTooltip(); + + const computedMarginRight = marginRight ?? (index !== undefined && index % 7 === 6 ? "8px" : "0"); + + return ( + + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarLegend.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarLegend.tsx new file mode 100644 index 0000000000000..d66ba21bb6f3c --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarLegend.tsx @@ -0,0 +1,113 @@ +/*! + * 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 { Box, HStack, Text, VStack } from "@chakra-ui/react"; +import { useTranslation } from "react-i18next"; + +import { Tooltip } from "src/components/ui"; + +import type { CalendarColorMode } from "./types"; + +type Props = { + readonly colorMode: CalendarColorMode; + readonly vertical?: boolean; +}; + +const totalRunsLegendData = [ + { color: { _dark: "gray.700", _light: "gray.100" }, label: "0" }, + { color: { _dark: "green.300", _light: "green.200" }, label: "1-5" }, + { color: { _dark: "green.500", _light: "green.400" }, label: "6-15" }, + { color: { _dark: "green.700", _light: "green.600" }, label: "16-25" }, + { color: { _dark: "green.900", _light: "green.800" }, label: "26+" }, +]; + +const failedRunsLegendData = [ + { color: { _dark: "gray.700", _light: "gray.100" }, label: "0" }, + { color: { _dark: "red.300", _light: "red.200" }, label: "1-2" }, + { color: { _dark: "red.500", _light: "red.400" }, label: "3-5" }, + { color: { _dark: "red.700", _light: "red.600" }, label: "6-10" }, + { color: { _dark: "red.900", _light: "red.800" }, label: "11+" }, +]; + +export const CalendarLegend = ({ colorMode, vertical = false }: Props) => { + const { t: translate } = useTranslation("dag"); + + const legendData = colorMode === "total" ? totalRunsLegendData : failedRunsLegendData; + const legendTitle = + colorMode === "total" ? translate("calendar.totalRuns") : translate("overview.buttons.failedRun_other"); + + return ( + + + + {legendTitle} + + {vertical ? ( + + + {translate("calendar.legend.more")} + + + {[...legendData].reverse().map(({ color, label }) => ( + + + + ))} + + + {translate("calendar.legend.less")} + + + ) : ( + + + {translate("calendar.legend.less")} + + + {legendData.map(({ color, label }) => ( + + + + ))} + + + {translate("calendar.legend.more")} + + + )} + + + + + + + + {translate("common:states.planned")} + + + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx new file mode 100644 index 0000000000000..16559614210ed --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx @@ -0,0 +1,69 @@ +/*! + * 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 { useMemo } from "react"; + +type Props = { + readonly content: string; +}; + +export const CalendarTooltip = ({ content }: Props) => { + const tooltipStyle = useMemo( + () => ({ + backgroundColor: "var(--chakra-colors-gray-800)", + borderRadius: "4px", + color: "white", + fontSize: "14px", + left: "50%", + opacity: 0, + padding: "8px", + pointerEvents: "none" as const, + position: "absolute" as const, + top: "22px", + transform: "translateX(-50%)", + transition: "opacity 0.2s, visibility 0.2s", + visibility: "hidden" as const, + whiteSpace: "nowrap" as const, + zIndex: 1000, + }), + [], + ); + + const arrowStyle = useMemo( + () => ({ + borderBottom: "4px solid var(--chakra-colors-gray-800)", + borderLeft: "4px solid transparent", + borderRight: "4px solid transparent", + content: '""', + height: 0, + left: "50%", + position: "absolute" as const, + top: "-4px", + transform: "translateX(-50%)", + width: 0, + }), + [], + ); + + return ( +
+
+ {content} +
+ ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx new file mode 100644 index 0000000000000..a0854b686fa93 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx @@ -0,0 +1,127 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* + * 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 { Box, Text } from "@chakra-ui/react"; +import dayjs from "dayjs"; +import { useTranslation } from "react-i18next"; + +import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen"; + +import { CalendarCell } from "./CalendarCell"; +import { createTooltipContent, generateDailyCalendarData, getCalendarCellColor } from "./calendarUtils"; +import type { CalendarColorMode } from "./types"; + +type Props = { + readonly colorMode: CalendarColorMode; + readonly data: Array; + readonly selectedYear: number; +}; + +export const DailyCalendarView = ({ colorMode, data, selectedYear }: Props) => { + const { t: translate } = useTranslation("dag"); + const dailyData = generateDailyCalendarData(data, selectedYear); + + const weekdays = [ + translate("calendar.weekdays.sunday"), + translate("calendar.weekdays.monday"), + translate("calendar.weekdays.tuesday"), + translate("calendar.weekdays.wednesday"), + translate("calendar.weekdays.thursday"), + translate("calendar.weekdays.friday"), + translate("calendar.weekdays.saturday"), + ]; + + return ( + + + + + {dailyData.map((week, index) => ( + + {Boolean(week[0] && dayjs(week[0].date).date() <= 7) && ( + + {dayjs(week[0]?.date).format("MMM")} + + )} + + ))} + + + + + {weekdays.map((day) => ( + + {day} + + ))} + + + {dailyData.map((week, weekIndex) => ( + + {week.map((day) => { + const dayDate = dayjs(day.date); + const isInSelectedYear = dayDate.year() === selectedYear; + + if (!isInSelectedYear) { + return ; + } + + return ( + + ); + })} + + ))} + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx new file mode 100644 index 0000000000000..b4a9e5c152015 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx @@ -0,0 +1,193 @@ +/*! + * 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. + */ + +/* + * 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 { Box, Text } from "@chakra-ui/react"; +import dayjs from "dayjs"; +import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; +import { useTranslation } from "react-i18next"; + +import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen"; + +import { CalendarCell } from "./CalendarCell"; +import { createTooltipContent, generateHourlyCalendarData, getCalendarCellColor } from "./calendarUtils"; +import type { CalendarColorMode } from "./types"; + +dayjs.extend(isSameOrBefore); + +type Props = { + readonly colorMode: CalendarColorMode; + readonly data: Array; + readonly selectedMonth: number; + readonly selectedYear: number; +}; + +export const HourlyCalendarView = ({ colorMode, data, selectedMonth, selectedYear }: Props) => { + const { t: translate } = useTranslation("dag"); + const hourlyData = generateHourlyCalendarData(data, selectedYear, selectedMonth); + + return ( + + + + + + {hourlyData.days.map((day, index) => { + const isFirstOfWeek = index % 7 === 0; + const weekNumber = Math.floor(index / 7) + 1; + + return ( + + {Boolean(isFirstOfWeek) && ( + + {translate("calendar.week", { weekNumber })} + + )} + + ); + })} + + + + + + {hourlyData.days.map((day, index) => { + const dayOfWeek = dayjs(day.day).day(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + const dateFontSize = "2xs"; + const dayNameFontSize = "2xs"; + const dayName = dayjs(day.day).format("dd").charAt(0); + + return ( + + + {dayjs(day.day).format("D")} + + + {dayName} + + + ); + })} + + + + + + + {Array.from({ length: 24 }, (_, hour) => ( + + {hour % 4 === 0 && hour.toString().padStart(2, "0")} + + ))} + + + {Array.from({ length: 24 }, (_, hour) => ( + + {hourlyData.days.map((day, index) => { + const hourData = day.hours.find((hourItem) => hourItem.hour === hour); + + if (!hourData) { + const noRunsTooltip = `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${translate("calendar.noRuns")}`; + + return ( + + ); + } + + const tooltipContent = + hourData.counts.total > 0 + ? `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${createTooltipContent(hourData).split(": ")[1]}` + : `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${translate("calendar.noRuns")}`; + + return ( + + ); + })} + + ))} + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts new file mode 100644 index 0000000000000..52b1cba570fea --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts @@ -0,0 +1,234 @@ +/*! + * 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 dayjs from "dayjs"; +import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; + +import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen"; + +import type { + RunCounts, + DailyCalendarData, + HourlyCalendarData, + CalendarCellData, + CalendarColorMode, +} from "./types"; + +dayjs.extend(isSameOrBefore); + +const createDailyDataMap = (data: Array) => { + const dailyDataMap = new Map>(); + + data.forEach((run) => { + const dateStr = run.date.slice(0, 10); // "YYYY-MM-DD" + const dailyRuns = dailyDataMap.get(dateStr); + + if (dailyRuns) { + dailyRuns.push(run); + } else { + dailyDataMap.set(dateStr, [run]); + } + }); + + return dailyDataMap; +}; + +const createHourlyDataMap = (data: Array) => { + const hourlyDataMap = new Map>(); + + data.forEach((run) => { + const hourStr = run.date.slice(0, 13); // "YYYY-MM-DDTHH" + const hourlyRuns = hourlyDataMap.get(hourStr); + + if (hourlyRuns) { + hourlyRuns.push(run); + } else { + hourlyDataMap.set(hourStr, [run]); + } + }); + + return hourlyDataMap; +}; + +export const calculateRunCounts = (runs: Array): RunCounts => { + const counts: { [K in keyof RunCounts]: number } = { + failed: 0, + planned: 0, + queued: 0, + running: 0, + success: 0, + total: 0, + }; + + runs.forEach((run) => { + const { count, state } = run; + + if (state in counts) { + counts[state] += count; + } + counts.total += count; + }); + + return counts as RunCounts; +}; + +const TOTAL_COLOR_INTENSITIES = [ + { _dark: "gray.700", _light: "gray.100" }, // 0 runs + { _dark: "green.300", _light: "green.200" }, // 1-5 runs + { _dark: "green.500", _light: "green.400" }, // 6-15 runs + { _dark: "green.700", _light: "green.600" }, // 16-25 runs + { _dark: "green.900", _light: "green.800" }, // 26+ runs +] as const; + +const FAILURE_COLOR_INTENSITIES = [ + { _dark: "gray.700", _light: "gray.100" }, // 0 failures + { _dark: "red.300", _light: "red.200" }, // 1-2 failures + { _dark: "red.500", _light: "red.400" }, // 3-5 failures + { _dark: "red.700", _light: "red.600" }, // 6-10 failures + { _dark: "red.900", _light: "red.800" }, // 11+ failures +] as const; + +const PLANNED_COLOR = { _dark: "scheduled.600", _light: "scheduled.200" }; + +const getIntensityLevel = (count: number, mode: CalendarColorMode): number => { + if (count === 0) { + return 0; + } + + if (mode === "total") { + if (count <= 5) { + return 1; + } + if (count <= 15) { + return 2; + } + if (count <= 25) { + return 3; + } + + return 4; + } else { + // failed runs mode + if (count <= 2) { + return 1; + } + if (count <= 5) { + return 2; + } + if (count <= 10) { + return 3; + } + + return 4; + } +}; + +export const getCalendarCellColor = ( + runs: Array, + colorMode: CalendarColorMode = "total", +): string | { _dark: string; _light: string } => { + if (runs.length === 0) { + return { _dark: "gray.700", _light: "gray.100" }; + } + + const counts = calculateRunCounts(runs); + + if (counts.planned > 0) { + return PLANNED_COLOR; + } + + const targetCount = colorMode === "total" ? counts.total : counts.failed; + const intensityLevel = getIntensityLevel(targetCount, colorMode); + const colorScheme = colorMode === "total" ? TOTAL_COLOR_INTENSITIES : FAILURE_COLOR_INTENSITIES; + + return colorScheme[intensityLevel] ?? { _dark: "gray.700", _light: "gray.100" }; +}; + +export const generateDailyCalendarData = ( + data: Array, + selectedYear: number, +): DailyCalendarData => { + const dailyDataMap = createDailyDataMap(data); + + const weeks = []; + const startOfYear = dayjs().year(selectedYear).startOf("year"); + const endOfYear = dayjs().year(selectedYear).endOf("year"); + + let currentDate = startOfYear.startOf("week"); + const endDate = endOfYear.endOf("week"); + + while (currentDate.isBefore(endDate) || currentDate.isSame(endDate, "day")) { + const week = []; + + for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) { + const dateStr = currentDate.format("YYYY-MM-DD"); + const runs = dailyDataMap.get(dateStr) ?? []; + const counts = calculateRunCounts(runs); + + week.push({ counts, date: dateStr, runs }); + currentDate = currentDate.add(1, "day"); + } + weeks.push(week); + } + + return weeks; +}; + +export const generateHourlyCalendarData = ( + data: Array, + selectedYear: number, + selectedMonth: number, +): HourlyCalendarData => { + const hourlyDataMap = createHourlyDataMap(data); + + const monthStart = dayjs().year(selectedYear).month(selectedMonth).startOf("month"); + const monthEnd = dayjs().year(selectedYear).month(selectedMonth).endOf("month"); + const monthData = []; + + let currentDate = monthStart; + + while (currentDate.isSameOrBefore(monthEnd, "day")) { + const dayHours = []; + + for (let hour = 0; hour < 24; hour += 1) { + const hourStr = currentDate.hour(hour).format("YYYY-MM-DDTHH"); + const runs = hourlyDataMap.get(hourStr) ?? []; + const counts = calculateRunCounts(runs); + + dayHours.push({ counts, date: `${hourStr}:00:00`, hour, runs }); + } + monthData.push({ day: currentDate.format("YYYY-MM-DD"), hours: dayHours }); + currentDate = currentDate.add(1, "day"); + } + + return { days: monthData, month: monthStart.format("MMM YYYY") }; +}; + +export const createTooltipContent = (cellData: CalendarCellData): string => { + const { counts, date } = cellData; + + if (counts.total === 0) { + return `${date}: No runs`; + } + + const parts = Object.entries(counts) + .filter(([key, value]) => key !== "total" && value > 0) + .map(([state, count]) => `${count} ${state}`); + + return `${date}: ${counts.total} runs (${parts.join(", ")})`; +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/index.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/index.ts new file mode 100644 index 0000000000000..85ffa1abfc2f6 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/index.ts @@ -0,0 +1,25 @@ +/*! + * 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. + */ + +export { Calendar } from "./Calendar"; +export { CalendarLegend } from "./CalendarLegend"; +export { DailyCalendarView } from "./DailyCalendarView"; +export { HourlyCalendarView } from "./HourlyCalendarView"; +export type * from "./types"; +export * from "./calendarUtils"; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/types.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/types.ts new file mode 100644 index 0000000000000..832e71f1e2713 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/types.ts @@ -0,0 +1,58 @@ +/*! + * 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 type { CalendarTimeRangeResponse } from "openapi/requests/types.gen"; + +export type DagRunState = "failed" | "planned" | "queued" | "running" | "success"; + +export type RunCounts = { + failed: number; + planned: number; + queued: number; + running: number; + success: number; + total: number; +}; + +export type CalendarCellData = { + readonly counts: RunCounts; + readonly date: string; + readonly runs: Array; +}; + +export type DayData = CalendarCellData; + +export type HourData = { + readonly hour: number; +} & CalendarCellData; + +export type WeekData = Array; + +export type DailyCalendarData = Array; + +export type HourlyCalendarData = { + readonly days: Array<{ + readonly day: string; + readonly hours: Array; + }>; + readonly month: string; +}; + +export type CalendarGranularity = "daily" | "hourly"; + +export type CalendarColorMode = "failed" | "total"; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/useDelayedTooltip.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/useDelayedTooltip.ts new file mode 100644 index 0000000000000..b2e0d17c0ea72 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/useDelayedTooltip.ts @@ -0,0 +1,60 @@ +/*! + * 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 { useRef } from "react"; + +export const useDelayedTooltip = (delayMs: number = 200) => { + const debounceTimeoutRef = useRef(undefined); + const activeTooltipRef = useRef(undefined); + + const handleMouseEnter = (event: React.MouseEvent) => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + const tooltipElement = event.currentTarget.querySelector("[data-tooltip]"); + + if (tooltipElement) { + activeTooltipRef.current = tooltipElement as HTMLElement; + debounceTimeoutRef.current = setTimeout(() => { + if (activeTooltipRef.current) { + activeTooltipRef.current.style.opacity = "1"; + activeTooltipRef.current.style.visibility = "visible"; + } + }, delayMs); + } + }; + + const handleMouseLeave = () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + debounceTimeoutRef.current = undefined; + } + + if (activeTooltipRef.current) { + activeTooltipRef.current.style.opacity = "0"; + activeTooltipRef.current.style.visibility = "hidden"; + activeTooltipRef.current = undefined; + } + }; + + return { + handleMouseEnter, + handleMouseLeave, + }; +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx index 455dbc2e7941a..3a1fff709487c 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx @@ -19,7 +19,7 @@ import { ReactFlowProvider } from "@xyflow/react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { FiBarChart, FiCode, FiUser } from "react-icons/fi"; +import { FiBarChart, FiCode, FiUser, FiCalendar } from "react-icons/fi"; import { LuChartColumn } from "react-icons/lu"; import { MdDetails, MdOutlineEventNote } from "react-icons/md"; import { RiArrowGoBackFill } from "react-icons/ri"; @@ -49,6 +49,7 @@ export const Dag = () => { { icon: , label: translate("tabs.overview"), value: "" }, { icon: , label: translate("tabs.runs"), value: "runs" }, { icon: , label: translate("tabs.tasks"), value: "tasks" }, + { icon: , label: translate("tabs.calendar"), value: "calendar" }, { icon: , label: translate("tabs.requiredActions"), value: "required_actions" }, { icon: , label: translate("tabs.backfills"), value: "backfills" }, { icon: , label: translate("tabs.auditLog"), value: "events" }, diff --git a/airflow-core/src/airflow/ui/src/router.tsx b/airflow-core/src/airflow/ui/src/router.tsx index 07ac8b7dd563e..093ea1b45bb05 100644 --- a/airflow-core/src/airflow/ui/src/router.tsx +++ b/airflow-core/src/airflow/ui/src/router.tsx @@ -29,6 +29,7 @@ import { Configs } from "src/pages/Configs"; import { Connections } from "src/pages/Connections"; import { Dag } from "src/pages/Dag"; import { Backfills } from "src/pages/Dag/Backfills"; +import { Calendar } from "src/pages/Dag/Calendar/Calendar"; import { Code } from "src/pages/Dag/Code"; import { Details as DagDetails } from "src/pages/Dag/Details"; import { Overview } from "src/pages/Dag/Overview"; @@ -161,6 +162,7 @@ export const routerConfig = [ { element: , index: true }, { element: , path: "runs" }, { element: , path: "tasks" }, + { element: , path: "calendar" }, { element: , path: "required_actions" }, { element: , path: "backfills" }, { element: , path: "events" }, diff --git a/airflow-core/src/airflow/ui/src/theme.ts b/airflow-core/src/airflow/ui/src/theme.ts index 706ae0ed57e41..88bac3fd19299 100644 --- a/airflow-core/src/airflow/ui/src/theme.ts +++ b/airflow-core/src/airflow/ui/src/theme.ts @@ -40,8 +40,8 @@ const customConfig = defineConfig({ "100": { value: "#C2FFC2" }, "200": { value: "#80FF80" }, "300": { value: "#42FF42" }, - "400": { value: "#00FF00" }, - "500": { value: "#00C200" }, + "400": { value: "#22C55E" }, + "500": { value: "#16A34A" }, "600": { value: "#008000" }, "700": { value: "#006100" }, "800": { value: "#004200" }, diff --git a/dev/react-plugin-tools/react_plugin_template/src/theme.ts b/dev/react-plugin-tools/react_plugin_template/src/theme.ts index 706ae0ed57e41..88bac3fd19299 100644 --- a/dev/react-plugin-tools/react_plugin_template/src/theme.ts +++ b/dev/react-plugin-tools/react_plugin_template/src/theme.ts @@ -40,8 +40,8 @@ const customConfig = defineConfig({ "100": { value: "#C2FFC2" }, "200": { value: "#80FF80" }, "300": { value: "#42FF42" }, - "400": { value: "#00FF00" }, - "500": { value: "#00C200" }, + "400": { value: "#22C55E" }, + "500": { value: "#16A34A" }, "600": { value: "#008000" }, "700": { value: "#006100" }, "800": { value: "#004200" },