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")}
+
+ )}
+
+ {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}
+
+ )}
+
+ );
+ })}
+ {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}
/>