This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 0619574dd21 Add `has_note` key to the API response for TI to render
saved note indicator in Airflow 3 Grid View UI (#68979)
0619574dd21 is described below
commit 0619574dd215787b18ef0e760f54d891eb9b42ae
Author: Jayachandra Kasarla <[email protected]>
AuthorDate: Fri Jun 26 15:20:29 2026 +0530
Add `has_note` key to the API response for TI to render saved note
indicator in Airflow 3 Grid View UI (#68979)
* Added has_note key
* Added tests for has_note
* Modified GridTi.tsx to render saved note indicator
* format
* added has_note to schemas.gen.ts
---------
Co-authored-by: Jayachandra Kasarla <[email protected]>
---
.../api_fastapi/core_api/datamodels/ui/grid.py | 1 +
.../api_fastapi/core_api/openapi/_private_ui.yaml | 4 +++
.../airflow/api_fastapi/core_api/routes/ui/grid.py | 11 ++++++-
.../api_fastapi/core_api/services/ui/grid.py | 5 ++++
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 5 ++++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 1 +
.../airflow/ui/src/layouts/Details/Grid/GridTI.tsx | 16 +++++++++-
.../layouts/Details/Grid/TaskInstancesColumn.tsx | 1 +
.../api_fastapi/core_api/routes/ui/test_grid.py | 35 +++++++++++++++++++++-
9 files changed, 76 insertions(+), 3 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
index 19278430d3f..98adbb537c6 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
@@ -34,6 +34,7 @@ class LightGridTaskInstanceSummary(BaseModel):
min_start_date: datetime | None
max_end_date: datetime | None
dag_version_number: int | None = None
+ has_note: bool = False
class GridTISummaries(BaseModel):
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 d786e259b4e..5d51f7b7480 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
@@ -3128,6 +3128,10 @@ components:
- type: integer
- type: 'null'
title: Dag Version Number
+ has_note:
+ type: boolean
+ title: Has Note
+ default: false
type: object
required:
- task_id
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 72d92fbef2c..ea9de387c39 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
@@ -68,7 +68,7 @@ from airflow.models.dag_version import DagVersion
from airflow.models.dagrun import DagRun
from airflow.models.deadline import Deadline
from airflow.models.serialized_dag import SerializedDagModel
-from airflow.models.taskinstance import TaskInstance
+from airflow.models.taskinstance import TaskInstance, TaskInstanceNote
from airflow.utils.session import create_session
if TYPE_CHECKING:
@@ -373,6 +373,7 @@ def _build_ti_summaries(
start_date=ti.start_date,
end_date=ti.end_date,
dag_version_number=getattr(ti, "version_number", None),
+ has_note=bool(getattr(ti, "has_note", False)),
)
if not ti_details:
return None
@@ -467,6 +468,13 @@ def get_grid_ti_summaries_stream(
# database connection open for the entire stream duration.
# See https://github.com/apache/airflow/issues/65010.
+ has_note_subq = (
+ exists()
+ .where(TaskInstanceNote.ti_id == TaskInstance.id)
+ .correlate(TaskInstance)
+ .label("has_note")
+ )
+
for run_id in run_ids or []:
with create_session(scoped=False) as session:
tis = session.execute(
@@ -477,6 +485,7 @@ def get_grid_ti_summaries_stream(
TaskInstance.start_date,
TaskInstance.end_date,
DagVersion.version_number,
+ has_note_subq,
)
.outerjoin(DagVersion, TaskInstance.dag_version_id ==
DagVersion.id)
.where(TaskInstance.dag_id == dag_id)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
index bff2fd66296..a40a6f726e2 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
@@ -43,6 +43,7 @@ class GridNodeAgg:
min_start_date: datetime | None = None
max_end_date: datetime | None = None
dag_version_number: int | None = None
+ has_note: bool = False
def add_ti(
self,
@@ -51,6 +52,7 @@ class GridNodeAgg:
start_date: datetime | None,
end_date: datetime | None,
dag_version_number: int | None,
+ has_note: bool = False,
) -> None:
"""Merge one task instance row into the summary."""
self.child_states[state] += 1
@@ -62,6 +64,7 @@ class GridNodeAgg:
self.dag_version_number is None or dag_version_number >
self.dag_version_number
):
self.dag_version_number = dag_version_number
+ self.has_note = self.has_note or has_note
def merge(self, other: GridNodeAgg) -> None:
"""Merge another summary into this one."""
@@ -78,6 +81,7 @@ class GridNodeAgg:
self.dag_version_number is None or other.dag_version_number >
self.dag_version_number
):
self.dag_version_number = other.dag_version_number
+ self.has_note = self.has_note or other.has_note
def with_placeholder_state(self) -> GridNodeAgg:
"""Represent mapped tasks without rows as a single no-status square in
the grid."""
@@ -133,6 +137,7 @@ def _get_aggs_for_node(summary: GridNodeAgg) -> dict[str,
Any]:
"max_end_date": summary.max_end_date,
"child_states": _serialize_child_states(summary.child_states),
"dag_version_number": summary.dag_version_number,
+ "has_note": summary.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 9685fd6b3cb..0f98b36e8c7 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
@@ -9737,6 +9737,11 @@ export const $LightGridTaskInstanceSummary = {
}
],
title: 'Dag Version Number'
+ },
+ has_note: {
+ type: 'boolean',
+ title: 'Has Note',
+ default: false
}
},
type: 'object',
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 7bd02c55979..81b5665a333 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
@@ -2472,6 +2472,7 @@ export type LightGridTaskInstanceSummary = {
min_start_date: string | null;
max_end_date: string | null;
dag_version_number?: number | null;
+ has_note?: boolean;
};
/**
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 6cd21d252f9..4ced7ca10c1 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,12 @@ 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%)";
+
type Props = {
readonly dagId: string;
+ readonly hasNote?: boolean;
readonly instance: LightGridTaskInstanceSummary;
readonly isGroup?: boolean;
readonly isMapped?: boolean | null;
@@ -36,7 +40,16 @@ type Props = {
readonly taskId: string;
};
-export const GridTI = ({ dagId, instance, isGroup, isMapped, onClick, runId,
taskId }: Props) => {
+export const GridTI = ({
+ dagId,
+ hasNote = false,
+ instance,
+ isGroup,
+ isMapped,
+ onClick,
+ runId,
+ taskId,
+}: Props) => {
const { hoveredTaskId, setHoveredTaskId } = useHover();
const { groupId: selectedGroupId, taskId: selectedTaskId } = useParams();
const location = useLocation();
@@ -107,6 +120,7 @@ export const GridTI = ({ dagId, instance, isGroup,
isMapped, onClick, runId, tas
justifyContent="center"
minH={0}
p={0}
+ style={hasNote ? { background: NOTE_GRADIENT } : undefined}
variant="solid"
width="14px"
>
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
index ed47572760c..227e8ed0188 100644
---
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
@@ -152,6 +152,7 @@ export const TaskInstancesColumn = ({
)}
<GridTI
dagId={dagId}
+ hasNote={taskInstance.has_note ?? false}
instance={taskInstance}
isGroup={node.isGroup}
isMapped={node.is_mapped}
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 d55b2df3625..7466812ca6c 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,7 +30,7 @@ from airflow._shared.timezones import timezone
from airflow.models.dag import DagModel
from airflow.models.dag_version import DagVersion
from airflow.models.dagbag import DBDagBag
-from airflow.models.taskinstance import TaskInstance
+from airflow.models.taskinstance import TaskInstance, TaskInstanceNote
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.providers.standard.operators.python import PythonOperator
from airflow.sdk import task_group
@@ -738,6 +738,7 @@ class TestGetGridDataEndpoint:
"task_display_name": "t1",
"child_states": None,
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": "2025-03-02T00:00:00Z",
"min_start_date": "2025-03-01T23:59:58Z",
},
@@ -747,6 +748,7 @@ class TestGetGridDataEndpoint:
"task_display_name": "t2",
"child_states": None,
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": "2025-03-02T00:00:02Z",
"min_start_date": "2025-03-02T00:00:00Z",
},
@@ -756,12 +758,14 @@ class TestGetGridDataEndpoint:
"task_display_name": "t7",
"child_states": None,
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": "2025-03-02T00:00:04Z",
"min_start_date": "2025-03-02T00:00:02Z",
},
{
"child_states": {"success": 4},
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": "2025-03-02T00:00:12Z",
"min_start_date": "2025-03-02T00:00:04Z",
"state": "success",
@@ -774,12 +778,14 @@ class TestGetGridDataEndpoint:
"task_display_name": "task_group-1.t6",
"child_states": None,
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": "2025-03-02T00:00:06Z",
"min_start_date": "2025-03-02T00:00:04Z",
},
{
"child_states": {"success": 3},
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": "2025-03-02T00:00:12Z",
"min_start_date": "2025-03-02T00:00:06Z",
"state": "success",
@@ -792,6 +798,7 @@ class TestGetGridDataEndpoint:
"task_display_name": "task_group-1.task_group-2.t3",
"child_states": None,
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": "2025-03-02T00:00:08Z",
"min_start_date": "2025-03-02T00:00:06Z",
},
@@ -801,6 +808,7 @@ class TestGetGridDataEndpoint:
"task_display_name": "task_group-1.task_group-2.t4",
"child_states": None,
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": "2025-03-02T00:00:10Z",
"min_start_date": "2025-03-02T00:00:08Z",
},
@@ -810,6 +818,7 @@ class TestGetGridDataEndpoint:
"task_display_name": "task_group-1.task_group-2.t5",
"child_states": None,
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": "2025-03-02T00:00:12Z",
"min_start_date": "2025-03-02T00:00:10Z",
},
@@ -842,6 +851,7 @@ class TestGetGridDataEndpoint:
{
"child_states": {"none": 1},
"dag_version_number": 1,
+ "has_note": False,
"task_id": "mapped_task_2",
"task_display_name": "mapped_task_2",
"max_end_date": None,
@@ -851,6 +861,7 @@ class TestGetGridDataEndpoint:
{
"child_states": {"success": 1, "running": 1, "none": 1},
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": "2024-12-30T01:02:03Z",
"min_start_date": "2024-12-30T01:00:00Z",
"state": "running",
@@ -863,6 +874,7 @@ class TestGetGridDataEndpoint:
"task_display_name": "mapped_task_group.subtask",
"child_states": None,
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": "2024-12-30T01:02:03Z",
"min_start_date": "2024-12-30T01:00:00Z",
},
@@ -872,12 +884,14 @@ class TestGetGridDataEndpoint:
"task_display_name": "A Beautiful Task Name \U0001f680",
"child_states": None,
"dag_version_number": 1,
+ "has_note": False,
"max_end_date": None,
"min_start_date": None,
},
{
"child_states": {"none": 6},
"dag_version_number": 1,
+ "has_note": False,
"task_id": "task_group",
"task_display_name": "task_group",
"max_end_date": None,
@@ -887,6 +901,7 @@ class TestGetGridDataEndpoint:
{
"child_states": {"none": 2},
"dag_version_number": 1,
+ "has_note": False,
"task_id": "task_group.inner_task_group",
"task_display_name": "task_group.inner_task_group",
"max_end_date": None,
@@ -896,6 +911,7 @@ class TestGetGridDataEndpoint:
{
"child_states": {"none": 2},
"dag_version_number": 1,
+ "has_note": False,
"task_id":
"task_group.inner_task_group.inner_task_group_sub_task",
"task_display_name": "Inner Task Group Sub Task Label",
"max_end_date": None,
@@ -905,6 +921,7 @@ class TestGetGridDataEndpoint:
{
"child_states": {"none": 4},
"dag_version_number": 1,
+ "has_note": False,
"task_id": "task_group.mapped_task",
"task_display_name": "task_group.mapped_task",
"max_end_date": None,
@@ -1143,6 +1160,22 @@ class TestGetGridDataEndpoint:
task_ids = sorted([node["id"] for node in nodes])
assert task_ids == expected_task_ids, description
+ def test_grid_ti_summaries_has_note(self, session, test_client):
+ """has_note is true when a TaskInstanceNote exists for a TI, false
otherwise."""
+ ti = session.scalar(
+ select(TaskInstance).where(TaskInstance.run_id == "run_1",
TaskInstance.task_id == TASK_ID)
+ )
+ ti.task_instance_note = TaskInstanceNote(content="test note")
+ session.commit()
+
+ response =
test_client.get(f"/grid/ti_summaries/{DAG_ID}?run_ids=run_1")
+ assert response.status_code == 200
+ [data] = self._parse_ndjson(response)
+ by_task_id = {ti["task_id"]: ti for ti in data["task_instances"]}
+
+ assert by_task_id[TASK_ID]["has_note"] is True
+ assert all(not ti["has_note"] for task_id, ti in by_task_id.items() if
task_id != TASK_ID)
+
@staticmethod
def _parse_ndjson(response) -> list[dict]:
"""Parse NDJSON streaming response into a list of dicts."""