diff --git a/airflow-core/src/airflow/serialization/encoders.py b/airflow-core/src/airflow/serialization/encoders.py index 2f30511a1e5fb..dcb064dcde06b 100644 --- a/airflow-core/src/airflow/serialization/encoders.py +++ b/airflow-core/src/airflow/serialization/encoders.py @@ -162,14 +162,6 @@ def _ensure_serialized(d): if isinstance(trigger, dict): classpath = trigger["classpath"] kwargs = trigger["kwargs"] - # unwrap any kwargs that are themselves serialized objects, to avoid double-serialization in the trigger's own serialize() method. - unwrapped = {} - for k, v in kwargs.items(): - if isinstance(v, dict) and Encoding.TYPE in v: - unwrapped[k] = BaseSerialization.deserialize(v) - else: - unwrapped[k] = v - kwargs = unwrapped else: classpath, kwargs = trigger.serialize() return { 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 58626c2494248..5abfee3e63c6b 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 @@ -7,6 +7,11 @@ }, "calendar": { "daily": "Daily", + "deadlines": { + "missed": "Missed", + "pending": "Pending", + "title": "Deadlines in Period" + }, "hourly": "Hourly", "legend": { "less": "Less", @@ -40,6 +45,31 @@ "parseDuration": "Parse Duration:", "parsedAt": "Parsed at:" }, + "deadlineAlerts": { + "completionRule": "The run must complete within {{interval}} of {{reference}}", + "count_one": "{{count}} deadline", + "count_other": "{{count}} deadlines", + "referenceType": { + "AverageRuntimeDeadline": "average runtime", + "DagRunLogicalDateDeadline": "logical date", + "DagRunQueuedAtDeadline": "queue time", + "FixedDatetimeDeadline": "fixed datetime" + }, + "title": "Deadline Alerts", + "unnamed": "Unnamed Alert" + }, + "deadlineStatus": { + "completionRule": "Must complete within {{interval}} of {{reference}}", + "deadlineIn": "Deadline in {{duration}}", + "finishedEarly": "Finished {{duration}} before deadline", + "finishedLate": "Finished {{duration}} after deadline", + "label": "Deadline", + "met": "Met", + "missed": "Missed", + "stillRunning": "Still running", + "upcoming": "Upcoming", + "viewAll": "View all {{count}}" + }, "extraLinks": "Extra Links", "grid": { "buttons": { @@ -93,6 +123,16 @@ "assetEvent_one": "Created Asset Event", "assetEvent_other": "Created Asset Events" }, + "deadlines": { + "completionRule": "Complete within {{interval}} of {{reference}}", + "finishedEarly": "Finished {{duration}} before deadline", + "finishedLate": "Finished {{duration}} after deadline", + "pending": "Pending Deadlines", + "recentlyMissed": "Recently Missed Deadlines", + "stillRunning": "Still running past deadline", + "title": "Deadlines", + "viewAll": "View all {{count}}" + }, "failedLogs": { "hideLogs": "Hide Logs", "showLogs": "Show Logs", diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.tsx new file mode 100644 index 0000000000000..6b9ade3ace045 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.tsx @@ -0,0 +1,90 @@ +/*! + * 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, Button, Separator, Text, VStack } from "@chakra-ui/react"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { useTranslation } from "react-i18next"; +import { FiClock } from "react-icons/fi"; + +import { useDeadlinesServiceGetDagDeadlineAlerts } from "openapi/queries"; +import type { DeadlineAlertResponse } from "openapi/requests/types.gen"; +import { Popover } from "src/components/ui"; + +dayjs.extend(duration); +dayjs.extend(relativeTime); + +const AlertRow = ({ alert }: { readonly alert: DeadlineAlertResponse }) => { + const { t: translate } = useTranslation("dag"); + const reference = translate(`deadlineAlerts.referenceType.${alert.reference_type}`, { + defaultValue: alert.reference_type, + }); + const interval = dayjs.duration(alert.interval, "seconds").humanize(); + + return ( + + + {translate("deadlineAlerts.completionRule", { interval, reference })} + {Boolean(alert.name) && ( + + {" "} + ({alert.name}) + + )} + + + ); +}; + +export const DeadlineAlertsBadge = ({ dagId }: { readonly dagId: string }) => { + const { t: translate } = useTranslation("dag"); + + const { data } = useDeadlinesServiceGetDagDeadlineAlerts({ dagId }); + + const alerts = data?.deadline_alerts ?? []; + + if (alerts.length === 0) { + return undefined; + } + + return ( + // eslint-disable-next-line jsx-a11y/no-autofocus + + + + + + + + + {translate("deadlineAlerts.title")} + + }> + {alerts.map((alert) => ( + + ))} + + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx index 6af9129a31641..05c436dc9478f 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx @@ -35,6 +35,7 @@ import { TogglePause } from "src/components/TogglePause"; import { DagOwners } from "../DagsList/DagOwners"; import { DagTags } from "../DagsList/DagTags"; import { Schedule } from "../DagsList/Schedule"; +import { DeadlineAlertsBadge } from "./DeadlineAlertsBadge"; type LatestRunInfo = { dag_id: string; @@ -123,6 +124,7 @@ export const Header = ({ actions={ dag === undefined ? undefined : ( <> + {dag.doc_md === null ? undefined : ( ; + readonly dagId: string; + readonly endDate: string; + readonly missed: boolean; + readonly onClose: () => void; + readonly open: boolean; + readonly refetchInterval: number | false; + readonly startDate: string; + readonly title: string; +}; + +export const AllDeadlinesModal = ({ + alertMap, + dagId, + endDate, + missed, + onClose, + open, + refetchInterval, + startDate, + title, +}: AllDeadlinesModalProps) => { + const [page, setPage] = useState(1); + const offset = (page - 1) * PAGE_LIMIT; + + const { data, error, isLoading } = useDeadlinesServiceGetDeadlines( + missed + ? { + dagId, + dagRunId: "~", + lastUpdatedAtGte: startDate, + lastUpdatedAtLte: endDate, + limit: PAGE_LIMIT, + missed: true, + offset, + orderBy: ["-last_updated_at"], + } + : { + dagId, + dagRunId: "~", + deadlineTimeGte: endDate, + limit: PAGE_LIMIT, + missed: false, + offset, + orderBy: ["deadline_time"], + }, + undefined, + { enabled: open, refetchInterval }, + ); + + const { data: runsData } = useDagRunServiceGetDagRuns( + { dagId, limit: 100, runAfterGte: startDate, runAfterLte: endDate }, + undefined, + { enabled: open, refetchInterval }, + ); + + const runStateMap = new Map(); + const runMap = new Map(); + + for (const run of runsData?.dag_runs ?? []) { + runStateMap.set(run.dag_run_id, run.state); + runMap.set(run.dag_run_id, run); + } + + const deadlines = data?.deadlines ?? []; + const totalEntries = data?.total_entries ?? 0; + + const getAlert = (alertId?: string | null) => + alertId !== undefined && alertId !== null ? alertMap.get(alertId) : undefined; + + const onOpenChange = () => { + setPage(1); + onClose(); + }; + + return ( + + + + {title} + + + + + {isLoading ? ( + + {Array.from({ length: PAGE_LIMIT }).map((_, idx) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + ) : ( + }> + {deadlines.map((dl) => ( + + ))} + + )} + + {totalEntries > PAGE_LIMIT ? ( + setPage(event.page)} + p={3} + page={page} + pageSize={PAGE_LIMIT} + > + + + + + + + ) : undefined} + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DagDeadlines.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DagDeadlines.tsx new file mode 100644 index 0000000000000..ccd9bb60c66d1 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DagDeadlines.tsx @@ -0,0 +1,247 @@ +/*! + * 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 { Badge, Box, Button, Flex, Heading, HStack, Separator, Skeleton, VStack } from "@chakra-ui/react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FiAlertTriangle, FiClock } from "react-icons/fi"; + +import { + useDagRunServiceGetDagRuns, + useDeadlinesServiceGetDagDeadlineAlerts, + useDeadlinesServiceGetDeadlines, +} from "openapi/queries"; +import type { DAGRunResponse, DagRunState, DeadlineAlertResponse } from "openapi/requests/types.gen"; +import { ErrorAlert } from "src/components/ErrorAlert"; +import { useAutoRefresh } from "src/utils"; + +import { AllDeadlinesModal } from "./AllDeadlinesModal"; +import { DeadlineRow } from "./DeadlineRow"; + +const LIMIT = 5; + +type DagDeadlinesProps = { + readonly dagId: string; + readonly endDate: string; + readonly startDate: string; +}; + +export const DagDeadlines = ({ dagId, endDate, startDate }: DagDeadlinesProps) => { + const { t: translate } = useTranslation("dag"); + const refetchInterval = useAutoRefresh({ dagId }); + const [modalOpen, setModalOpen] = useState<"missed" | "pending" | undefined>(undefined); + + const { + data: pendingData, + error: pendingError, + isLoading: isPendingLoading, + } = useDeadlinesServiceGetDeadlines( + { + dagId, + dagRunId: "~", + deadlineTimeGte: endDate, + limit: LIMIT, + missed: false, + orderBy: ["deadline_time"], + }, + undefined, + { refetchInterval }, + ); + + const { + data: missedData, + error: missedError, + isLoading: isMissedLoading, + } = useDeadlinesServiceGetDeadlines( + { + dagId, + dagRunId: "~", + lastUpdatedAtGte: startDate, + lastUpdatedAtLte: endDate, + limit: LIMIT, + missed: true, + orderBy: ["-last_updated_at"], + }, + undefined, + { refetchInterval }, + ); + + const { data: runsData } = useDagRunServiceGetDagRuns( + { dagId, limit: 100, runAfterGte: startDate, runAfterLte: endDate }, + undefined, + { refetchInterval }, + ); + + const { data: alertData } = useDeadlinesServiceGetDagDeadlineAlerts({ dagId, limit: 100 }, undefined, { + refetchInterval, + }); + + const runStateMap = new Map(); + const runMap = new Map(); + + for (const run of runsData?.dag_runs ?? []) { + runStateMap.set(run.dag_run_id, run.state); + runMap.set(run.dag_run_id, run); + } + + const alertMap = new Map(); + + for (const alert of alertData?.deadline_alerts ?? []) { + alertMap.set(alert.id, alert); + } + + const pendingDeadlines = pendingData?.deadlines ?? []; + const missedDeadlines = missedData?.deadlines ?? []; + + if ( + !isPendingLoading && + !isMissedLoading && + pendingError === null && + missedError === null && + pendingDeadlines.length === 0 && + missedDeadlines.length === 0 + ) { + return undefined; + } + + const getAlert = (alertId?: string | null) => + alertId !== undefined && alertId !== null ? alertMap.get(alertId) : undefined; + + return ( + + + + + {translate("overview.deadlines.title")} + + + + + {isPendingLoading || pendingDeadlines.length > 0 ? ( + + + + {translate("overview.deadlines.pending")} + {pendingData ? ( + + {pendingData.total_entries} + + ) : undefined} + + {isPendingLoading ? ( + + {Array.from({ length: 3 }).map((_, idx) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + ) : ( + }> + {pendingDeadlines.map((dl) => ( + + ))} + {(pendingData?.total_entries ?? 0) > LIMIT ? ( + + ) : undefined} + + )} + + ) : undefined} + + {isMissedLoading || missedDeadlines.length > 0 ? ( + + + + {translate("overview.deadlines.recentlyMissed")} + {missedData ? ( + + {missedData.total_entries} + + ) : undefined} + + {isMissedLoading ? ( + + {Array.from({ length: 3 }).map((_, idx) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + ) : ( + }> + {missedDeadlines.map((dl) => ( + + ))} + {(missedData?.total_entries ?? 0) > LIMIT ? ( + + ) : undefined} + + )} + + ) : undefined} + + + setModalOpen(undefined)} + open={modalOpen !== undefined} + refetchInterval={refetchInterval} + startDate={startDate} + title={ + modalOpen === "missed" + ? translate("overview.deadlines.recentlyMissed") + : translate("overview.deadlines.pending") + } + /> + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DeadlineRow.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DeadlineRow.tsx new file mode 100644 index 0000000000000..52913ff81af2e --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DeadlineRow.tsx @@ -0,0 +1,102 @@ +/*! + * 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 { HStack, Link, Text, VStack } from "@chakra-ui/react"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { useTranslation } from "react-i18next"; +import { Link as RouterLink } from "react-router-dom"; + +import type { + DAGRunResponse, + DeadlineAlertResponse, + DeadlineResponse, + DagRunState, +} from "openapi/requests/types.gen"; +import Time from "src/components/Time"; + +dayjs.extend(duration); +dayjs.extend(relativeTime); + +type DeadlineRowProps = { + readonly alert?: DeadlineAlertResponse; + readonly deadline: DeadlineResponse; + readonly run?: DAGRunResponse; + readonly runState?: DagRunState; +}; + +export const DeadlineRow = ({ alert, deadline, run, runState }: DeadlineRowProps) => { + const { t: translate } = useTranslation("dag"); + + const reference = alert + ? translate(`deadlineAlerts.referenceType.${alert.reference_type}`, { + defaultValue: alert.reference_type, + }) + : undefined; + const interval = alert ? dayjs.duration(alert.interval, "seconds").humanize() : undefined; + + let contextLine: string | undefined; + + if (deadline.missed) { + if (run?.end_date === undefined || run.end_date === null) { + contextLine = translate("overview.deadlines.stillRunning"); + } else { + const diff = dayjs(run.end_date).diff(dayjs(deadline.deadline_time)); + + contextLine = + diff >= 0 + ? translate("overview.deadlines.finishedLate", { + duration: dayjs.duration(diff).humanize(), + }) + : translate("overview.deadlines.finishedEarly", { + duration: dayjs.duration(-diff).humanize(), + }); + } + } + + return ( + + + + + + {deadline.dag_run_id} + + + {deadline.missed && runState !== undefined ? ( + + ({runState}) + + ) : undefined} + + {reference !== undefined && interval !== undefined ? ( + + {translate("overview.deadlines.completionRule", { interval, reference })} + + ) : undefined} + {contextLine !== undefined && ( + + {contextLine} + + )} + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx index 7ea155fe84761..7627695f2c45f 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx @@ -38,6 +38,8 @@ import { SearchParamsKeys } from "src/constants/searchParams"; import { useGridRuns } from "src/queries/useGridRuns.ts"; import { isStatePending, useAutoRefresh } from "src/utils"; +import { DagDeadlines } from "./DagDeadlines"; + const FailedLogs = lazy(() => import("./FailedLogs")); const defaultHour = "24"; @@ -146,6 +148,9 @@ export const Overview = () => { /> ) : undefined} + {dagId === undefined ? undefined : ( + + )} }> diff --git a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx new file mode 100644 index 0000000000000..fba45cfb72b4c --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx @@ -0,0 +1,183 @@ +/*! + * 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 { Badge, Button, HStack, Text, VStack } from "@chakra-ui/react"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FiAlertTriangle, FiCheck, FiClock } from "react-icons/fi"; + +import { useDeadlinesServiceGetDagDeadlineAlerts, useDeadlinesServiceGetDeadlines } from "openapi/queries"; +import type { DeadlineAlertResponse } from "openapi/requests/types.gen"; +import Time from "src/components/Time"; + +import { DeadlineStatusModal } from "./DeadlineStatusModal"; + +dayjs.extend(duration); +dayjs.extend(relativeTime); + +type DeadlineStatusProps = { + readonly dagId: string; + readonly dagRunId: string; + readonly endDate: string | null; +}; + +export const DeadlineStatus = ({ dagId, dagRunId, endDate }: DeadlineStatusProps) => { + const { t: translate } = useTranslation("dag"); + const [isModalOpen, setIsModalOpen] = useState(false); + + const { data: deadlineData, isLoading: isLoadingDeadlines } = useDeadlinesServiceGetDeadlines({ + dagId, + dagRunId, + limit: 10, + orderBy: ["deadline_time"], + }); + + // Used to detect whether the DAG has any deadline alerts at all, so we can show "Met" when there are alerts configured but no active deadline instances. + const { data: alertData, isLoading: isLoadingAlerts } = useDeadlinesServiceGetDagDeadlineAlerts({ + dagId, + limit: 100, + }); + + const alertMap = new Map(); + + for (const alert of alertData?.deadline_alerts ?? []) { + alertMap.set(alert.id, alert); + } + + if (isLoadingDeadlines || isLoadingAlerts) { + return undefined; + } + + // Active instances for this run; hasAlerts = DAG has deadline alerts configured. + const deadlines = deadlineData?.deadlines ?? []; + const hasAlerts = (alertData?.total_entries ?? 0) > 0; + + // No deadline alerts configured on the DAG at all + if (deadlines.length === 0 && !hasAlerts) { + return undefined; + } + + // Alerts are configured but no active deadline instances exist therefore all deadlines were met. + if (deadlines.length === 0 && hasAlerts) { + return ( + + + + {translate("deadlineStatus.met")} + + + ); + } + + const totalEntries = deadlineData?.total_entries ?? 0; + const extraCount = totalEntries - deadlines.length; + const runEndDate = endDate ?? undefined; + + return ( + <> + + {deadlines.map((dl) => { + const alert = + dl.alert_id !== undefined && dl.alert_id !== null ? alertMap.get(dl.alert_id) : undefined; + const deadlineTime = dayjs(dl.deadline_time); + let contextLine: string | undefined; + + if (dl.missed) { + if (runEndDate === undefined) { + contextLine = translate("deadlineStatus.stillRunning"); + } else { + const diff = dayjs(runEndDate).diff(deadlineTime); + + contextLine = + diff >= 0 + ? translate("deadlineStatus.finishedLate", { + duration: dayjs.duration(diff).humanize(), + }) + : translate("deadlineStatus.finishedEarly", { + duration: dayjs.duration(-diff).humanize(), + }); + } + } else { + const remaining = deadlineTime.diff(dayjs()); + + if (remaining > 0) { + contextLine = translate("deadlineStatus.deadlineIn", { + duration: dayjs.duration(remaining).humanize(), + }); + } + } + + return ( + + + {dl.missed ? ( + + + {translate("deadlineStatus.missed")} + + ) : ( + + + {translate("deadlineStatus.upcoming")} + + )} + + {alert === undefined ? undefined : ( + + {translate("deadlineStatus.completionRule", { + interval: dayjs.duration(alert.interval, "seconds").humanize(), + reference: translate(`deadlineAlerts.referenceType.${alert.reference_type}`, { + defaultValue: alert.reference_type, + }), + })} + + )} + {contextLine === undefined ? undefined : ( + + {contextLine} + + )} + + ); + })} + {extraCount > 0 ? ( + + ) : undefined} + + setIsModalOpen(false)} + open={isModalOpen} + runEndDate={runEndDate} + /> + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx new file mode 100644 index 0000000000000..ff72920c9e791 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx @@ -0,0 +1,188 @@ +/*! + * 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 { Badge, Heading, HStack, Separator, Skeleton, Text, VStack } from "@chakra-ui/react"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FiAlertTriangle, FiClock } from "react-icons/fi"; + +import { useDeadlinesServiceGetDeadlines } from "openapi/queries"; +import type { DeadlineAlertResponse } from "openapi/requests/types.gen"; +import { ErrorAlert } from "src/components/ErrorAlert"; +import Time from "src/components/Time"; +import { Dialog } from "src/components/ui"; +import { Pagination } from "src/components/ui/Pagination"; + +dayjs.extend(duration); +dayjs.extend(relativeTime); + +const PAGE_LIMIT = 10; + +type DeadlineStatusModalProps = { + readonly alertMap: Map; + readonly dagId: string; + readonly dagRunId: string; + readonly onClose: () => void; + readonly open: boolean; + readonly runEndDate: string | undefined; +}; + +export const DeadlineStatusModal = ({ + alertMap, + dagId, + dagRunId, + onClose, + open, + runEndDate, +}: DeadlineStatusModalProps) => { + const { t: translate } = useTranslation("dag"); + const [page, setPage] = useState(1); + const offset = (page - 1) * PAGE_LIMIT; + + const { data, error, isLoading } = useDeadlinesServiceGetDeadlines( + { + dagId, + dagRunId, + limit: PAGE_LIMIT, + offset, + orderBy: ["deadline_time"], + }, + undefined, + { enabled: open }, + ); + + const deadlines = data?.deadlines ?? []; + const totalEntries = data?.total_entries ?? 0; + + const onOpenChange = () => { + setPage(1); + onClose(); + }; + + return ( + + + + {translate("deadlineStatus.label")} + + + + + {isLoading ? ( + + {Array.from({ length: PAGE_LIMIT }).map((_, idx) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + ) : ( + }> + {deadlines.map((dl) => { + const alert = + dl.alert_id !== undefined && dl.alert_id !== null ? alertMap.get(dl.alert_id) : undefined; + const deadlineTime = dayjs(dl.deadline_time); + let contextLine: string | undefined; + + if (dl.missed) { + if (runEndDate === undefined) { + contextLine = translate("deadlineStatus.stillRunning"); + } else { + const diff = dayjs(runEndDate).diff(deadlineTime); + + contextLine = + diff >= 0 + ? translate("deadlineStatus.finishedLate", { + duration: dayjs.duration(diff).humanize(), + }) + : translate("deadlineStatus.finishedEarly", { + duration: dayjs.duration(-diff).humanize(), + }); + } + } else { + const remaining = deadlineTime.diff(dayjs()); + + if (remaining > 0) { + contextLine = translate("deadlineStatus.deadlineIn", { + duration: dayjs.duration(remaining).humanize(), + }); + } + } + + return ( + + + + + {dl.missed ? : } + {dl.missed + ? translate("deadlineStatus.missed") + : translate("deadlineStatus.upcoming")} + + {dl.alert_name === undefined || + dl.alert_name === null || + dl.alert_name === "" ? undefined : ( + + {dl.alert_name} + + )} + + + {alert === undefined ? undefined : ( + + {translate("deadlineStatus.completionRule", { + interval: dayjs.duration(alert.interval, "seconds").humanize(), + reference: translate(`deadlineAlerts.referenceType.${alert.reference_type}`, { + defaultValue: alert.reference_type, + }), + })} + + )} + {contextLine === undefined ? undefined : ( + + {contextLine} + + )} + + ); + })} + + )} + + {totalEntries > PAGE_LIMIT ? ( + setPage(event.page)} + p={3} + page={page} + pageSize={PAGE_LIMIT} + > + + + + + + + ) : undefined} + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx index a5a0c4a26c2fa..6b29ba5187330 100644 --- a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx @@ -22,6 +22,7 @@ import { useTranslation } from "react-i18next"; import { FiBarChart } from "react-icons/fi"; import { Link as RouterLink } from "react-router-dom"; +import { useDeadlinesServiceGetDagDeadlineAlerts } from "openapi/queries"; import type { DAGRunResponse } from "openapi/requests/types.gen"; import { ClearRunButton } from "src/components/Clear"; import { DagVersion } from "src/components/DagVersion"; @@ -36,6 +37,8 @@ import DeleteRunButton from "src/pages/DeleteRunButton"; import { usePatchDagRun } from "src/queries/usePatchDagRun"; import { getDuration } from "src/utils"; +import { DeadlineStatus } from "./DeadlineStatus"; + export const Header = ({ dagRun }: { readonly dagRun: DAGRunResponse }) => { const { t: translate } = useTranslation(); const [note, setNote] = useState(dagRun.note); @@ -43,6 +46,9 @@ export const Header = ({ dagRun }: { readonly dagRun: DAGRunResponse }) => { const dagId = dagRun.dag_id; const dagRunId = dagRun.dag_run_id; + const { data: alertData } = useDeadlinesServiceGetDagDeadlineAlerts({ dagId }); + const hasDeadlineAlerts = (alertData?.total_entries ?? 0) > 0; + const { isPending, mutate } = usePatchDagRun({ dagId, dagRunId, @@ -139,6 +145,14 @@ export const Header = ({ dagRun }: { readonly dagRun: DAGRunResponse }) => { /> ), }, + ...(hasDeadlineAlerts + ? [ + { + label: translate("dag:deadlineStatus.label"), + value: , + }, + ] + : []), ]} title={dagRun.dag_run_id} />