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 4f4befcc73c Show per-Dag run state counts on the Dags list page 
(#67242)
4f4befcc73c is described below

commit 4f4befcc73cac7786424867eb7fa97a6f75352c4
Author: Yuseok Jo <[email protected]>
AuthorDate: Tue Jul 7 07:35:13 2026 +0900

    Show per-Dag run state counts on the Dags list page (#67242)
    
    * UI: Show per-Dag run state counts on the Dags list page
    
    * UI: Align run history chart to card bottom in Dag list
    
    * UI: Add time range filter for run state counts on Dags list
    
    * UI: Fix openapi-gen queries runAfterGte type and formatting for run state 
counts
    
    * UI: Fix Dag capitalization and remove stale performance comment in run 
state counts
    
    * UI: Simplify Dag ID deduplication with set intersection in run state 
counts
    
    * Cap per-Dag run state counts to scale on large DagRun tables
    
    * Show capped per-Dag run state counts as "N+" on the Dags list
    
    * Query run state counts per state to keep them fast at scale
    
    * Apply prettier formatting to run state counts UI components
    
    * Drop feature newsfragment for Dags list run state counts
    
    * Drop redundant useMemo for dag ids in run state counts query
    
    * UI: Drop redundant useMemo in Dags list run state counts (React Compiler 
memoizes)
    
    * Regenerate run state counts API client after rebase on main
    
    * Remove time range filter from Dags list run state counts
    
    * Cap run_state_counts dag_ids to the maximum page limit
    
    * Revert unused showDateRange prop from shared TimeRangeSelector
---
 .../api_fastapi/core_api/datamodels/ui/dags.py     |  15 +++
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |  69 ++++++++++
 .../airflow/api_fastapi/core_api/routes/ui/dags.py |  62 ++++++++-
 .../src/airflow/ui/openapi-gen/queries/common.ts   |   6 +
 .../ui/openapi-gen/queries/ensureQueryData.ts      |  11 ++
 .../src/airflow/ui/openapi-gen/queries/prefetch.ts |  11 ++
 .../src/airflow/ui/openapi-gen/queries/queries.ts  |  11 ++
 .../src/airflow/ui/openapi-gen/queries/suspense.ts |  11 ++
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts |  43 +++++++
 .../ui/openapi-gen/requests/services.gen.ts        |  23 +++-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  39 ++++++
 .../airflow/ui/public/i18n/locales/en/dags.json    |   5 +
 .../airflow/ui/src/pages/DagsList/DagCard.test.tsx |  27 ++--
 .../src/airflow/ui/src/pages/DagsList/DagCard.tsx  |  98 ++++++++------
 .../src/pages/DagsList/DagRunStateCounts.test.tsx  | 100 ++++++++++++++
 .../ui/src/pages/DagsList/DagRunStateCounts.tsx    |  87 +++++++++++++
 .../src/airflow/ui/src/pages/DagsList/DagsList.tsx |  63 +++++++--
 .../ui/src/queries/useDagRunStateCounts.tsx        |  45 +++++++
 .../api_fastapi/core_api/routes/ui/test_dags.py    | 143 +++++++++++++++++++++
 19 files changed, 811 insertions(+), 58 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
index febdf45c0a8..590084cc2c5 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
@@ -22,6 +22,7 @@ from airflow.api_fastapi.core_api.datamodels.common import 
MaybeAssetExpression
 from airflow.api_fastapi.core_api.datamodels.dags import DAGResponse
 from airflow.api_fastapi.core_api.datamodels.hitl import HITLDetail
 from airflow.api_fastapi.core_api.datamodels.ui.dag_runs import 
DAGRunLightResponse
+from airflow.utils.state import DagRunState
 
 
 class DAGWithLatestDagRunsResponse(DAGResponse):
@@ -38,3 +39,17 @@ class DAGWithLatestDagRunsCollectionResponse(BaseModel):
 
     total_entries: int
     dags: list[DAGWithLatestDagRunsResponse]
+
+
+class DAGRunStateCountsResponse(BaseModel):
+    """Per-Dag counts of DagRuns grouped by state."""
+
+    dag_id: str
+    state_counts: dict[DagRunState, int]
+
+
+class DAGsRunStateCountsCollectionResponse(BaseModel):
+    """Collection of per-Dag DagRun-state counts for the Dag list page."""
+
+    dags: list[DAGRunStateCountsResponse]
+    state_count_limit: int
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 45da42f9224..887aee12c9b 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
@@ -593,6 +593,41 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/HTTPValidationError'
+  /ui/dags/run_state_counts:
+    get:
+      tags:
+      - DAG
+      summary: Get Dag Run State Counts
+      description: Return per-Dag DagRun state counts (zero-filled) for the 
Dag list
+        page.
+      operationId: get_dag_run_state_counts_ui
+      security:
+      - OAuth2PasswordBearer: []
+      - HTTPBearer: []
+      parameters:
+      - name: dag_ids
+        in: query
+        required: true
+        schema:
+          type: array
+          items:
+            type: string
+          minItems: 1
+          maxItems: 100
+          title: Dag Ids
+      responses:
+        '200':
+          description: Successful Response
+          content:
+            application/json:
+              schema:
+                $ref: 
'#/components/schemas/DAGsRunStateCountsCollectionResponse'
+        '422':
+          description: Validation Error
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPValidationError'
   /ui/dependencies:
     get:
       tags:
@@ -2260,6 +2295,24 @@ components:
       - duration
       title: DAGRunLightResponse
       description: DAG Run serializer for responses.
+    DAGRunStateCountsResponse:
+      properties:
+        dag_id:
+          type: string
+          title: Dag Id
+        state_counts:
+          additionalProperties:
+            type: integer
+          propertyNames:
+            $ref: '#/components/schemas/DagRunState'
+          type: object
+          title: State Counts
+      type: object
+      required:
+      - dag_id
+      - state_counts
+      title: DAGRunStateCountsResponse
+      description: Per-Dag counts of DagRuns grouped by state.
     DAGRunStates:
       properties:
         queued:
@@ -2499,6 +2552,22 @@ components:
       - file_token
       title: DAGWithLatestDagRunsResponse
       description: DAG with latest dag runs response serializer.
+    DAGsRunStateCountsCollectionResponse:
+      properties:
+        dags:
+          items:
+            $ref: '#/components/schemas/DAGRunStateCountsResponse'
+          type: array
+          title: Dags
+        state_count_limit:
+          type: integer
+          title: State Count Limit
+      type: object
+      required:
+      - dags
+      - state_count_limit
+      title: DAGsRunStateCountsCollectionResponse
+      description: Collection of per-Dag DagRun-state counts for the Dag list 
page.
     DagRunState:
       type: string
       enum:
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
index d2b86beb2b7..15515a92bb8 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
@@ -19,8 +19,8 @@ from __future__ import annotations
 
 from typing import Annotated
 
-from fastapi import Depends, HTTPException, status
-from sqlalchemy import select, union_all
+from fastapi import Depends, HTTPException, Query, status
+from sqlalchemy import func, literal, select, union_all
 from sqlalchemy.orm import defaultload
 
 from airflow.api_fastapi.auth.managers.models.resource_details import 
DagAccessEntity
@@ -57,20 +57,24 @@ from airflow.api_fastapi.common.router import AirflowRouter
 from airflow.api_fastapi.core_api.datamodels.dags import DAG_ALIAS_MAPPING, 
DAGResponse
 from airflow.api_fastapi.core_api.datamodels.ui.dag_runs import 
DAGRunLightResponse
 from airflow.api_fastapi.core_api.datamodels.ui.dags import (
+    DAGRunStateCountsResponse,
+    DAGsRunStateCountsCollectionResponse,
     DAGWithLatestDagRunsCollectionResponse,
     DAGWithLatestDagRunsResponse,
 )
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
+from airflow.api_fastapi.core_api.routes.ui.dashboard import STATE_COUNT_CAP
 from airflow.api_fastapi.core_api.security import (
     GetUserDep,
     ReadableDagsFilterDep,
     requires_access_dag,
 )
+from airflow.configuration import conf
 from airflow.models import DagModel, DagRun
 from airflow.models.dag_favorite import DagFavorite
 from airflow.models.hitl import HITLDetail
 from airflow.models.taskinstance import TaskInstance
-from airflow.utils.state import TaskInstanceState
+from airflow.utils.state import DagRunState, TaskInstanceState
 
 dags_router = AirflowRouter(prefix="/dags", tags=["DAG"])
 
@@ -284,3 +288,55 @@ def get_latest_run_info(dag_id: str, session: SessionDep) 
-> DAGRunLightResponse
     latest_run_info = session.execute(latest_run_info_select).one_or_none()
 
     return DAGRunLightResponse(**latest_run_info._mapping) if latest_run_info 
else None
+
+
+@dags_router.get(
+    "/run_state_counts",
+    dependencies=[
+        Depends(requires_access_dag(method="GET")),
+        Depends(requires_access_dag(method="GET", 
access_entity=DagAccessEntity.RUN)),
+    ],
+    operation_id="get_dag_run_state_counts_ui",
+)
+def get_dag_run_state_counts(
+    session: SessionDep,
+    readable_dags_filter: ReadableDagsFilterDep,
+    dag_ids: Annotated[list[str], Query(min_length=1, 
max_length=conf.getint("api", "maximum_page_limit"))],
+) -> DAGsRunStateCountsCollectionResponse:
+    """Return per-Dag DagRun state counts (zero-filled) for the Dag list 
page."""
+    permitted_dag_ids = readable_dags_filter.value or set()
+    requested_dag_ids = list(set(dag_ids) & permitted_dag_ids)
+    counts_by_dag: dict[str, dict[DagRunState, int]] = {
+        dag_id: {state: 0 for state in DagRunState} for dag_id in 
requested_dag_ids
+    }
+
+    if requested_dag_ids:
+        # One capped union per state, not a single (dag, state) union: keeping 
every
+        # branch on the same state lets the planner pick a uniform, efficient 
per-branch
+        # plan. A mixed-state union misplans and scans whole partitions 
(orders of
+        # magnitude slower). Each branch reads at most STATE_COUNT_CAP rows; 
the UI
+        # shows "N+" once a count reaches the cap.
+        for state in DagRunState:
+            branches = []
+            for dag_id in requested_dag_ids:
+                capped = (
+                    select(literal(dag_id).label("dag_id"))
+                    .select_from(DagRun)
+                    .where(DagRun.dag_id == dag_id, DagRun.state == state)
+                    .limit(STATE_COUNT_CAP)
+                    .subquery()
+                )
+                branches.append(select(capped.c.dag_id))
+            counts = union_all(*branches).subquery()
+            for row in session.execute(
+                select(counts.c.dag_id, 
func.count().label("cnt")).group_by(counts.c.dag_id)
+            ):
+                counts_by_dag[row.dag_id][state] = row.cnt
+
+    return DAGsRunStateCountsCollectionResponse(
+        dags=[
+            DAGRunStateCountsResponse(dag_id=dag_id, state_counts=counts)
+            for dag_id, counts in counts_by_dag.items()
+        ],
+        state_count_limit=STATE_COUNT_CAP,
+    )
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
index 69372a087a3..a34ca6b0c68 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -356,6 +356,12 @@ export const useDagServiceGetLatestRunInfoKey = 
"DagServiceGetLatestRunInfo";
 export const UseDagServiceGetLatestRunInfoKeyFn = ({ dagId }: {
   dagId: string;
 }, queryKey?: Array<unknown>) => [useDagServiceGetLatestRunInfoKey, 
...(queryKey ?? [{ dagId }])];
+export type DagServiceGetDagRunStateCountsUiDefaultResponse = 
Awaited<ReturnType<typeof DagService.getDagRunStateCountsUi>>;
+export type DagServiceGetDagRunStateCountsUiQueryResult<TData = 
DagServiceGetDagRunStateCountsUiDefaultResponse, TError = unknown> = 
UseQueryResult<TData, TError>;
+export const useDagServiceGetDagRunStateCountsUiKey = 
"DagServiceGetDagRunStateCountsUi";
+export const UseDagServiceGetDagRunStateCountsUiKeyFn = ({ dagIds }: {
+  dagIds: string[];
+}, queryKey?: Array<unknown>) => [useDagServiceGetDagRunStateCountsUiKey, 
...(queryKey ?? [{ dagIds }])];
 export type EventLogServiceGetEventLogDefaultResponse = 
Awaited<ReturnType<typeof EventLogService.getEventLog>>;
 export type EventLogServiceGetEventLogQueryResult<TData = 
EventLogServiceGetEventLogDefaultResponse, TError = unknown> = 
UseQueryResult<TData, TError>;
 export const useEventLogServiceGetEventLogKey = "EventLogServiceGetEventLog";
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 ff0174b77e9..c4c043c93b4 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -709,6 +709,17 @@ export const ensureUseDagServiceGetLatestRunInfoData = 
(queryClient: QueryClient
   dagId: string;
 }) => queryClient.ensureQueryData({ queryKey: 
Common.UseDagServiceGetLatestRunInfoKeyFn({ dagId }), queryFn: () => 
DagService.getLatestRunInfo({ dagId }) });
 /**
+* Get Dag Run State Counts
+* Return per-Dag DagRun state counts (zero-filled) for the Dag list page.
+* @param data The data for the request.
+* @param data.dagIds
+* @returns DAGsRunStateCountsCollectionResponse Successful Response
+* @throws ApiError
+*/
+export const ensureUseDagServiceGetDagRunStateCountsUiData = (queryClient: 
QueryClient, { dagIds }: {
+  dagIds: string[];
+}) => queryClient.ensureQueryData({ queryKey: 
Common.UseDagServiceGetDagRunStateCountsUiKeyFn({ dagIds }), queryFn: () => 
DagService.getDagRunStateCountsUi({ dagIds }) });
+/**
 * Get Event Log
 * @param data The data for the request.
 * @param data.eventLogId
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 1f8db278d9d..7aebe21755c 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -709,6 +709,17 @@ export const prefetchUseDagServiceGetLatestRunInfo = 
(queryClient: QueryClient,
   dagId: string;
 }) => queryClient.prefetchQuery({ queryKey: 
Common.UseDagServiceGetLatestRunInfoKeyFn({ dagId }), queryFn: () => 
DagService.getLatestRunInfo({ dagId }) });
 /**
+* Get Dag Run State Counts
+* Return per-Dag DagRun state counts (zero-filled) for the Dag list page.
+* @param data The data for the request.
+* @param data.dagIds
+* @returns DAGsRunStateCountsCollectionResponse Successful Response
+* @throws ApiError
+*/
+export const prefetchUseDagServiceGetDagRunStateCountsUi = (queryClient: 
QueryClient, { dagIds }: {
+  dagIds: string[];
+}) => queryClient.prefetchQuery({ queryKey: 
Common.UseDagServiceGetDagRunStateCountsUiKeyFn({ dagIds }), queryFn: () => 
DagService.getDagRunStateCountsUi({ dagIds }) });
+/**
 * Get Event Log
 * @param data The data for the request.
 * @param data.eventLogId
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 c5ec74825be..42eed251fa4 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -709,6 +709,17 @@ export const useDagServiceGetLatestRunInfo = <TData = 
Common.DagServiceGetLatest
   dagId: string;
 }, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetLatestRunInfoKeyFn({ dagId }, queryKey), queryFn: () => 
DagService.getLatestRunInfo({ dagId }) as TData, ...options });
 /**
+* Get Dag Run State Counts
+* Return per-Dag DagRun state counts (zero-filled) for the Dag list page.
+* @param data The data for the request.
+* @param data.dagIds
+* @returns DAGsRunStateCountsCollectionResponse Successful Response
+* @throws ApiError
+*/
+export const useDagServiceGetDagRunStateCountsUi = <TData = 
Common.DagServiceGetDagRunStateCountsUiDefaultResponse, TError = unknown, 
TQueryKey extends Array<unknown> = unknown[]>({ dagIds }: {
+  dagIds: string[];
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetDagRunStateCountsUiKeyFn({ dagIds }, queryKey), queryFn: 
() => DagService.getDagRunStateCountsUi({ dagIds }) as TData, ...options });
+/**
 * Get Event Log
 * @param data The data for the request.
 * @param data.eventLogId
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index 90f75e1cfb3..b3f2d6f4e9f 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -709,6 +709,17 @@ export const useDagServiceGetLatestRunInfoSuspense = 
<TData = Common.DagServiceG
   dagId: string;
 }, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetLatestRunInfoKeyFn({ dagId }, queryKey), queryFn: () => 
DagService.getLatestRunInfo({ dagId }) as TData, ...options });
 /**
+* Get Dag Run State Counts
+* Return per-Dag DagRun state counts (zero-filled) for the Dag list page.
+* @param data The data for the request.
+* @param data.dagIds
+* @returns DAGsRunStateCountsCollectionResponse Successful Response
+* @throws ApiError
+*/
+export const useDagServiceGetDagRunStateCountsUiSuspense = <TData = 
Common.DagServiceGetDagRunStateCountsUiDefaultResponse, TError = unknown, 
TQueryKey extends Array<unknown> = unknown[]>({ dagIds }: {
+  dagIds: string[];
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: 
Common.UseDagServiceGetDagRunStateCountsUiKeyFn({ dagIds }, queryKey), queryFn: 
() => DagService.getDagRunStateCountsUi({ dagIds }) as TData, ...options });
+/**
 * Get Event Log
 * @param data The data for the request.
 * @param data.eventLogId
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 08dbfa80a44..4dab4ff145c 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
@@ -8898,6 +8898,29 @@ export const $DAGRunLightResponse = {
     description: 'DAG Run serializer for responses.'
 } as const;
 
+export const $DAGRunStateCountsResponse = {
+    properties: {
+        dag_id: {
+            type: 'string',
+            title: 'Dag Id'
+        },
+        state_counts: {
+            additionalProperties: {
+                type: 'integer'
+            },
+            propertyNames: {
+                '$ref': '#/components/schemas/DagRunState'
+            },
+            type: 'object',
+            title: 'State Counts'
+        }
+    },
+    type: 'object',
+    required: ['dag_id', 'state_counts'],
+    title: 'DAGRunStateCountsResponse',
+    description: 'Per-Dag counts of DagRuns grouped by state.'
+} as const;
+
 export const $DAGRunStates = {
     properties: {
         queued: {
@@ -9248,6 +9271,26 @@ export const $DAGWithLatestDagRunsResponse = {
     description: 'DAG with latest dag runs response serializer.'
 } as const;
 
+export const $DAGsRunStateCountsCollectionResponse = {
+    properties: {
+        dags: {
+            items: {
+                '$ref': '#/components/schemas/DAGRunStateCountsResponse'
+            },
+            type: 'array',
+            title: 'Dags'
+        },
+        state_count_limit: {
+            type: 'integer',
+            title: 'State Count Limit'
+        }
+    },
+    type: 'object',
+    required: ['dags', 'state_count_limit'],
+    title: 'DAGsRunStateCountsCollectionResponse',
+    description: 'Collection of per-Dag DagRun-state counts for the Dag list 
page.'
+} as const;
+
 export const $DagRunStatsResponse = {
     properties: {
         duration: {
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index ebb30ffa68c..b44b9865237 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -3,7 +3,7 @@
 import type { CancelablePromise } from './core/CancelablePromise';
 import { OpenAPI } from './core/OpenAPI';
 import { request as __request } from './core/request';
-import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, 
GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, 
GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, 
CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, 
GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, 
DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, 
GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, 
Dele [...]
+import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, 
GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, 
GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, 
CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, 
GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, 
DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, 
GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, 
Dele [...]
 
 export class AssetService {
     /**
@@ -1987,6 +1987,27 @@ export class DagService {
         });
     }
     
+    /**
+     * Get Dag Run State Counts
+     * Return per-Dag DagRun state counts (zero-filled) for the Dag list page.
+     * @param data The data for the request.
+     * @param data.dagIds
+     * @returns DAGsRunStateCountsCollectionResponse Successful Response
+     * @throws ApiError
+     */
+    public static getDagRunStateCountsUi(data: GetDagRunStateCountsUiData): 
CancelablePromise<GetDagRunStateCountsUiResponse> {
+        return __request(OpenAPI, {
+            method: 'GET',
+            url: '/ui/dags/run_state_counts',
+            query: {
+                dag_ids: data.dagIds
+            },
+            errors: {
+                422: 'Validation Error'
+            }
+        });
+    }
+    
 }
 
 export class EventLogService {
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 a9943390b27..a9d002e1e3f 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
@@ -2271,6 +2271,16 @@ export type DAGRunLightResponse = {
     readonly duration: number | null;
 };
 
+/**
+ * Per-Dag counts of DagRuns grouped by state.
+ */
+export type DAGRunStateCountsResponse = {
+    dag_id: string;
+    state_counts: {
+        [key: string]: (number);
+    };
+};
+
 /**
  * DAG Run States for responses.
  */
@@ -2335,6 +2345,14 @@ export type DAGWithLatestDagRunsResponse = {
     readonly file_token: string;
 };
 
+/**
+ * Collection of per-Dag DagRun-state counts for the Dag list page.
+ */
+export type DAGsRunStateCountsCollectionResponse = {
+    dags: Array<DAGRunStateCountsResponse>;
+    state_count_limit: number;
+};
+
 /**
  * DAG Run statistics serializer for responses.
  */
@@ -3483,6 +3501,12 @@ export type GetLatestRunInfoData = {
 
 export type GetLatestRunInfoResponse = DAGRunLightResponse | null;
 
+export type GetDagRunStateCountsUiData = {
+    dagIds: Array<(string)>;
+};
+
+export type GetDagRunStateCountsUiResponse = 
DAGsRunStateCountsCollectionResponse;
+
 export type GetEventLogData = {
     eventLogId: number;
 };
@@ -6341,6 +6365,21 @@ export type $OpenApiTs = {
             };
         };
     };
+    '/ui/dags/run_state_counts': {
+        get: {
+            req: GetDagRunStateCountsUiData;
+            res: {
+                /**
+                 * Successful Response
+                 */
+                200: DAGsRunStateCountsCollectionResponse;
+                /**
+                 * Validation Error
+                 */
+                422: HTTPValidationError;
+            };
+        };
+    };
     '/api/v2/eventLogs/{event_log_id}': {
         get: {
             req: GetEventLogData;
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
index 30fefed93f5..990d6c7848f 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
@@ -76,6 +76,11 @@
       "upstream": "Upstream"
     }
   },
+  "runStateCounts": {
+    "label": "Run states",
+    "loading": "Loading run state counts",
+    "tooltip": "{{state}}: {{formattedCount}} — click to filter runs"
+  },
   "search": {
     "advanced": "Advanced Search",
     "clear": "Clear search",
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx
index 14532199258..ad163ae19f1 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx
@@ -53,6 +53,15 @@ const GMTWrapper = ({ children }: PropsWithChildren) => (
   </BaseWrapper>
 );
 
+// Render with the run-state-counts row in its loading state so it renders
+// skeletons rather than StateBadges. Without this, every card would emit
+// 4 extra "state-badge" testids and break getByTestId assertions in tests
+// that target the latest-run badge.
+const renderCard = (dag: DAGWithLatestDagRunsResponse) =>
+  render(<DagCard dag={dag} runStateCounts={undefined} runStateCountsLoading 
stateCountLimit={undefined} />, {
+    wrapper: GMTWrapper,
+  });
+
 const mockDag = {
   allowed_run_types: null,
   asset_expression: null,
@@ -136,7 +145,7 @@ afterEach(() => {
 
 describe("DagCard", () => {
   it("DagCard should render without tags", () => {
-    render(<DagCard dag={mockDag} />, { wrapper: GMTWrapper });
+    renderCard(mockDag);
     expect(screen.getByText(mockDag.dag_display_name)).toBeInTheDocument();
     expect(screen.queryByTestId("dag-tag")).toBeNull();
   });
@@ -154,7 +163,7 @@ describe("DagCard", () => {
       tags,
     } satisfies DAGWithLatestDagRunsResponse;
 
-    render(<DagCard dag={expandedMockDag} />, { wrapper: GMTWrapper });
+    renderCard(expandedMockDag);
     expect(screen.getByTestId("dag-id")).toBeInTheDocument();
     expect(screen.getByTestId("dag-tag")).toBeInTheDocument();
     expect(screen.queryByText("tag3")).toBeInTheDocument();
@@ -176,14 +185,14 @@ describe("DagCard", () => {
       tags,
     } satisfies DAGWithLatestDagRunsResponse;
 
-    render(<DagCard dag={expandedMockDag} />, { wrapper: GMTWrapper });
+    renderCard(expandedMockDag);
     expect(screen.getByTestId("dag-id")).toBeInTheDocument();
     expect(screen.getByTestId("dag-tag")).toBeInTheDocument();
     expect(screen.getByText("+2 more")).toBeInTheDocument();
   });
 
   it("DagCard should render schedule section", () => {
-    render(<DagCard dag={mockDag} />, { wrapper: GMTWrapper });
+    renderCard(mockDag);
     const scheduleElement = screen.getByTestId("schedule");
 
     expect(scheduleElement).toBeInTheDocument();
@@ -192,7 +201,7 @@ describe("DagCard", () => {
   });
 
   it("DagCard should render latest run section with actual run data", () => {
-    render(<DagCard dag={mockDag} />, { wrapper: GMTWrapper });
+    renderCard(mockDag);
     const latestRunElement = screen.getByTestId("latest-run");
 
     expect(latestRunElement).toBeInTheDocument();
@@ -201,7 +210,7 @@ describe("DagCard", () => {
   });
 
   it("DagCard should render next run section with timestamp", () => {
-    render(<DagCard dag={mockDag} />, { wrapper: GMTWrapper });
+    renderCard(mockDag);
     const nextRunElement = screen.getByTestId("next-run");
 
     expect(nextRunElement).toBeInTheDocument();
@@ -210,7 +219,7 @@ describe("DagCard", () => {
   });
 
   it("DagCard should not render next run timestamp for a paused Dag", () => {
-    render(<DagCard dag={{ ...mockDag, is_paused: true }} />, { wrapper: 
GMTWrapper });
+    renderCard({ ...mockDag, is_paused: true });
     const nextRunElement = screen.getByTestId("next-run");
 
     expect(nextRunElement).toBeInTheDocument();
@@ -218,7 +227,7 @@ describe("DagCard", () => {
   });
 
   it("DagCard should render StateBadge as success", () => {
-    render(<DagCard dag={mockDag} />, { wrapper: GMTWrapper });
+    renderCard(mockDag);
     const stateBadge = screen.getByTestId("state-badge");
 
     expect(stateBadge).toBeInTheDocument();
@@ -243,7 +252,7 @@ describe("DagCard", () => {
       ],
     } satisfies DAGWithLatestDagRunsResponse;
 
-    render(<DagCard dag={mockDagWithFailedRun} />, { wrapper: GMTWrapper });
+    renderCard(mockDagWithFailedRun);
     const stateBadge = screen.getByTestId("state-badge");
 
     expect(stateBadge).toBeInTheDocument();
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
index 9eef74269d6..cd320d417d0 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, Flex, HStack, SimpleGrid, Spinner } from "@chakra-ui/react";
+import { Box, Flex, Grid, GridItem, HStack, Spinner } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 
 import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
@@ -30,15 +30,19 @@ import { TriggerDAGButton } from 
"src/components/TriggerDag/TriggerDAGButton";
 import { RouterLink, Tooltip } from "src/components/ui";
 import { isStatePending, useAutoRefresh } from "src/utils";
 
+import { DagRunStateCounts } from "./DagRunStateCounts";
 import { DagTags } from "./DagTags";
 import { RecentRuns } from "./RecentRuns";
 import { Schedule } from "./Schedule";
 
 type Props = {
   readonly dag: DAGWithLatestDagRunsResponse;
+  readonly runStateCounts: Record<string, number> | undefined;
+  readonly runStateCountsLoading: boolean;
+  readonly stateCountLimit: number | undefined;
 };
 
-export const DagCard = ({ dag }: Props) => {
+export const DagCard = ({ dag, runStateCounts, runStateCountsLoading, 
stateCountLimit }: Props) => {
   const { t: translate } = useTranslation(["common", "dag"]);
   const [latestRun] = dag.latest_dag_runs;
 
@@ -74,42 +78,64 @@ export const DagCard = ({ dag }: Props) => {
           <DeleteDagButton dagDisplayName={dag.dag_display_name} 
dagId={dag.dag_id} />
         </HStack>
       </Flex>
-      <SimpleGrid columns={4} gap={1} height={20} px={3} py={1}>
-        <Stat data-testid="schedule" label={translate("dagDetails.schedule")}>
-          <Schedule
-            assetExpression={dag.asset_expression}
-            dagId={dag.dag_id}
-            timetableDescription={dag.timetable_description}
-            timetablePartitioned={dag.timetable_partitioned}
-            timetableSummary={dag.timetable_summary}
-          />
-        </Stat>
-        <Stat data-testid="latest-run" 
label={translate("dagDetails.latestRun")}>
-          {latestRun ? (
-            <RouterLink 
to={`/dags/${latestRun.dag_id}/runs/${latestRun.run_id}`}>
+      <Grid gap={1} px={3} py={2} templateColumns="repeat(4, 1fr)" 
templateRows="auto auto">
+        <GridItem gridColumn={1} gridRow={1}>
+          <Stat data-testid="schedule" 
label={translate("dagDetails.schedule")}>
+            <Schedule
+              assetExpression={dag.asset_expression}
+              dagId={dag.dag_id}
+              timetableDescription={dag.timetable_description}
+              timetablePartitioned={dag.timetable_partitioned}
+              timetableSummary={dag.timetable_summary}
+            />
+          </Stat>
+        </GridItem>
+        <GridItem gridColumn={2} gridRow={1}>
+          <Stat data-testid="latest-run" 
label={translate("dagDetails.latestRun")}>
+            {latestRun ? (
+              <RouterLink 
to={`/dags/${latestRun.dag_id}/runs/${latestRun.run_id}`}>
+                <DagRunInfo
+                  endDate={latestRun.end_date}
+                  logicalDate={latestRun.logical_date}
+                  runAfter={latestRun.run_after}
+                  startDate={latestRun.start_date}
+                  state={latestRun.state}
+                />
+                {isStatePending(latestRun.state) && !dag.is_paused && 
Boolean(refetchInterval) ? (
+                  <Spinner />
+                ) : undefined}
+              </RouterLink>
+            ) : undefined}
+          </Stat>
+        </GridItem>
+        <GridItem gridColumn={3} gridRow={1}>
+          <Stat data-testid="next-run" label={translate("dagDetails.nextRun")}>
+            {!dag.is_paused && Boolean(dag.next_dagrun_run_after) ? (
               <DagRunInfo
-                endDate={latestRun.end_date}
-                logicalDate={latestRun.logical_date}
-                runAfter={latestRun.run_after}
-                startDate={latestRun.start_date}
-                state={latestRun.state}
+                logicalDate={dag.next_dagrun_logical_date}
+                runAfter={dag.next_dagrun_run_after as string}
               />
-              {isStatePending(latestRun.state) && !dag.is_paused && 
Boolean(refetchInterval) ? (
-                <Spinner />
-              ) : undefined}
-            </RouterLink>
-          ) : undefined}
-        </Stat>
-        <Stat data-testid="next-run" label={translate("dagDetails.nextRun")}>
-          {!dag.is_paused && Boolean(dag.next_dagrun_run_after) ? (
-            <DagRunInfo
-              logicalDate={dag.next_dagrun_logical_date}
-              runAfter={dag.next_dagrun_run_after as string}
-            />
-          ) : undefined}
-        </Stat>
-        <RecentRuns latestRuns={dag.latest_dag_runs} />
-      </SimpleGrid>
+            ) : undefined}
+          </Stat>
+        </GridItem>
+        <GridItem
+          alignItems="flex-end"
+          display="flex"
+          gridColumn={4}
+          gridRow="1 / 3"
+          justifyContent="flex-end"
+        >
+          <RecentRuns latestRuns={dag.latest_dag_runs} />
+        </GridItem>
+        <GridItem alignSelf="end" gridColumn={1} gridRow={2}>
+          <DagRunStateCounts
+            counts={runStateCounts}
+            dagId={dag.dag_id}
+            isLoading={runStateCountsLoading}
+            stateCountLimit={stateCountLimit}
+          />
+        </GridItem>
+      </Grid>
     </Box>
   );
 };
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagRunStateCounts.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagRunStateCounts.test.tsx
new file mode 100644
index 00000000000..906b0b80937
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagRunStateCounts.test.tsx
@@ -0,0 +1,100 @@
+/*!
+ * 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/vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { describe, expect, it } from "vitest";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import "../../i18n/config";
+import { DagRunStateCounts } from "./DagRunStateCounts";
+
+const renderCounts = (
+  counts: Record<string, number> | undefined,
+  options: { isLoading?: boolean; stateCountLimit?: number } = {},
+) =>
+  render(
+    <DagRunStateCounts
+      counts={counts}
+      dagId="my_dag"
+      isLoading={options.isLoading ?? false}
+      stateCountLimit={options.stateCountLimit}
+    />,
+    {
+      wrapper: ({ children }) => (
+        <BaseWrapper>
+          <MemoryRouter>{children}</MemoryRouter>
+        </BaseWrapper>
+      ),
+    },
+  );
+
+describe("DagRunStateCounts", () => {
+  it("renders skeleton placeholders while loading", () => {
+    renderCounts(undefined, { isLoading: true });
+    
expect(screen.getByTestId("run-state-counts-loading-my_dag")).toBeInTheDocument();
+    expect(screen.queryByTestId("run-state-counts-my_dag")).toBeNull();
+  });
+
+  it("renders one clickable badge per state with counts", () => {
+    renderCounts({ failed: 3, queued: 0, running: 1, success: 42 });
+    expect(screen.getByTestId("run-state-counts-my_dag")).toBeInTheDocument();
+
+    const successLink = screen.getByTestId("run-state-count-success-my_dag");
+    const failedLink = screen.getByTestId("run-state-count-failed-my_dag");
+    const runningLink = screen.getByTestId("run-state-count-running-my_dag");
+    const queuedLink = screen.getByTestId("run-state-count-queued-my_dag");
+
+    expect(successLink).toHaveAttribute("href", 
"/dags/my_dag/runs?state=success");
+    expect(failedLink).toHaveAttribute("href", 
"/dags/my_dag/runs?state=failed");
+    expect(runningLink).toHaveAttribute("href", 
"/dags/my_dag/runs?state=running");
+    expect(queuedLink).toHaveAttribute("href", 
"/dags/my_dag/runs?state=queued");
+
+    expect(successLink).toHaveTextContent("42");
+    expect(failedLink).toHaveTextContent("3");
+    expect(runningLink).toHaveTextContent("1");
+    expect(queuedLink).toHaveTextContent("0");
+  });
+
+  it("dims zero-count badges but keeps them clickable", () => {
+    renderCounts({ failed: 0, queued: 0, running: 0, success: 5 });
+    const queuedLink = screen.getByTestId("run-state-count-queued-my_dag");
+    // The badge is the link's child Chakra Badge; check style is dimmed.
+    const badge = queuedLink.querySelector('[data-testid="state-badge"]');
+
+    expect(badge).not.toBeNull();
+    expect(badge).toHaveStyle({ opacity: "0.4" });
+    // Still a real link so operators can confirm "no failures" by clicking 
through.
+    expect(queuedLink).toHaveAttribute("href", 
"/dags/my_dag/runs?state=queued");
+  });
+
+  it("treats missing keys in `counts` as zero", () => {
+    renderCounts({ failed: 2 });
+    
expect(screen.getByTestId("run-state-count-success-my_dag")).toHaveTextContent("0");
+    
expect(screen.getByTestId("run-state-count-failed-my_dag")).toHaveTextContent("2");
+  });
+
+  it("suffixes counts that reached the cap with '+'", () => {
+    renderCounts({ failed: 5, queued: 0, running: 1, success: 1000 }, { 
stateCountLimit: 1000 });
+    
expect(screen.getByTestId("run-state-count-success-my_dag")).toHaveTextContent("1000+");
+    
expect(screen.getByTestId("run-state-count-failed-my_dag")).toHaveTextContent("5");
+    
expect(screen.getByTestId("run-state-count-running-my_dag")).toHaveTextContent("1");
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagRunStateCounts.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagRunStateCounts.tsx
new file mode 100644
index 00000000000..4a238795ce1
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagRunStateCounts.tsx
@@ -0,0 +1,87 @@
+/*!
+ * 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 { HStack, Skeleton } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import type { DagRunState } from "openapi/requests/types.gen";
+import { StateBadge } from "src/components/StateBadge";
+import { RouterLink, Tooltip } from "src/components/ui";
+import { SearchParamsKeys } from "src/constants/searchParams";
+
+const DISPLAYED_STATES: ReadonlyArray<DagRunState> = ["success", "failed", 
"running", "queued"];
+
+type Props = {
+  readonly compact?: boolean;
+  readonly counts: Record<string, number> | undefined;
+  readonly dagId: string;
+  readonly isLoading: boolean;
+  readonly stateCountLimit: number | undefined;
+};
+
+export const DagRunStateCounts = ({ compact = false, counts, dagId, isLoading, 
stateCountLimit }: Props) => {
+  const { t: translate } = useTranslation(["dags", "common"]);
+  const gap = compact ? 0.5 : 1;
+  const fontSize = compact ? "xs" : "sm";
+
+  if (isLoading || counts === undefined) {
+    // Render skeletons sized to a typical badge so the row doesn't reflow when
+    // counts arrive. Rendering zeros while loading would mislead the operator.
+    return (
+      <HStack
+        aria-label={translate("runStateCounts.loading")}
+        data-testid={`run-state-counts-loading-${dagId}`}
+        gap={gap}
+      >
+        {DISPLAYED_STATES.map((state) => (
+          <Skeleton borderRadius="full" height="22px" key={state} width="44px" 
/>
+        ))}
+      </HStack>
+    );
+  }
+
+  return (
+    <HStack data-testid={`run-state-counts-${dagId}`} gap={gap}>
+      {DISPLAYED_STATES.map((state) => {
+        const count = counts[state] ?? 0;
+        // A count that reached the API cap is only a lower bound; suffix it 
with "+".
+        const isCapped = stateCountLimit !== undefined && count >= 
stateCountLimit;
+        const suffix = isCapped ? "+" : "";
+        const translatedState = translate(`common:states.${state}` as const);
+        const tooltipContent = translate("runStateCounts.tooltip", {
+          formattedCount: `${count}${suffix}`,
+          state: translatedState,
+        });
+
+        return (
+          <Tooltip content={tooltipContent} key={state}>
+            <RouterLink
+              aria-label={tooltipContent}
+              data-testid={`run-state-count-${state}-${dagId}`}
+              to={`/dags/${dagId}/runs?${SearchParamsKeys.STATE}=${state}`}
+            >
+              <StateBadge fontSize={fontSize} opacity={count === 0 ? 0.4 : 1} 
state={state}>
+                {`${count}${suffix}`}
+              </StateBadge>
+            </RouterLink>
+          </Tooltip>
+        );
+      })}
+    </HStack>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
index eaedd94406a..12e08fdb51b 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -40,18 +40,27 @@ import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searc
 import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
 import { DagsLayout } from "src/layouts/DagsLayout";
 import { useConfig } from "src/queries/useConfig";
+import { useDagRunStateCounts } from "src/queries/useDagRunStateCounts";
 import { useDags } from "src/queries/useDags";
 
 import { DagImportErrors } from "../Dashboard/Stats/DagImportErrors";
 import { DagCard } from "./DagCard";
+import { DagRunStateCounts } from "./DagRunStateCounts";
 import { DagTags } from "./DagTags";
 import { DagsFilters } from "./DagsFilters";
 import { Schedule } from "./Schedule";
 import { SortSelect } from "./SortSelect";
 import { useTagFilter } from "./useTagFilter";
 
+type RunStateCountsContext = {
+  readonly countsByDag: Record<string, Record<string, number> | undefined>;
+  readonly isLoading: boolean;
+  readonly stateCountLimit: number | undefined;
+};
+
 const createColumns = (
   translate: (key: string, options?: Record<string, unknown>) => string,
+  runStateContext: RunStateCountsContext,
 ): Array<ColumnDef<DAGWithLatestDagRunsResponse>> => [
   {
     accessorKey: "is_paused",
@@ -121,6 +130,20 @@ const createColumns = (
       ) : undefined,
     header: () => translate("dagDetails.latestRun"),
   },
+  {
+    accessorKey: "run_state_counts",
+    cell: ({ row: { original } }) => (
+      <DagRunStateCounts
+        compact
+        counts={runStateContext.countsByDag[original.dag_id]}
+        dagId={original.dag_id}
+        isLoading={runStateContext.isLoading}
+        stateCountLimit={runStateContext.stateCountLimit}
+      />
+    ),
+    enableSorting: false,
+    header: () => translate("dags:runStateCounts.label"),
+  },
   {
     accessorKey: "tags",
     cell: ({
@@ -179,12 +202,19 @@ const {
   PAUSED,
 }: SearchParamsKeysType = SearchParamsKeys;
 
-const cardDef: CardDef<DAGWithLatestDagRunsResponse> = {
-  card: ({ row }) => <DagCard dag={row} />,
+const createCardDef = (runStateContext: RunStateCountsContext): 
CardDef<DAGWithLatestDagRunsResponse> => ({
+  card: ({ row }) => (
+    <DagCard
+      dag={row}
+      runStateCounts={runStateContext.countsByDag[row.dag_id]}
+      runStateCountsLoading={runStateContext.isLoading}
+      stateCountLimit={runStateContext.stateCountLimit}
+    />
+  ),
   meta: {
-    customSkeleton: <Skeleton height="120px" width="100%" />,
+    customSkeleton: <Skeleton height="140px" width="100%" />,
   },
-};
+});
 
 export const DagsList = () => {
   const { t: translate } = useTranslation();
@@ -212,8 +242,6 @@ export const DagsList = () => {
   const [sort] = sorting;
   const orderBy = sort ? `${sort.desc ? "-" : ""}${sort.id}` : 
"dag_display_name";
 
-  const columns = createColumns(translate);
-
   const handleSearchChange = (value: string) => {
     setTableURLState({
       pagination: { ...pagination, pageIndex: 0 },
@@ -268,6 +296,21 @@ export const DagsList = () => {
     tagsMatchMode: selectedMatchMode,
   });
 
+  const { data: runStateCountsData, isLoading: runStateCountsLoading } = 
useDagRunStateCounts({
+    dagIds: data?.dags.map((dag) => dag.dag_id) ?? [],
+    dags: data?.dags,
+  });
+  const runStateContext: RunStateCountsContext = {
+    countsByDag: Object.fromEntries(
+      (runStateCountsData?.dags ?? []).map((entry) => [entry.dag_id, 
entry.state_counts]),
+    ),
+    isLoading: runStateCountsLoading,
+    stateCountLimit: runStateCountsData?.state_count_limit,
+  };
+
+  const columns = createColumns(translate, runStateContext);
+  const cardDef = createCardDef(runStateContext);
+
   const handleSortChange = ({ value }: 
SelectValueChangeDetails<Array<string>>) => {
     setTableURLState({
       pagination,
@@ -297,9 +340,11 @@ export const DagsList = () => {
             </Heading>
             <DagImportErrors iconOnly />
           </HStack>
-          {display === "card" ? (
-            <SortSelect handleSortChange={handleSortChange} orderBy={orderBy} 
/>
-          ) : undefined}
+          <HStack>
+            {display === "card" ? (
+              <SortSelect handleSortChange={handleSortChange} 
orderBy={orderBy} />
+            ) : undefined}
+          </HStack>
         </HStack>
       </VStack>
       <Box pb={8}>
diff --git a/airflow-core/src/airflow/ui/src/queries/useDagRunStateCounts.tsx 
b/airflow-core/src/airflow/ui/src/queries/useDagRunStateCounts.tsx
new file mode 100644
index 00000000000..4e152fecfec
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useDagRunStateCounts.tsx
@@ -0,0 +1,45 @@
+/*!
+ * 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 { useDagServiceGetDagRunStateCountsUi } from "openapi/queries";
+import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
+import { isStatePending, useAutoRefresh } from "src/utils";
+
+export const useDagRunStateCounts = ({
+  dagIds,
+  dags,
+}: {
+  readonly dagIds: ReadonlyArray<string>;
+  // Refresh predicate is derived from useDags' data so the counts query 
doesn't
+  // need to be loaded before it knows whether to poll — avoids a 
chicken-and-egg.
+  readonly dags: ReadonlyArray<DAGWithLatestDagRunsResponse> | undefined;
+}) => {
+  const refetchInterval = useAutoRefresh({});
+  const hasPendingRun =
+    dags?.some((dag) => !dag.is_paused && dag.latest_dag_runs.some((run) => 
isStatePending(run.state))) ??
+    false;
+
+  // Stable key: sort the dag_ids so pagination/sort order changes don't churn 
the cache.
+  const sortedDagIds = [...dagIds].sort();
+
+  return useDagServiceGetDagRunStateCountsUi({ dagIds: sortedDagIds }, 
undefined, {
+    enabled: sortedDagIds.length > 0,
+    placeholderData: (prev) => prev,
+    refetchInterval: hasPendingRun ? refetchInterval : false,
+  });
+};
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
index bb94168bd57..50444e04914 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
@@ -26,6 +26,7 @@ from sqlalchemy.orm import Session
 
 from airflow.api_fastapi.auth.managers.models.resource_details import 
DagAccessEntity
 from airflow.api_fastapi.auth.managers.simple.simple_auth_manager import 
SimpleAuthManager
+from airflow.configuration import conf
 from airflow.models import DagRun
 from airflow.models.dag import DagModel, DagTag
 from airflow.models.dag_favorite import DagFavorite
@@ -418,3 +419,145 @@ class TestGetDagRuns(TestPublicDagEndpoint):
         # Verify that DAG1 is not marked as favorite for the test user
         dag1_data = next(dag for dag in body["dags"] if dag["dag_id"] == 
DAG1_ID)
         assert dag1_data["is_favorite"] is False
+
+
+# Rows seeded by the parent ``TestPublicDagEndpoint.setup`` fixture. Tracked 
here
+# so expected counts below stay derivable if the parent fixture is ever 
changed.
+_PARENT_DAG1_FAILED = 1  # via ``dag_maker.create_dagrun(state=FAILED)``
+_PARENT_DAG3_FAILED = 1  # via ``_create_deactivated_paused_dag``
+_PARENT_DAG3_SUCCESS = 1  # via ``_create_deactivated_paused_dag``
+
+
+class TestGetDagRunStateCounts(TestPublicDagEndpoint):
+    """Tests for ``GET /ui/dags/run_state_counts``."""
+
+    @pytest.fixture(autouse=True)
+    def seed_dag_runs(self, setup, session) -> None:
+        # DAG1 gets runs in every state. DAG2 gets only SUCCESS/FAILED.
+        # DAG3 is left to the parent fixture.
+        base = utcnow() - pendulum.duration(days=10)
+        for dag_id, scheme in (
+            (
+                DAG1_ID,
+                [
+                    DagRunState.SUCCESS,
+                    DagRunState.SUCCESS,
+                    DagRunState.FAILED,
+                    DagRunState.FAILED,
+                    DagRunState.RUNNING,
+                    DagRunState.QUEUED,
+                ],
+            ),
+            (DAG2_ID, [DagRunState.SUCCESS, DagRunState.FAILED]),
+        ):
+            for idx, state in enumerate(scheme):
+                # Offset by idx seconds so the (dag_id, logical_date) and
+                # (dag_id, run_after) uniqueness constraints both hold.
+                run_after = base + pendulum.duration(seconds=idx)
+                session.add(
+                    DagRun(
+                        dag_id=dag_id,
+                        run_id=f"{dag_id}_state_run_{idx}",
+                        run_type=DagRunType.MANUAL,
+                        start_date=run_after,
+                        logical_date=run_after,
+                        run_after=run_after,
+                        state=state,
+                        triggered_by=DagRunTriggeredByType.TEST,
+                    )
+                )
+        session.commit()
+
+    @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+    def test_returns_zero_filled_counts_per_requested_dag(self, test_client):
+        response = test_client.get(
+            "/dags/run_state_counts",
+            params={"dag_ids": [DAG1_ID, DAG3_ID]},
+        )
+        assert response.status_code == 200
+        counts = {entry["dag_id"]: entry["state_counts"] for entry in 
response.json()["dags"]}
+        # DAG1: 2 SUCCESS + 2 FAILED + 1 RUNNING + 1 QUEUED locally + parent 
seeds.
+        assert counts[DAG1_ID] == {
+            "success": 2,
+            "failed": 2 + _PARENT_DAG1_FAILED,
+            "running": 1,
+            "queued": 1,
+        }
+        # DAG3: only parent seeds (this fixture skips DAG3).
+        assert counts[DAG3_ID] == {
+            "success": _PARENT_DAG3_SUCCESS,
+            "failed": _PARENT_DAG3_FAILED,
+            "running": 0,
+            "queued": 0,
+        }
+
+    def test_deduplicates_dag_ids(self, test_client):
+        response = test_client.get(
+            "/dags/run_state_counts",
+            params={"dag_ids": [DAG1_ID, DAG1_ID]},
+        )
+        assert response.status_code == 200
+        dag_ids = [entry["dag_id"] for entry in response.json()["dags"]]
+        assert dag_ids == [DAG1_ID]
+
+    def test_rejects_too_many_dag_ids(self, test_client):
+        # The page never sends more than maximum_page_limit dag_ids; a direct 
call with a
+        # larger list is rejected so the per-Dag UNION ALL width stays bounded.
+        too_many = [f"dag_{idx}" for idx in range(conf.getint("api", 
"maximum_page_limit") + 1)]
+        response = test_client.get("/dags/run_state_counts", 
params={"dag_ids": too_many})
+        assert response.status_code == 422
+
+    @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+    def test_permission_filter_hides_disallowed_dags(self, test_client, 
session):
+        # The test user is granted read on DAG1, DAG2, DAG4, DAG5 (DAG3 is 
paused/stale in
+        # the parent fixture). Asking for a dag that exists but the caller 
cannot read
+        # should silently drop it from the result.
+        with mock.patch.object(
+            SimpleAuthManager,
+            "get_authorized_dag_ids",
+            return_value={DAG1_ID},
+        ):
+            response = test_client.get(
+                "/dags/run_state_counts",
+                params={"dag_ids": [DAG1_ID, DAG2_ID]},
+            )
+        assert response.status_code == 200
+        dag_ids = [entry["dag_id"] for entry in response.json()["dags"]]
+        assert dag_ids == [DAG1_ID]
+
+    @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+    @mock.patch("airflow.api_fastapi.core_api.routes.ui.dags.STATE_COUNT_CAP", 
3)
+    def test_caps_counts_at_state_count_cap(self, test_client, session):
+        # Push SUCCESS over the (patched) cap while FAILED stays under it: 
states cap independently.
+        base = utcnow() - pendulum.duration(days=5)
+        for idx in range(5):
+            run_after = base + pendulum.duration(seconds=idx)
+            session.add(
+                DagRun(
+                    dag_id=DAG2_ID,
+                    run_id=f"{DAG2_ID}_cap_run_{idx}",
+                    run_type=DagRunType.MANUAL,
+                    start_date=run_after,
+                    logical_date=run_after,
+                    run_after=run_after,
+                    state=DagRunState.SUCCESS,
+                    triggered_by=DagRunTriggeredByType.TEST,
+                )
+            )
+        session.commit()
+
+        response = test_client.get("/dags/run_state_counts", 
params={"dag_ids": [DAG2_ID]})
+        assert response.status_code == 200
+        body = response.json()
+        assert body["state_count_limit"] == 3
+        counts = {entry["dag_id"]: entry["state_counts"] for entry in 
body["dags"]}
+        assert counts[DAG2_ID]["success"] == 3
+        assert counts[DAG2_ID]["failed"] == 1
+
+    def test_should_response_401(self, unauthenticated_test_client):
+        response = unauthenticated_test_client.get("/dags/run_state_counts", 
params={"dag_ids": [DAG1_ID]})
+        assert response.status_code == 401
+
+    def test_should_response_403(self, unauthorized_test_client):
+        response = unauthorized_test_client.get("/dags/run_state_counts", 
params={"dag_ids": [DAG1_ID]})
+        assert response.status_code == 403

Reply via email to