From 8bfab471137abe82eeceaf01e4f081925445ed88 Mon Sep 17 00:00:00 2001 From: Shahar Epstein <60007259+shahar1@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:30:25 +0300 Subject: [PATCH] Regenerate UI client and fix deadline alerts with no fixed interval The deadline-alert response now reports a null interval when the alert has no fixed number of seconds, but the committed TypeScript client still described it as a required number. That stale client hid a real type error in every component rendering the completion rule, so `ts-compile-lint-ui` fails on a clean checkout and blocks unrelated PRs. The null is wrong on screen today as well: dayjs humanizes it as "a few seconds", so such an alert claims the run must complete within a few seconds of its reference point. There is no duration to name there, so the rule now names the reference point alone. English is the source and fallback locale, so the wording is added there alone and the other locales fall back to it until translated. --- .../ui/openapi-gen/queries/ensureQueryData.ts | 2 +- .../ui/openapi-gen/queries/prefetch.ts | 2 +- .../airflow/ui/openapi-gen/queries/queries.ts | 2 +- .../ui/openapi-gen/queries/suspense.ts | 2 +- .../ui/openapi-gen/requests/schemas.gen.ts | 13 ++- .../ui/openapi-gen/requests/services.gen.ts | 2 +- .../ui/openapi-gen/requests/types.gen.ts | 6 +- .../ui/public/i18n/locales/en/dag.json | 1 + .../pages/Dag/DeadlineAlertsBadge.test.tsx | 89 +++++++++++++++++++ .../ui/src/pages/Dag/DeadlineAlertsBadge.tsx | 13 +-- .../ui/src/pages/Dag/Overview/DeadlineRow.tsx | 20 ++--- .../ui/src/pages/Run/DeadlineStatus.tsx | 23 ++--- .../ui/src/pages/Run/DeadlineStatusModal.tsx | 16 +--- .../ui/src/utils/datetimeUtils.test.ts | 17 ++++ .../src/airflow/ui/src/utils/datetimeUtils.ts | 7 ++ .../airflow/ui/src/utils/deadlines.test.ts | 52 +++++++++++ .../src/airflow/ui/src/utils/deadlines.ts | 44 +++++++++ 17 files changed, 244 insertions(+), 67 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx create mode 100644 airflow-core/src/airflow/ui/src/utils/deadlines.test.ts create mode 100644 airflow-core/src/airflow/ui/src/utils/deadlines.ts 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 }); +};