This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-2-test by this push:
new 990ad8e9e1c [v3-2-test] UI: Change queued Dag runs color to grey in
Calendar (#66623) (#66870)
990ad8e9e1c is described below
commit 990ad8e9e1ccdecc227a020b66b222cff9ee9baa
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu May 14 10:25:46 2026 -0400
[v3-2-test] UI: Change queued Dag runs color to grey in Calendar (#66623)
(#66870)
* UI: Add tests for Calendar run color utilities
* UI: Add queued run color tests for Calendar
* UI: Show queued Calendar runs as planned
* UI: Refactor Calendar run count helpers
(cherry picked from commit 4f9174e554f95d517a4ded340336ab56f92227df)
Co-authored-by: hojeong park <[email protected]>
---
.../src/pages/Dag/Calendar/calendarUtils.test.ts | 186 +++++++++++++++++++++
.../ui/src/pages/Dag/Calendar/calendarUtils.ts | 37 +++-
2 files changed, 214 insertions(+), 9 deletions(-)
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.test.ts
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.test.ts
new file mode 100644
index 00000000000..1dec1580283
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.test.ts
@@ -0,0 +1,186 @@
+/*!
+ * 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 { describe, expect, it } from "vitest";
+
+import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen";
+
+import { calculateDataBounds, calculateRunCounts, createCalendarScale } from
"./calendarUtils";
+import type { RunCounts } from "./types";
+
+const EMPTY_COLOR = { _dark: "gray.700", _light: "gray.100" };
+const PLANNED_COLOR = { _dark: "stone.600", _light: "stone.500" };
+const DEFAULT_TOTAL_COLOR = { _dark: "green.700", _light: "green.400" };
+const DEFAULT_FAILED_COLOR = { _dark: "red.700", _light: "red.400" };
+
+const EMPTY_COUNTS: RunCounts = {
+ failed: 0,
+ planned: 0,
+ queued: 0,
+ running: 0,
+ success: 0,
+ total: 0,
+};
+
+const run = (
+ state: CalendarTimeRangeResponse["state"],
+ count: number,
+ date = "2026-04-08T10:00:00Z",
+): CalendarTimeRangeResponse => ({
+ count,
+ date,
+ state,
+});
+
+describe("calculateRunCounts", () => {
+ it("counts each calendar state and includes all states in total", () => {
+ expect(
+ calculateRunCounts([
+ run("success", 2),
+ run("failed", 1),
+ run("running", 3),
+ run("queued", 4),
+ run("planned", 5),
+ ]),
+ ).toEqual({
+ failed: 1,
+ planned: 5,
+ queued: 4,
+ running: 3,
+ success: 2,
+ total: 15,
+ });
+ });
+});
+
+describe("calculateDataBounds", () => {
+ it("uses total counts for total mode bounds", () => {
+ expect(
+ calculateDataBounds(
+ [
+ run("success", 2, "2026-04-08T10:00:00Z"),
+ run("failed", 1, "2026-04-08T10:00:00Z"),
+ run("running", 4, "2026-04-08T11:00:00Z"),
+ ],
+ "total",
+ "hourly",
+ ),
+ ).toEqual({ maxCount: 4, minCount: 3 });
+ });
+
+ it("excludes queued runs from total mode bounds when actual runs are
present", () => {
+ expect(
+ calculateDataBounds(
+ [run("queued", 100, "2026-04-08T10:00:00Z"), run("success", 1,
"2026-04-08T11:00:00Z")],
+ "total",
+ "hourly",
+ ),
+ ).toEqual({ maxCount: 1, minCount: 1 });
+ });
+
+ it("keeps queued-only total mode data from using an empty scale", () => {
+ expect(calculateDataBounds([run("queued", 100)], "total",
"hourly")).toEqual({
+ maxCount: 100,
+ minCount: 100,
+ });
+ });
+
+ it("uses failed counts for failed mode bounds", () => {
+ expect(
+ calculateDataBounds(
+ [
+ run("success", 10, "2026-04-08T10:00:00Z"),
+ run("failed", 2, "2026-04-08T10:00:00Z"),
+ run("failed", 5, "2026-04-08T11:00:00Z"),
+ run("queued", 20, "2026-04-08T11:00:00Z"),
+ ],
+ "failed",
+ "hourly",
+ ),
+ ).toEqual({ maxCount: 5, minCount: 2 });
+ });
+});
+
+describe("createCalendarScale", () => {
+ it("returns the planned color for a planned-only cell", () => {
+ const scale = createCalendarScale([run("planned", 1)], "total", "hourly");
+
+ expect(scale.getColor({ ...EMPTY_COUNTS, planned: 1, total: 1
})).toEqual(PLANNED_COLOR);
+ });
+
+ it("returns the default total color for a success-only cell", () => {
+ const scale = createCalendarScale([run("success", 1)], "total", "hourly");
+
+ expect(scale.getColor({ ...EMPTY_COUNTS, success: 1, total: 1
})).toEqual(DEFAULT_TOTAL_COLOR);
+ });
+
+ it("returns the planned color for a queued-only cell in total mode", () => {
+ const scale = createCalendarScale([run("queued", 1)], "total", "hourly");
+
+ expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, total: 1
})).toEqual(PLANNED_COLOR);
+ });
+
+ it("returns a mixed color for planned and actual runs in total mode", () => {
+ const scale = createCalendarScale([run("planned", 1), run("success", 1)],
"total", "hourly");
+
+ expect(scale.getColor({ ...EMPTY_COUNTS, planned: 1, success: 1, total: 2
})).toEqual({
+ actual: DEFAULT_TOTAL_COLOR,
+ planned: PLANNED_COLOR,
+ });
+ });
+
+ it("returns a mixed color for queued and actual runs in total mode", () => {
+ const scale = createCalendarScale([run("queued", 1), run("success", 1)],
"total", "hourly");
+
+ expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, success: 1, total: 2
})).toEqual({
+ actual: DEFAULT_TOTAL_COLOR,
+ planned: PLANNED_COLOR,
+ });
+ });
+
+ it("uses failed counts for failed mode", () => {
+ const scale = createCalendarScale([run("success", 5), run("failed", 1)],
"failed", "hourly");
+
+ expect(scale.getColor({ ...EMPTY_COUNTS, success: 5, total: 5
})).toEqual(EMPTY_COLOR);
+ expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, total: 1
})).toEqual(DEFAULT_FAILED_COLOR);
+ });
+
+ it("returns a mixed color for planned and failed runs in failed mode", () =>
{
+ const scale = createCalendarScale([run("planned", 1), run("failed", 1)],
"failed", "hourly");
+
+ expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, planned: 1, total: 2
})).toEqual({
+ actual: DEFAULT_FAILED_COLOR,
+ planned: PLANNED_COLOR,
+ });
+ });
+
+ it("returns the planned color for a queued-only cell in failed mode", () => {
+ const scale = createCalendarScale([run("queued", 1)], "failed", "hourly");
+
+ expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, total: 1
})).toEqual(PLANNED_COLOR);
+ });
+
+ it("returns a mixed color for queued and failed runs in failed mode", () => {
+ const scale = createCalendarScale([run("queued", 1), run("failed", 1)],
"failed", "hourly");
+
+ expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, queued: 1, total: 2
})).toEqual({
+ actual: DEFAULT_FAILED_COLOR,
+ planned: PLANNED_COLOR,
+ });
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts
index 3d4fbaab846..712699f5707 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts
@@ -55,6 +55,11 @@ const FAILURE_COLOR_INTENSITIES = [
{ _dark: "red.300", _light: "red.800" },
];
+const getActualRunCount = (counts: RunCounts, viewMode: CalendarColorMode) =>
+ viewMode === "total" ? counts.total - counts.planned - counts.queued :
counts.failed;
+
+const getPendingRunCount = (counts: RunCounts) => counts.planned +
counts.queued;
+
const createDailyDataMap = (data: Array<CalendarTimeRangeResponse>) => {
const dailyDataMap = new Map<string, Array<CalendarTimeRangeResponse>>();
@@ -181,19 +186,33 @@ export const calculateDataBounds = (
}
const counts: Array<number> = [];
+ const pendingCounts: Array<number> = [];
const mapCreator = granularity === "daily" ? createDailyDataMap :
createHourlyDataMap;
const dataMap = mapCreator(data);
dataMap.forEach((runs) => {
const runCounts = calculateRunCounts(runs);
- const targetCount = viewMode === "total" ? runCounts.total :
runCounts.failed;
+ const targetCount = getActualRunCount(runCounts, viewMode);
if (targetCount > 0) {
counts.push(targetCount);
+ } else {
+ const pendingCount = getPendingRunCount(runCounts);
+
+ if (pendingCount > 0) {
+ pendingCounts.push(pendingCount);
+ }
}
});
if (counts.length === 0) {
+ if (pendingCounts.length > 0) {
+ return {
+ maxCount: Math.max(...pendingCounts),
+ minCount: Math.min(...pendingCounts),
+ };
+ }
+
return { maxCount: 0, minCount: 0 };
}
@@ -226,18 +245,18 @@ export const createCalendarScale = (
return {
getColor: (counts: RunCounts) => {
- const actualCount = viewMode === "total" ? counts.total -
counts.planned : counts.failed;
- const hasPlanned = counts.planned > 0;
+ const actualCount = getActualRunCount(counts, viewMode);
+ const hasPending = getPendingRunCount(counts) > 0;
const hasActual = actualCount > 0;
- if (hasPlanned && hasActual) {
+ if (hasPending && hasActual) {
return {
actual: singleColor,
planned: PLANNED_COLOR,
};
}
- if (hasPlanned && !hasActual) {
+ if (hasPending && !hasActual) {
return PLANNED_COLOR;
}
@@ -274,11 +293,11 @@ export const createCalendarScale = (
actual: string | { _dark: string; _light: string };
planned: string | { _dark: string; _light: string };
} => {
- const actualCount = viewMode === "total" ? counts.total - counts.planned :
counts.failed;
- const hasPlanned = counts.planned > 0;
+ const actualCount = getActualRunCount(counts, viewMode);
+ const hasPending = getPendingRunCount(counts) > 0;
const hasActual = actualCount > 0;
- if (hasPlanned && hasActual) {
+ if (hasPending && hasActual) {
let actualColor = colorScheme[0] ?? EMPTY_COLOR;
for (let index = uniqueThresholds.length - 1; index >= 1; index -= 1) {
@@ -300,7 +319,7 @@ export const createCalendarScale = (
};
}
- if (hasPlanned && !hasActual) {
+ if (hasPending && !hasActual) {
return PLANNED_COLOR;
}