diff --git a/airflow-core/src/airflow/ui/package.json b/airflow-core/src/airflow/ui/package.json index 30b4eaedd4fb6..927364de54ab6 100644 --- a/airflow-core/src/airflow/ui/package.json +++ b/airflow-core/src/airflow/ui/package.json @@ -32,6 +32,7 @@ "axios": "^1.8.4", "chakra-react-select": "6.1.0", "chart.js": "^4.4.9", + "chartjs-adapter-dayjs-4": "^1.0.4", "chartjs-plugin-annotation": "^3.1.0", "dayjs": "^1.11.13", "debounce-promise": "^3.1.2", diff --git a/airflow-core/src/airflow/ui/pnpm-lock.yaml b/airflow-core/src/airflow/ui/pnpm-lock.yaml index 0f84ca9ef7902..d78abd26fdcc0 100644 --- a/airflow-core/src/airflow/ui/pnpm-lock.yaml +++ b/airflow-core/src/airflow/ui/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: chart.js: specifier: ^4.4.9 version: 4.4.9 + chartjs-adapter-dayjs-4: + specifier: ^1.0.4 + version: 1.0.4(chart.js@4.4.9)(dayjs@1.11.13) chartjs-plugin-annotation: specifier: ^3.1.0 version: 3.1.0(chart.js@4.4.9) @@ -1975,6 +1978,13 @@ packages: resolution: {integrity: sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==} engines: {pnpm: '>=8'} + chartjs-adapter-dayjs-4@1.0.4: + resolution: {integrity: sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==} + engines: {node: '>=10'} + peerDependencies: + chart.js: '>=4.0.1' + dayjs: ^1.9.7 + chartjs-plugin-annotation@3.1.0: resolution: {integrity: sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==} peerDependencies: @@ -7010,6 +7020,11 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 + chartjs-adapter-dayjs-4@1.0.4(chart.js@4.4.9)(dayjs@1.11.13): + dependencies: + chart.js: 4.4.9 + dayjs: 1.11.13 + chartjs-plugin-annotation@3.1.0(chart.js@4.4.9): dependencies: chart.js: 4.4.9 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..fc8463c169f78 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 @@ -65,6 +65,7 @@ "panel": { "buttons": { "options": "Options", + "showGantt": "Show Gantt", "showGraph": "Show Graph", "showGrid": "Show Grid" }, 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..8e9f4fb6e6942 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 @@ -65,6 +65,7 @@ "panel": { "buttons": { "options": "選項", + "showGantt": "顯示甘特圖", "showGraph": "顯示圖表", "showGrid": "顯示網格" }, diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx index cc9ca8a975d44..e536061cded1a 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx @@ -39,6 +39,7 @@ import { Tooltip } from "src/components/ui/Tooltip"; import { OpenGroupsProvider } from "src/context/openGroups"; import { DagBreadcrumb } from "./DagBreadcrumb"; +import { Gantt } from "./Gantt/Gantt"; import { Graph } from "./Graph"; import { Grid } from "./Grid"; import { NavTabs } from "./NavTabs"; @@ -52,12 +53,14 @@ type Props = { export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { const { t: translate } = useTranslation(); - const { dagId = "" } = useParams(); + const { dagId = "", runId } = useParams(); const { data: dag } = useDagServiceGetDag({ dagId }); const [defaultDagView] = useLocalStorage<"graph" | "grid">("default_dag_view", "grid"); const panelGroupRef = useRef(null); const [dagView, setDagView] = useLocalStorage<"graph" | "grid">(`dag_view-${dagId}`, defaultDagView); const [limit, setLimit] = useLocalStorage(`dag_runs_limit-${dagId}`, 10); + + const [showGantt, setShowGantt] = useLocalStorage(`show_gantt-${dagId}`, true); const { fitView, getZoom } = useReactFlow(); const { data: warningData } = useDagWarningServiceListDagWarnings({ dagId }); const { onClose, onOpen, open } = useDisclosure(); @@ -110,7 +113,12 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { key={`${dagView}-${direction}`} ref={panelGroupRef} > - + { panelGroupRef={panelGroupRef} setDagView={setDagView} setLimit={setLimit} + setShowGantt={setShowGantt} + showGantt={showGantt} /> - {dagView === "graph" ? : } + {dagView === "graph" ? ( + + ) : ( + + + {showGantt ? : undefined} + + )} {!isRightPanelCollapsed && ( diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx new file mode 100644 index 0000000000000..8e1568aa0e23f --- /dev/null +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -0,0 +1,291 @@ +/*! + * 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, useToken } from "@chakra-ui/react"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Filler, + Title, + Tooltip, + Legend, + TimeScale, + type TooltipItem, +} from "chart.js"; +import "chart.js/auto"; +import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm"; +import annotationPlugin from "chartjs-plugin-annotation"; +import { useMemo, useRef } from "react"; +import { Bar } from "react-chartjs-2"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; + +import { useTaskInstanceServiceGetTaskInstances } from "openapi/queries"; +import { useColorMode } from "src/context/colorMode"; +import { useOpenGroups } from "src/context/openGroups"; +import { useTimezone } from "src/context/timezone"; +import { flattenNodes } from "src/layouts/Details/Grid/utils"; +import { useGridRuns } from "src/queries/useGridRuns"; +import { useGridStructure } from "src/queries/useGridStructure"; +import { useGridTiSummaries } from "src/queries/useGridTISummaries"; +import { system } from "src/theme"; +import { getDuration, isStatePending, useAutoRefresh } from "src/utils"; +import { formatDate } from "src/utils/datetimeUtils"; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + BarElement, + LineElement, + Filler, + Title, + Tooltip, + Legend, + annotationPlugin, + TimeScale, +); + +type Props = { + readonly limit: number; +}; + +const CHART_PADDING = 36; +const CHART_ROW_HEIGHT = 20; +const MIN_BAR_WIDTH = 10; + +export const Gantt = ({ limit }: Props) => { + const { dagId = "", groupId: selectedGroupId, runId, taskId: selectedTaskId } = useParams(); + const { openGroupIds } = useOpenGroups(); + const { t: translate } = useTranslation("common"); + const { selectedTimezone } = useTimezone(); + const { colorMode } = useColorMode(); + const ref = useRef(); + + const [lightGridColor, darkGridColor, lightSelectedColor, darkSelectedColor] = useToken("colors", [ + "gray.200", + "gray.800", + "blue.200", + "blue.800", + ]); + const gridColor = colorMode === "light" ? lightGridColor : darkGridColor; + const selectedItemColor = colorMode === "light" ? lightSelectedColor : darkSelectedColor; + + const { data: gridRuns, isLoading: runsLoading } = useGridRuns({ limit }); + const { data: dagStructure, isLoading: structureLoading } = useGridStructure({ limit }); + const selectedRun = gridRuns?.find((run) => run.run_id === runId); + const refetchInterval = useAutoRefresh({ dagId }); + + // Get grid summaries for groups (which have min/max times) + const { data: gridTiSummaries, isLoading: summariesLoading } = useGridTiSummaries({ + dagId, + runId: runId ?? "", + state: selectedRun?.state, + }); + + // Get individual task instances for tasks (which have start/end times) + const { data: taskInstancesData, isLoading: tiLoading } = useTaskInstanceServiceGetTaskInstances( + { + dagId, + dagRunId: runId ?? "~", + }, + undefined, + { + enabled: Boolean(dagId), + refetchInterval: (query) => + query.state.data?.task_instances.some((ti) => isStatePending(ti.state)) ? refetchInterval : false, + }, + ); + + const { flatNodes } = useMemo(() => flattenNodes(dagStructure, openGroupIds), [dagStructure, openGroupIds]); + + const isLoading = runsLoading || structureLoading || summariesLoading || tiLoading; + + const data = useMemo(() => { + if (isLoading || runId === undefined) { + return []; + } + + const gridSummaries = gridTiSummaries?.task_instances ?? []; + const taskInstances = taskInstancesData?.task_instances ?? []; + + return flatNodes + .map((node) => { + const gridSummary = gridSummaries.find((ti) => ti.task_id === node.id); + + if (node.isGroup && gridSummary) { + // Group node - use min/max times from grid summary + return { + isGroup: true, + state: gridSummary.state, + x: [ + formatDate(gridSummary.min_start_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + formatDate(gridSummary.max_end_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + ], + y: gridSummary.task_id, + }; + } else if (!node.isGroup) { + // Individual task - use individual task instance data + const taskInstance = taskInstances.find((ti) => ti.task_id === node.id); + + if (taskInstance) { + return { + isGroup: false, + state: taskInstance.state, + x: [ + formatDate(taskInstance.start_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + formatDate(taskInstance.end_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + ], + y: taskInstance.task_id, + }; + } + } + + return undefined; + }) + .filter((item) => item !== undefined); + }, [flatNodes, gridTiSummaries, taskInstancesData, selectedTimezone, isLoading, runId]); + + const chartData = useMemo( + () => ({ + datasets: [ + { + backgroundColor: data.map( + (dataItem) => + system.tokens.categoryMap.get("colors")?.get(`${dataItem.state}.600`)?.value as string, + ), + data, + maxBarThickness: CHART_ROW_HEIGHT, + minBarLength: MIN_BAR_WIDTH, + }, + ], + labels: [], + }), + [data], + ); + + const fixedHeight = flatNodes.length * CHART_ROW_HEIGHT + CHART_PADDING; + const selectedId = selectedTaskId ?? selectedGroupId; + + const chartOptions = useMemo( + () => ({ + animation: { + duration: 100, + }, + indexAxis: "y" as const, + maintainAspectRatio: false, + plugins: { + annotation: { + annotations: + selectedId === undefined + ? [] + : [ + { + backgroundColor: selectedItemColor, + borderWidth: 0, + drawTime: "beforeDatasetsDraw" as const, + type: "box" as const, + xMax: "max" as const, + xMin: "min" as const, + yMax: data.findIndex((dataItem) => dataItem.y === selectedId) + 0.5, + yMin: data.findIndex((dataItem) => dataItem.y === selectedId) - 0.5, + }, + ], + }, + legend: { + display: false, + }, + tooltip: { + callbacks: { + afterBody(tooltipItems: Array>) { + const taskInstance = data.find((dataItem) => dataItem.y === tooltipItems[0]?.label); + const startDate = formatDate(taskInstance?.x[0], selectedTimezone); + const endDate = formatDate(taskInstance?.x[1], selectedTimezone); + + return [ + `${translate("startDate")}: ${startDate}`, + `${translate("endDate")}: ${endDate}`, + `${translate("duration")}: ${getDuration(taskInstance?.x[0], taskInstance?.x[1])}`, + ]; + }, + label(tooltipItem: TooltipItem<"bar">) { + const { label } = tooltipItem; + const taskInstance = data.find((dataItem) => dataItem.y === label); + + return `${translate("state")}: ${translate(`states.${taskInstance?.state}`)}`; + }, + }, + }, + }, + resizeDelay: 100, + responsive: true, + scales: { + x: { + grid: { + color: gridColor, + display: true, + }, + max: formatDate(selectedRun?.end_date, selectedTimezone), + min: formatDate(selectedRun?.start_date, selectedTimezone), + position: "top" as const, + stacked: true, + ticks: { + align: "start" as const, + callback: (value: number | string) => formatDate(value, selectedTimezone, "HH:mm:ss"), + maxRotation: 8, + maxTicksLimit: 8, + minRotation: 8, + }, + type: "time" as const, + }, + y: { + grid: { + color: gridColor, + display: true, + }, + stacked: true, + ticks: { + display: false, + }, + }, + }, + }), + [data, selectedId, selectedItemColor, gridColor, selectedRun, selectedTimezone, translate], + ); + + if (runId === undefined) { + return undefined; + } + + return ( + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/index.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/index.ts new file mode 100644 index 0000000000000..24f6dabe4cfe7 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/index.ts @@ -0,0 +1,20 @@ +/*! + * 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 * from "./Gantt"; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx index f8a618015c38c..a5b1fb4429882 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx @@ -55,9 +55,10 @@ const getArrowsForMode = (navigationMode: string) => { type Props = { readonly limit: number; + readonly showGantt?: boolean; }; -export const Grid = ({ limit }: Props) => { +export const Grid = ({ limit, showGantt }: Props) => { const { t: translate } = useTranslation("dag"); const gridRef = useRef(null); const { isGridFocused, setIsGridFocused } = useGridStore(); @@ -138,10 +139,10 @@ export const Grid = ({ limit }: Props) => { onMouseDown={() => setGridFocus(true)} outline="none" position="relative" - pt={50} + pt={20} ref={gridRef} tabIndex={0} - width="100%" + width={showGantt ? undefined : "100%"} > {translate("navigation.navigation", { arrow: getArrowsForMode(mode) })} @@ -157,7 +158,12 @@ export const Grid = ({ limit }: Props) => { - + {Boolean(gridRuns?.length) && ( <> diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx index 52dbd974dab31..2684be54a6435 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx @@ -1,3 +1,5 @@ +/* eslint-disable max-lines */ + /*! * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -25,6 +27,7 @@ import { Popover, Portal, Select, + VStack, } from "@chakra-ui/react"; import { useReactFlow } from "@xyflow/react"; import { useTranslation } from "react-i18next"; @@ -36,6 +39,7 @@ import { useLocalStorage } from "usehooks-ts"; import { DagVersionSelect } from "src/components/DagVersionSelect"; import { directionOptions, type Direction } from "src/components/Graph/useGraphLayout"; import { Button } from "src/components/ui"; +import { Checkbox } from "src/components/ui/Checkbox"; import { DagRunSelect } from "./DagRunSelect"; import { ToggleGroups } from "./ToggleGroups"; @@ -46,6 +50,8 @@ type Props = { readonly panelGroupRef: React.RefObject<{ setLayout?: (layout: Array) => void } & HTMLDivElement>; readonly setDagView: (x: "graph" | "grid") => void; readonly setLimit: React.Dispatch>; + readonly setShowGantt: React.Dispatch>; + readonly showGantt: boolean; }; const getOptions = (translate: (key: string) => string) => @@ -72,10 +78,19 @@ const deps = ["all", "immediate", "tasks"]; type Dependency = (typeof deps)[number]; -export const PanelButtons = ({ dagView, limit, panelGroupRef, setDagView, setLimit }: Props) => { +export const PanelButtons = ({ + dagView, + limit, + panelGroupRef, + setDagView, + setLimit, + setShowGantt, + showGantt, +}: Props) => { const { t: translate } = useTranslation(["components", "dag"]); - const { dagId = "" } = useParams(); + const { dagId = "", runId } = useParams(); const { fitView } = useReactFlow(); + const shouldShowToggleButtons = Boolean(runId); const [dependencies, setDependencies, removeDependencies] = useLocalStorage( `dependencies-${dagId}`, "tasks", @@ -165,7 +180,7 @@ export const PanelButtons = ({ dagView, limit, panelGroupRef, setDagView, setLim - + {dagView === "graph" ? ( <> @@ -227,33 +242,42 @@ export const PanelButtons = ({ dagView, limit, panelGroupRef, setDagView, setLim ) : ( - - {translate("dag:panel.dagRuns.label")} - - - - - - - - - - - {displayRunOptions.items.map((option) => ( - - {option.label} - - ))} - - - + <> + + {translate("dag:panel.dagRuns.label")} + + + + + + + + + + + {displayRunOptions.items.map((option) => ( + + {option.label} + + ))} + + + + {shouldShowToggleButtons ? ( + + setShowGantt(!showGantt)} size="sm"> + {translate("dag:panel.buttons.showGantt")} + + + ) : undefined} + )} diff --git a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts index 4691859d5d37a..bcc1f65731635 100644 --- a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts +++ b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts @@ -18,8 +18,10 @@ */ import dayjs from "dayjs"; import dayjsDuration from "dayjs/plugin/duration"; +import tz from "dayjs/plugin/timezone"; dayjs.extend(dayjsDuration); +dayjs.extend(tz); export const renderDuration = (durationSeconds: number | null | undefined): string => { if ( @@ -45,3 +47,15 @@ export const getDuration = (startDate?: string | null, endDate?: string | null) return renderDuration(seconds); }; + +export const formatDate = ( + date: number | string | null | undefined, + timezone: string, + format: string = "YYYY-MM-DD HH:mm:ss", +) => { + if (date === null || date === undefined || !dayjs(date).isValid()) { + return dayjs().tz(timezone).format(format); + } + + return dayjs(date).tz(timezone).format(format); +};