diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts index f435ab903023e..d25f997798181 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts @@ -1872,7 +1872,7 @@ export const ensureUseDeadlinesServiceGetDeadlinesData = (queryClient: QueryClie * @param data.dagId * @param data.limit * @param data.offset -* @param data.orderBy Attributes to order by, multi criteria sort is supported. Prefix with `-` for descending order. Supported attributes: `id, created_at, name, interval` +* @param data.orderBy Attributes to order by, multi criteria sort is supported. Prefix with `-` for descending order. Supported attributes: `id, created_at, name` * @returns DeadlineAlertCollectionResponse Successful Response * @throws ApiError */ diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts index a9e1144261ad4..a15d7bfd4edd7 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts @@ -1872,7 +1872,7 @@ export const prefetchUseDeadlinesServiceGetDeadlines = (queryClient: QueryClient * @param data.dagId * @param data.limit * @param data.offset -* @param data.orderBy Attributes to order by, multi criteria sort is supported. Prefix with `-` for descending order. Supported attributes: `id, created_at, name, interval` +* @param data.orderBy Attributes to order by, multi criteria sort is supported. Prefix with `-` for descending order. Supported attributes: `id, created_at, name` * @returns DeadlineAlertCollectionResponse Successful Response * @throws ApiError */ diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts index 57a9970cdb72a..1eb20ba00fae0 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts @@ -1872,7 +1872,7 @@ export const useDeadlinesServiceGetDeadlines = ; }; 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 907fba9e5eac4..f203c21595f5d 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 @@ -42,6 +42,7 @@ }, "deadlineAlerts": { "completionRule": "Must complete within {{interval}} of {{reference}}", + "completionRuleDynamic": "Must complete within a variable interval measured from {{reference}}", "count_one": "{{count}} deadline", "count_other": "{{count}} deadlines", "referenceType": { diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx new file mode 100644 index 0000000000000..cd1885f3f1ad4 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx @@ -0,0 +1,89 @@ +/*! + * 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 "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type * as OpenapiQueries from "openapi/queries"; +import type { DeadlineAlertResponse } from "openapi/requests/types.gen"; +import { Wrapper } from "src/utils/Wrapper"; + +import { DeadlineAlertsBadge } from "./DeadlineAlertsBadge"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + // eslint-disable-next-line id-length + t: (key: string, options?: { interval?: string; reference?: string }) => { + if (options?.reference === undefined) { + return key; + } + + return options.interval === undefined + ? `${key}:${options.reference}` + : `${key}:${options.interval}:${options.reference}`; + }, + }), +})); + +vi.mock("openapi/queries", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useDeadlinesServiceGetDagDeadlineAlerts: vi.fn(), + }; +}); + +const { useDeadlinesServiceGetDagDeadlineAlerts } = await import("openapi/queries"); + +// Defaults to a VariableInterval alert, whose interval only the scheduler resolves at evaluation +// time. Without a rule of its own, dayjs humanizes that null interval as "a few seconds" and the +// popover claims the run must complete within a few seconds of its logical date. +const baseAlert: DeadlineAlertResponse = { + created_at: "2025-01-01T00:00:00Z", + id: "alert-1", + interval: null, + name: null, + reference_type: "DagRunLogicalDateDeadline", +}; + +const REFERENCE = "deadlineAlerts.referenceType.DagRunLogicalDateDeadline"; +const DYNAMIC_RULE = `deadlineAlerts.completionRuleDynamic:${REFERENCE}`; +const FIXED_RULE = `deadlineAlerts.completionRule:an hour:${REFERENCE}`; + +describe("DeadlineAlertsBadge", () => { + it.each([ + { absent: FIXED_RULE, expected: DYNAMIC_RULE, interval: null }, + { absent: DYNAMIC_RULE, expected: FIXED_RULE, interval: 3600 }, + ])( + "states the completion rule for an alert with interval $interval", + async ({ absent, expected, interval }) => { + vi.mocked(useDeadlinesServiceGetDagDeadlineAlerts).mockReturnValue({ + data: { deadline_alerts: [{ ...baseAlert, interval }], total_entries: 1 }, + } as ReturnType); + + render(, { wrapper: Wrapper }); + + fireEvent.click(screen.getByRole("button")); + + expect(await screen.findByText(expected)).toBeInTheDocument(); + expect(screen.queryByText(absent)).not.toBeInTheDocument(); + }, + ); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.tsx index 5ee03b8aff855..549ee76a14cb0 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.tsx @@ -17,30 +17,21 @@ * 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); +import { translateCompletionRule } from "src/utils/deadlines"; 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 })} + {translateCompletionRule(translate, alert)} {Boolean(alert.name) && ( {" "} 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 index 3e47861c90d82..dff4053b3c1cf 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DeadlineRow.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DeadlineRow.tsx @@ -17,18 +17,13 @@ * under the License. */ import { Badge, HStack, 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 { FiAlertTriangle, FiClock } from "react-icons/fi"; import type { DeadlineAlertResponse, DeadlineResponse } from "openapi/requests/types.gen"; import Time from "src/components/Time"; import { RouterLink } from "src/components/ui"; - -dayjs.extend(duration); -dayjs.extend(relativeTime); +import { translateCompletionRule } from "src/utils/deadlines"; type DeadlineRowProps = { readonly alert?: DeadlineAlertResponse; @@ -38,12 +33,7 @@ type DeadlineRowProps = { export const DeadlineRow = ({ alert, deadline }: 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; + const completionRule = translateCompletionRule(translate, alert); return ( @@ -61,11 +51,11 @@ export const DeadlineRow = ({ alert, deadline }: DeadlineRowProps) => { {deadline.dag_run_id} - {reference !== undefined && interval !== undefined ? ( + {completionRule === undefined ? undefined : ( - {translate("deadlineAlerts.completionRule", { interval, reference })} + {completionRule} - ) : 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 index eff38d4a78e71..0a38e2d99aad0 100644 --- a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx @@ -18,8 +18,6 @@ */ 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"; @@ -29,12 +27,10 @@ import type { DeadlineAlertResponse } from "openapi/requests/types.gen"; import Time from "src/components/Time"; import { Tooltip } from "src/components/ui/Tooltip"; import { renderDuration } from "src/utils/datetimeUtils"; +import { translateCompletionRule } from "src/utils/deadlines"; import { DeadlineStatusModal } from "./DeadlineStatusModal"; -dayjs.extend(duration); -dayjs.extend(relativeTime); - type DeadlineStatusProps = { readonly dagId: string; readonly dagRunId: string; @@ -84,12 +80,7 @@ export const DeadlineStatus = ({ dagId, dagRunId, endDate }: DeadlineStatusProps {(alertData?.deadline_alerts ?? []).map((deadlineAlert) => ( - {translate("deadlineAlerts.completionRule", { - interval: dayjs.duration(deadlineAlert.interval, "seconds").humanize(), - reference: translate(`deadlineAlerts.referenceType.${deadlineAlert.reference_type}`, { - defaultValue: deadlineAlert.reference_type, - }), - })} + {translateCompletionRule(translate, deadlineAlert)} ))} @@ -156,6 +147,7 @@ export const DeadlineStatus = ({ dagId, dagRunId, endDate }: DeadlineStatusProps } const alert = dl.alert_id !== undefined && dl.alert_id !== null ? alertMap.get(dl.alert_id) : undefined; + const completionRule = translateCompletionRule(translate, alert); const deadlineTime = dayjs(dl.deadline_time); let actualDurationLabel: string | undefined; @@ -185,14 +177,9 @@ export const DeadlineStatus = ({ dagId, dagRunId, endDate }: DeadlineStatusProps )} - {alert === undefined ? undefined : ( + {completionRule === undefined ? undefined : ( - {translate("deadlineAlerts.completionRule", { - interval: dayjs.duration(alert.interval, "seconds").humanize(), - reference: translate(`deadlineAlerts.referenceType.${alert.reference_type}`, { - defaultValue: alert.reference_type, - }), - })} + {completionRule} )} diff --git a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx index 968f1210d9e82..c5afe117eb4d5 100644 --- a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx @@ -18,8 +18,6 @@ */ 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"; @@ -31,9 +29,7 @@ import Time from "src/components/Time"; import { Dialog } from "src/components/ui"; import { Pagination } from "src/components/ui/Pagination"; import { renderDuration } from "src/utils/datetimeUtils"; - -dayjs.extend(duration); -dayjs.extend(relativeTime); +import { translateCompletionRule } from "src/utils/deadlines"; const PAGE_LIMIT = 10; @@ -99,6 +95,7 @@ export const DeadlineStatusModal = ({ {deadlines.map((dl) => { const alert = dl.alert_id !== undefined && dl.alert_id !== null ? alertMap.get(dl.alert_id) : undefined; + const completionRule = translateCompletionRule(translate, alert); const deadlineTime = dayjs(dl.deadline_time); let actualDurationLabel: string | undefined; @@ -128,14 +125,9 @@ export const DeadlineStatusModal = ({ )} - {alert === undefined ? undefined : ( + {completionRule === undefined ? undefined : ( - {translate("deadlineAlerts.completionRule", { - interval: dayjs.duration(alert.interval, "seconds").humanize(), - reference: translate(`deadlineAlerts.referenceType.${alert.reference_type}`, { - defaultValue: alert.reference_type, - }), - })} + {completionRule} )} diff --git a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.test.ts b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.test.ts index 2919167a33fc9..c5c766af95a78 100644 --- a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.test.ts +++ b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.test.ts @@ -23,6 +23,7 @@ import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; import { getDuration, getDurationTickStep, + humanizeSeconds, renderCompactDuration, renderDuration, getRelativeTime, @@ -170,3 +171,19 @@ describe("getDurationTickStep", () => { expect(getDurationTickStep(10_000_000)).toBe(1_250_000); }); }); + +describe("humanizeSeconds", () => { + it.each([ + [3600, "an hour"], + [86_400, "a day"], + ])("humanizes %s seconds as %s", (seconds, expected) => { + expect(humanizeSeconds(seconds)).toBe(expected); + }); + + it.each([[null], [undefined], [Number.NaN], [Number.POSITIVE_INFINITY]])( + "returns undefined without a finite interval (%s)", + (seconds) => { + expect(humanizeSeconds(seconds)).toBeUndefined(); + }, + ); +}); diff --git a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts index 3a73c29459f3c..20e129d06458f 100644 --- a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts +++ b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts @@ -55,6 +55,13 @@ export const renderDuration = ( return duration.asSeconds() < 86_400 ? duration.format("HH:mm:ss") : duration.format("D[d]HH:mm:ss"); }; +// dayjs humanizes a missing or non-finite input as "a few seconds", so callers with no duration +// to name get undefined instead of a made-up one. +export const humanizeSeconds = (seconds: number | null | undefined): string | undefined => + typeof seconds === "number" && Number.isFinite(seconds) + ? dayjs.duration(seconds, "seconds").humanize() + : undefined; + // Chart axes need whole units at a glance; HH:mm:ss forces the reader to decode // every tick to work out the magnitude. export const renderCompactDuration = (durationSeconds: number): string => { diff --git a/airflow-core/src/airflow/ui/src/utils/deadlines.test.ts b/airflow-core/src/airflow/ui/src/utils/deadlines.test.ts new file mode 100644 index 0000000000000..fdd65885bb3c9 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/utils/deadlines.test.ts @@ -0,0 +1,52 @@ +/*! + * 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 { TFunction } from "i18next"; +import { describe, expect, it } from "vitest"; + +import type { DeadlineAlertResponse } from "openapi/requests/types.gen"; + +import { translateCompletionRule } from "./deadlines"; + +const translate = ((key: string, options?: { interval?: string; reference?: string }) => + options?.reference === undefined + ? key + : `${key}:${options.interval ?? ""}:${options.reference}`) as unknown as TFunction; + +const baseAlert: DeadlineAlertResponse = { + created_at: "2025-01-01T00:00:00Z", + id: "alert-1", + interval: null, + name: null, + reference_type: "DagRunLogicalDateDeadline", +}; + +const REFERENCE = "deadlineAlerts.referenceType.DagRunLogicalDateDeadline"; + +describe("translateCompletionRule", () => { + it.each([ + [3600, `deadlineAlerts.completionRule:an hour:${REFERENCE}`], + [null, `deadlineAlerts.completionRuleDynamic::${REFERENCE}`], + ])("names the rule for an interval of %s seconds", (interval, expected) => { + expect(translateCompletionRule(translate, { ...baseAlert, interval })).toBe(expected); + }); + + it("has no rule to name without an alert", () => { + expect(translateCompletionRule(translate, undefined)).toBeUndefined(); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/utils/deadlines.ts b/airflow-core/src/airflow/ui/src/utils/deadlines.ts new file mode 100644 index 0000000000000..96c86065d7cfa --- /dev/null +++ b/airflow-core/src/airflow/ui/src/utils/deadlines.ts @@ -0,0 +1,44 @@ +/*! + * 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 { TFunction } from "i18next"; + +import type { DeadlineAlertResponse } from "openapi/requests/types.gen"; +import { humanizeSeconds } from "src/utils/datetimeUtils"; + +// The API sends a null interval when the alert has no fixed number of seconds — a variable +// interval the scheduler only resolves per run, or a stored value it could not read as a +// duration. Naming a length there would be a lie, so the rule only names the reference point. +// `translate` must be bound to the "dag" namespace. +export const translateCompletionRule = ( + translate: TFunction, + alert: DeadlineAlertResponse | undefined, +): string | undefined => { + if (alert === undefined) { + return undefined; + } + + const reference = translate(`deadlineAlerts.referenceType.${alert.reference_type}`, { + defaultValue: alert.reference_type, + }); + const interval = humanizeSeconds(alert.interval); + + return interval === undefined + ? translate("deadlineAlerts.completionRuleDynamic", { reference }) + : translate("deadlineAlerts.completionRule", { interval, reference }); +};