diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx index ea91e34309bb0..6660194329e72 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx @@ -28,8 +28,8 @@ type Props = { | Record | string | { - actual: string | { _dark: string; _light: string }; - planned: string | { _dark: string; _light: string }; + primary: string | { _dark: string; _light: string }; + secondary: string | { _dark: string; _light: string }; }; readonly cellData: CalendarCellData | undefined; readonly index?: number; @@ -64,7 +64,7 @@ export const CalendarCell = ({ : []; const isMixedState = - typeof backgroundColor === "object" && "planned" in backgroundColor && "actual" in backgroundColor; + typeof backgroundColor === "object" && "secondary" in backgroundColor && "primary" in backgroundColor; const cellBox = isMixedState ? ( + | string + | { primary: Record | string; secondary: Record | string }; + +const LegendIcon = ({ color, cursor }: { readonly color: LegendColorType; readonly cursor?: string }) => { + const isMixedState = typeof color === "object" && "primary" in color && "secondary" in color; + + if (isMixedState) { + return ( + + + + + ); + } + + return ; +}; + export const CalendarLegend = ({ scale, vertical = false, viewMode }: Props) => { - const { t: translate } = useTranslation("dag"); + const { t: translate } = useTranslation(["dag", "common"]); const legendTitle = viewMode === "failed" ? translate("overview.buttons.failedRun_other") : translate("calendar.totalRuns"); @@ -54,7 +94,9 @@ export const CalendarLegend = ({ scale, vertical = false, viewMode }: Props) => {[...scale.legendItems].reverse().map(({ color, label }) => ( - + + + ))} @@ -70,7 +112,9 @@ export const CalendarLegend = ({ scale, vertical = false, viewMode }: Props) => {scale.legendItems.map(({ color, label }) => ( - + + + ))} @@ -83,42 +127,49 @@ export const CalendarLegend = ({ scale, vertical = false, viewMode }: Props) => + {viewMode === "total" && ( + <> + + + + {translate("common:states.success")} + + + + + + {translate("common:states.running")} + + + + )} + + + + + {translate("common:states.failed")} + + + {translate("common:states.planned")} + - - - - + : { _dark: "green.700", _light: "green.400" }, + secondary: PLANNED_COLOR, + }} + /> - {translate("calendar.legend.mixed")} + {translate("dag:calendar.legend.mixed")} diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx index f5a3a50ecf96e..90ae1d8ab3681 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx @@ -32,6 +32,7 @@ type Props = { const stateColorMap = { failed: "failed.solid", planned: "stone.solid", + queued: "queued.solid", running: "running.solid", success: "success.solid", }; 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 index 4cb86deec295c..c35225cebdc8f 100644 --- 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 @@ -27,6 +27,7 @@ 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 DEFAULT_RUNNING_COLOR = { _dark: "cyan.700", _light: "cyan.400" }; const EMPTY_COUNTS: RunCounts = { failed: 0, @@ -158,8 +159,8 @@ describe("createCalendarScale", () => { }); expect(scale.getColor({ ...EMPTY_COUNTS, planned: 1, success: 1, total: 2 })).toEqual({ - actual: DEFAULT_TOTAL_COLOR, - planned: PLANNED_COLOR, + primary: DEFAULT_TOTAL_COLOR, + secondary: PLANNED_COLOR, }); }); @@ -171,8 +172,57 @@ describe("createCalendarScale", () => { }); expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, success: 1, total: 2 })).toEqual({ - actual: DEFAULT_TOTAL_COLOR, - planned: PLANNED_COLOR, + primary: DEFAULT_TOTAL_COLOR, + secondary: PLANNED_COLOR, + }); + }); + + it("returns the failed color for a failed-only cell in total mode", () => { + const scale = createCalendarScale([run("failed", 1)], { + granularity: "hourly", + timezone: "UTC", + viewMode: "total", + }); + + expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, total: 1 })).toEqual(DEFAULT_FAILED_COLOR); + }); + + it("returns a mixed red and green color for failed and success runs in total mode", () => { + const scale = createCalendarScale([run("failed", 1), run("success", 1)], { + granularity: "hourly", + timezone: "UTC", + viewMode: "total", + }); + + expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, success: 1, total: 2 })).toEqual({ + primary: DEFAULT_FAILED_COLOR, + secondary: DEFAULT_TOTAL_COLOR, + }); + }); + + it("returns a mixed cyan and green color for running and success runs in total mode", () => { + const scale = createCalendarScale([run("running", 1), run("success", 1)], { + granularity: "hourly", + timezone: "UTC", + viewMode: "total", + }); + + expect(scale.getColor({ ...EMPTY_COUNTS, running: 1, success: 1, total: 2 })).toEqual({ + primary: DEFAULT_RUNNING_COLOR, + secondary: DEFAULT_TOTAL_COLOR, + }); + }); + + it("returns a mixed cyan and red color for running and failed runs in total mode", () => { + const scale = createCalendarScale([run("running", 1), run("failed", 1)], { + granularity: "hourly", + timezone: "UTC", + viewMode: "total", + }); + + expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, running: 1, total: 2 })).toEqual({ + primary: DEFAULT_FAILED_COLOR, + secondary: DEFAULT_RUNNING_COLOR, }); }); @@ -195,8 +245,8 @@ describe("createCalendarScale", () => { }); expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, planned: 1, total: 2 })).toEqual({ - actual: DEFAULT_FAILED_COLOR, - planned: PLANNED_COLOR, + primary: DEFAULT_FAILED_COLOR, + secondary: PLANNED_COLOR, }); }); @@ -218,8 +268,76 @@ describe("createCalendarScale", () => { }); expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, queued: 1, total: 2 })).toEqual({ - actual: DEFAULT_FAILED_COLOR, - planned: PLANNED_COLOR, + primary: DEFAULT_FAILED_COLOR, + secondary: PLANNED_COLOR, + }); + }); + + it("returns the correct gradient color when runs span across different dates", () => { + const scale = createCalendarScale( + [run("failed", 1, "2026-04-08T10:00:00Z"), run("failed", 5, "2026-04-09T10:00:00Z")], + { + granularity: "hourly", + timezone: "UTC", + viewMode: "total", + }, + ); + + const lowIntensityColor = { _dark: "red.900", _light: "red.200" }; + const highIntensityColor = { _dark: "red.300", _light: "red.800" }; + + expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, total: 1 })).toEqual(lowIntensityColor); + expect(scale.getColor({ ...EMPTY_COUNTS, failed: 5, total: 5 })).toEqual(highIntensityColor); + }); + + it("prioritizes failed over running over success when multiple actual states coexist with pending", () => { + const scale = createCalendarScale([run("planned", 1), run("failed", 1), run("success", 1)], { + granularity: "hourly", + timezone: "UTC", + viewMode: "total", + }); + + expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, planned: 1, success: 1, total: 3 })).toEqual({ + primary: DEFAULT_FAILED_COLOR, + secondary: PLANNED_COLOR, }); }); + + it("returns an empty scale when no data is provided", () => { + const scale = createCalendarScale([], { + granularity: "hourly", + timezone: "UTC", + viewMode: "total", + }); + + expect(scale.type).toBe("empty"); + expect(scale.getColor(EMPTY_COUNTS)).toEqual(EMPTY_COLOR); + expect(scale.legendItems).toEqual([{ color: EMPTY_COLOR, label: "0" }]); + }); + + it("prioritizes running and failed colors when failed, running, and success coexist without pending states", () => { + const scale = createCalendarScale([run("failed", 1), run("running", 1), run("success", 1)], { + granularity: "hourly", + timezone: "UTC", + viewMode: "total", + }); + + expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, running: 1, success: 1, total: 3 })).toEqual({ + primary: DEFAULT_FAILED_COLOR, + secondary: DEFAULT_RUNNING_COLOR, + }); + }); + + it("returns the correct gradient color for failed mode when failed runs span across different dates", () => { + const scale = createCalendarScale( + [run("failed", 1, "2026-04-08T10:00:00Z"), run("failed", 10, "2026-04-09T10:00:00Z")], + { granularity: "hourly", timezone: "UTC", viewMode: "failed" }, + ); + + const lowIntensityFailedColor = { _dark: "red.900", _light: "red.200" }; + const highIntensityFailedColor = { _dark: "red.300", _light: "red.800" }; + + expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, total: 1 })).toEqual(lowIntensityFailedColor); + expect(scale.getColor({ ...EMPTY_COUNTS, failed: 10, total: 10 })).toEqual(highIntensityFailedColor); + }); }); 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 3f57eb35030c5..527ebc6389c26 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 @@ -40,6 +40,7 @@ dayjs.extend(tz); // Calendar color constants export const PLANNED_COLOR = { _dark: "stone.600", _light: "stone.500" }; const EMPTY_COLOR = { _dark: "gray.700", _light: "gray.100" }; +const RUNNING_COLOR = { _dark: "cyan.700", _light: "cyan.400" }; const TOTAL_COLOR_INTENSITIES = [ EMPTY_COLOR, // 0 @@ -244,6 +245,75 @@ type ScaleOptions = { viewMode: CalendarColorMode; }; +type ColorValue = string | { _dark: string; _light: string }; + +type ResolveColorParams = { + failedColor: ColorValue; + failedCount: number; + hasPending: boolean; + runningCount: number; + successColor: ColorValue; + successCount: number; +}; + +const resolveCellColor = ({ + failedColor, + failedCount, + hasPending, + runningCount, + successColor, + successCount, +}: ResolveColorParams): ColorValue | { primary: ColorValue; secondary: ColorValue } => { + const hasActual = failedCount > 0 || runningCount > 0 || successCount > 0; + + if (hasPending && hasActual) { + let primaryColor: ColorValue = EMPTY_COLOR; + + if (failedCount > 0) { + primaryColor = failedColor; + } else if (runningCount > 0) { + primaryColor = RUNNING_COLOR; + } else if (successCount > 0) { + primaryColor = successColor; + } + + return { + primary: primaryColor, + secondary: PLANNED_COLOR, + }; + } + + if (hasPending && !hasActual) { + return PLANNED_COLOR; + } + + if (hasActual) { + if (failedCount > 0 && runningCount > 0) { + return { primary: failedColor, secondary: RUNNING_COLOR }; + } + + if (failedCount > 0 && successCount > 0) { + return { primary: failedColor, secondary: successColor }; + } + + if (runningCount > 0 && successCount > 0) { + return { primary: RUNNING_COLOR, secondary: successColor }; + } + + if (failedCount > 0) { + return failedColor; + } + if (runningCount > 0) { + return RUNNING_COLOR; + } + if (successCount > 0) { + return successColor; + } + } + + return EMPTY_COLOR; +}; + export const createCalendarScale = ( data: Array, options: ScaleOptions, @@ -267,22 +337,23 @@ export const createCalendarScale = ( return { getColor: (counts: RunCounts) => { - const actualCount = getActualRunCount(counts, viewMode); - const hasPending = getPendingRunCount(counts) > 0; - const hasActual = actualCount > 0; - - if (hasPending && hasActual) { - return { - actual: singleColor, - planned: PLANNED_COLOR, - }; - } + const failedCount = counts.failed; + const runningCount = viewMode === "total" ? counts.running : 0; + const successCount = viewMode === "total" ? counts.success : 0; - if (hasPending && !hasActual) { - return PLANNED_COLOR; - } + const hasPending = getPendingRunCount(counts) > 0; - return actualCount === 0 ? EMPTY_COLOR : singleColor; + const failedColor = FAILURE_COLOR_INTENSITIES[2] ?? EMPTY_COLOR; + const successColor = TOTAL_COLOR_INTENSITIES[2] ?? EMPTY_COLOR; + + return resolveCellColor({ + failedColor, + failedCount, + hasPending, + runningCount, + successColor, + successCount, + }); }, legendItems: [ { color: EMPTY_COLOR, label: "0" }, @@ -312,54 +383,43 @@ export const createCalendarScale = ( | string | { _dark: string; _light: string } | { - actual: string | { _dark: string; _light: string }; - planned: string | { _dark: string; _light: string }; + primary: string | { _dark: string; _light: string }; + secondary: string | { _dark: string; _light: string }; } => { - const actualCount = getActualRunCount(counts, viewMode); - const hasPending = getPendingRunCount(counts) > 0; - const hasActual = actualCount > 0; + const failedCount = counts.failed; + const runningCount = viewMode === "total" ? counts.running : 0; + const successCount = viewMode === "total" ? counts.success : 0; - if (hasPending && hasActual) { - let actualColor = colorScheme[0] ?? EMPTY_COLOR; + const hasPending = getPendingRunCount(counts) > 0; + const getIntensityColor = (count: number, scheme: Array) => { + if (count === 0) { + return scheme[0] ?? EMPTY_COLOR; + } for (let index = uniqueThresholds.length - 1; index >= 1; index -= 1) { const threshold = uniqueThresholds[index]; - if (threshold !== undefined && actualCount >= threshold) { - actualColor = colorScheme[Math.min(index, colorScheme.length - 1)] ?? EMPTY_COLOR; - break; + if (threshold !== undefined && count >= threshold) { + return scheme[Math.min(index, scheme.length - 1)] ?? EMPTY_COLOR; } } - if (actualCount > 0 && actualColor === colorScheme[0]) { - actualColor = colorScheme[1] ?? EMPTY_COLOR; - } - - return { - actual: actualColor, - planned: PLANNED_COLOR, - }; - } - - if (hasPending && !hasActual) { - return PLANNED_COLOR; - } - - const targetCount = actualCount; - - if (targetCount === 0) { - return colorScheme[0] ?? EMPTY_COLOR; - } - - for (let index = uniqueThresholds.length - 1; index >= 1; index -= 1) { - const threshold = uniqueThresholds[index]; - - if (threshold !== undefined && targetCount >= threshold) { - return colorScheme[Math.min(index, colorScheme.length - 1)] ?? EMPTY_COLOR; - } - } + return scheme[1] ?? EMPTY_COLOR; + }; - return colorScheme[1] ?? EMPTY_COLOR; + const failedColor = + failedCount > 0 ? getIntensityColor(failedCount, FAILURE_COLOR_INTENSITIES) : EMPTY_COLOR; + const successColor = + successCount > 0 ? getIntensityColor(successCount, TOTAL_COLOR_INTENSITIES) : EMPTY_COLOR; + + return resolveCellColor({ + failedColor, + failedCount, + hasPending, + runningCount, + successColor, + successCount, + }); }; const legendItems: Array = []; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/types.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/types.ts index 8ef78a66af4d3..7e93c63773462 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/types.ts +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/types.ts @@ -69,8 +69,8 @@ export type CalendarScale = { | string | { _dark: string; _light: string } | { - actual: string | { _dark: string; _light: string }; - planned: string | { _dark: string; _light: string }; + primary: string | { _dark: string; _light: string }; + secondary: string | { _dark: string; _light: string }; }; readonly legendItems: Array; readonly type: CalendarScaleType;