diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py index e429673001735..702f6997cdf5e 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py @@ -93,6 +93,7 @@ class GridRunsResponse(BaseModel): run_type: DagRunType dag_versions: list[DagVersionResponse] = [] has_missed_deadline: bool + has_note: bool @computed_field def duration(self) -> float: diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml index 3defcad70f9fd..e01a6f6e3c566 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml +++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml @@ -3188,6 +3188,9 @@ components: has_missed_deadline: type: boolean title: Has Missed Deadline + has_note: + type: boolean + title: Has Note duration: type: number title: Duration @@ -3203,6 +3206,7 @@ components: - state - run_type - has_missed_deadline + - has_note - duration title: GridRunsResponse description: Base Node serializer for responses. diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py index ea9de387c3987..a16f9336fd57e 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py @@ -65,7 +65,7 @@ task_group_to_dict_grid, ) from airflow.models.dag_version import DagVersion -from airflow.models.dagrun import DagRun +from airflow.models.dagrun import DagRun, DagRunNote from airflow.models.deadline import Deadline from airflow.models.serialized_dag import SerializedDagModel from airflow.models.taskinstance import TaskInstance, TaskInstanceNote @@ -291,8 +291,14 @@ def get_grid_runs( .correlate(DagRun) .label("has_missed_deadline") ) + has_note_subq = ( + exists() + .where(DagRunNote.dag_run_id == DagRun.id, DagRunNote.content.isnot(None)) + .correlate(DagRun) + .label("has_note") + ) base_query = ( - select(DagRun, has_missed_deadline) + select(DagRun, has_missed_deadline, has_note_subq) .where(DagRun.dag_id == dag_id) .options( load_only( @@ -329,10 +335,10 @@ def get_grid_runs( return_total_entries=False, ) results = session.execute(dag_runs_select_filter).unique().all() - dag_runs = [run for run, _ in results] + dag_runs = [run for run, _, _ in results] attach_dag_versions_to_runs(dag_runs, session=session) grid_runs = [] - for run, has_missed in results: + for run, has_missed, has_note in results: grid_runs.append( GridRunsResponse.model_validate( { @@ -346,6 +352,7 @@ def get_grid_runs( "run_type": run.run_type, "dag_versions": run.dag_versions, "has_missed_deadline": has_missed, + "has_note": has_note, } ) ) @@ -470,7 +477,7 @@ def _generate() -> Generator[str, None, None]: has_note_subq = ( exists() - .where(TaskInstanceNote.ti_id == TaskInstance.id) + .where(TaskInstanceNote.ti_id == TaskInstance.id, TaskInstanceNote.content.isnot(None)) .correlate(TaskInstance) .label("has_note") ) diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts index 55b25b2c9753f..d8d8fcb7d880d 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts @@ -9910,6 +9910,10 @@ export const $GridRunsResponse = { type: 'boolean', title: 'Has Missed Deadline' }, + has_note: { + type: 'boolean', + title: 'Has Note' + }, duration: { type: 'number', title: 'Duration', @@ -9917,7 +9921,7 @@ export const $GridRunsResponse = { } }, type: 'object', - required: ['dag_id', 'run_id', 'queued_at', 'start_date', 'end_date', 'run_after', 'state', 'run_type', 'has_missed_deadline', 'duration'], + required: ['dag_id', 'run_id', 'queued_at', 'start_date', 'end_date', 'run_after', 'state', 'run_type', 'has_missed_deadline', 'has_note', 'duration'], title: 'GridRunsResponse', description: 'Base Node serializer for responses.' } as const; diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts index acec153fd1fe2..e63e178b3f9d8 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts @@ -2529,6 +2529,7 @@ export type GridRunsResponse = { run_type: DagRunType; dag_versions?: Array; has_missed_deadline: boolean; + has_note: boolean; readonly duration: number; }; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx index 0cfce328ac45e..acb26f4a9b186 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx @@ -25,7 +25,7 @@ import { useHover } from "src/context/hover"; import { GridButton } from "./GridButton"; import { BundleVersionIndicator, DagVersionIndicator } from "./VersionIndicator"; -import { BAR_HEIGHT } from "./constants"; +import { BAR_HEIGHT, NOTE_GRADIENT } from "./constants"; import { getBundleVersion, getMaxVersionNumber, @@ -83,6 +83,7 @@ export const Bar = ({ max, onClick, run, showVersionIndicatorMode }: Props) => { { runId={run.run_id} searchParams={search} state={run.state} + style={run.has_note ? { background: NOTE_GRADIENT } : undefined} zIndex={1} > {run.run_type !== "scheduled" && } diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx index 4ced7ca10c1a7..f7f1abaa74c52 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx @@ -25,8 +25,7 @@ import TaskInstanceTooltip from "src/components/TaskInstanceTooltip"; import { useHover } from "src/context/hover"; import { buildTaskInstanceUrl } from "src/utils/links"; -const NOTE_GRADIENT = - "linear-gradient(45deg, var(--chakra-colors-color-palette-solid) 65%, var(--chakra-colors-color-palette-emphasized) 65%)"; +import { NOTE_GRADIENT } from "./constants"; type Props = { readonly dagId: string; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts index 616dd2223d905..fa2f987fffbc3 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts @@ -41,3 +41,7 @@ export const BUNDLE_VERSION_INDICATOR_LEFT = -2; // Position from left for bundl export const BUNDLE_VERSION_ICON_SIZE = 15; // Size of the git commit icon export const DAG_VERSION_INDICATOR_HEIGHT = 104; // Height of the vertical line indicator export const VERSION_INDICATOR_Z_INDEX = 1; // Z-index for version indicators + +// Render a gradient to indicate a saved note on a Dag run or task instance +export const NOTE_GRADIENT = + "linear-gradient(45deg, var(--chakra-colors-color-palette-solid) calc(100% - 8px), var(--chakra-colors-color-palette-emphasized) calc(100% - 8px))"; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.test.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.test.ts index b8fae1ed1f20c..33a40c8e36664 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.test.ts +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridPagination.test.ts @@ -28,6 +28,7 @@ const makeRun = (runId: string): GridRunsResponse => ({ duration: 0, end_date: null, has_missed_deadline: false, + has_note: false, queued_at: null, run_after: "2024-01-01T00:00:00Z", run_id: runId, diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py index 7466812ca6c52..17b485b9ebf30 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py @@ -30,6 +30,7 @@ from airflow.models.dag import DagModel from airflow.models.dag_version import DagVersion from airflow.models.dagbag import DBDagBag +from airflow.models.dagrun import DagRun, DagRunNote from airflow.models.taskinstance import TaskInstance, TaskInstanceNote from airflow.providers.standard.operators.empty import EmptyOperator from airflow.providers.standard.operators.python import PythonOperator @@ -76,6 +77,7 @@ "duration": 283996800.0, "end_date": "2024-12-31T00:00:00Z", "has_missed_deadline": False, + "has_note": False, "run_after": "2024-11-30T00:00:00Z", "run_id": "run_1", "run_type": "scheduled", @@ -97,6 +99,7 @@ "duration": 283996800.0, "end_date": "2024-12-31T00:00:00Z", "has_missed_deadline": False, + "has_note": False, "run_after": "2024-11-30T00:00:00Z", "run_id": "run_2", "run_type": "manual", @@ -643,6 +646,19 @@ def test_get_grid_runs(self, session, test_client): assert response.status_code == 200 assert _strip_dag_version_ids(response.json()) == [GRID_RUN_1, GRID_RUN_2] + def test_get_grid_runs_has_note(self, session, test_client): + """has_note is True when a DagRunNote exists for a dag run, False otherwise.""" + run_1 = session.scalar(select(DagRun).where(DagRun.dag_id == DAG_ID, DagRun.run_id == "run_1")) + run_1.dag_run_note = DagRunNote(content="a note on run_1") + session.commit() + + response = test_client.get(f"/grid/runs/{DAG_ID}") + assert response.status_code == 200 + by_run_id = {run["run_id"]: run for run in response.json()} + + assert by_run_id["run_1"]["has_note"] is True + assert by_run_id["run_2"]["has_note"] is False + def test_get_grid_runs_multiple_dag_versions(self, session, test_client): # run_5_2 is created after version 2 exists, so its task instances run on version 2. # Reassign one of them to version 1 so the run spans two versions.