This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 9198acb15e8 UI: Add team filter to Human-in-the-loop task instances
listing (#73726)
9198acb15e8 is described below
commit 9198acb15e8075e89414da81c955cfcbb5c479c7
Author: Vincent <[email protected]>
AuthorDate: Tue Sep 29 14:12:38 2026 -0400
UI: Add team filter to Human-in-the-loop task instances listing (#73726)
In multi-team deployments the Required Actions page could not be scoped by
team, so operators had no way to focus on the required actions their team
owns. Expose a team filter on the listing so required actions can be
narrowed to one or more owning teams.
---
.../core_api/openapi/v2-rest-api-generated.yaml | 8 +++
.../api_fastapi/core_api/routes/public/hitl.py | 4 ++
.../src/airflow/ui/openapi-gen/queries/common.ts | 5 +-
.../ui/openapi-gen/queries/ensureQueryData.ts | 6 ++-
.../src/airflow/ui/openapi-gen/queries/prefetch.ts | 6 ++-
.../src/airflow/ui/openapi-gen/queries/queries.ts | 6 ++-
.../src/airflow/ui/openapi-gen/queries/suspense.ts | 6 ++-
.../ui/openapi-gen/requests/services.gen.ts | 2 +
.../airflow/ui/openapi-gen/requests/types.gen.ts | 1 +
.../ui/src/pages/HITLTaskInstances/HITLFilters.tsx | 6 +++
.../HITLTaskInstances/HITLTaskInstances.test.tsx | 62 +++++++++++++++++++++-
.../pages/HITLTaskInstances/HITLTaskInstances.tsx | 21 ++++++++
.../core_api/routes/public/test_hitl.py | 48 ++++++++++++++++-
13 files changed, 169 insertions(+), 12 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 22f1afe6903..1ca26d0f9c2 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -11092,6 +11092,14 @@ paths:
items:
type: string
title: State
+ - name: teams
+ in: query
+ required: false
+ schema:
+ type: array
+ items:
+ type: string
+ title: Teams
- name: response_received
in: query
required: false
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/hitl.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/hitl.py
index a77baedd9a1..25862ab527b 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/hitl.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/hitl.py
@@ -43,7 +43,9 @@ from airflow.api_fastapi.common.parameters import (
QueryTIStateFilter,
RangeFilter,
SortParam,
+ _DagIdTeamsFilter,
datetime_range_filter_factory,
+ teams_filter_factory,
)
from airflow.api_fastapi.common.router import AirflowRouter
from airflow.api_fastapi.core_api.datamodels.hitl import (
@@ -344,6 +346,7 @@ def get_hitl_details(
task_id_prefix_pattern: QueryHITLDetailTaskIdPrefixPatternSearch,
map_index: QueryHITLDetailMapIndexFilter,
ti_state: QueryTIStateFilter,
+ teams: Annotated[_DagIdTeamsFilter,
Depends(teams_filter_factory(TI.dag_id))],
# hitl detail related filter
response_received: QueryHITLDetailResponseReceivedFilter,
responded_by_user_id: QueryHITLDetailRespondedUserIdFilter,
@@ -383,6 +386,7 @@ def get_hitl_details(
task_id_prefix_pattern,
map_index,
ti_state,
+ teams,
# hitl detail related filter
response_received,
responded_by_user_id,
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 e1de3ccdd0d..cd883bd0d9d 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -742,7 +742,7 @@ export const
UseTaskInstanceServiceGetHitlDetailTryDetailKeyFn = ({ dagId, dagRu
export type TaskInstanceServiceGetHitlDetailsDefaultResponse =
Awaited<ReturnType<typeof TaskInstanceService.getHitlDetails>>;
export type TaskInstanceServiceGetHitlDetailsQueryResult<TData =
TaskInstanceServiceGetHitlDetailsDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
export const useTaskInstanceServiceGetHitlDetailsKey =
"TaskInstanceServiceGetHitlDetails";
-export const UseTaskInstanceServiceGetHitlDetailsKeyFn = ({ bodySearch,
createdAtGt, createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern }: {
+export const UseTaskInstanceServiceGetHitlDetailsKeyFn = ({ bodySearch,
createdAtGt, createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern, teams }: {
bodySearch?: string;
createdAtGt?: string;
createdAtGte?: string;
@@ -764,7 +764,8 @@ export const UseTaskInstanceServiceGetHitlDetailsKeyFn = ({
bodySearch, createdA
taskId?: string;
taskIdPattern?: string;
taskIdPrefixPattern?: string;
-}, queryKey?: Array<unknown>) => [useTaskInstanceServiceGetHitlDetailsKey,
...(queryKey ?? [{ bodySearch, createdAtGt, createdAtGte, createdAtLt,
createdAtLte, dagId, dagIdPattern, dagIdPrefixPattern, dagRunId, limit,
mapIndex, offset, orderBy, respondedByUserId, respondedByUserName,
responseReceived, state, subjectSearch, taskId, taskIdPattern,
taskIdPrefixPattern }])];
+ teams?: string[];
+}, queryKey?: Array<unknown>) => [useTaskInstanceServiceGetHitlDetailsKey,
...(queryKey ?? [{ bodySearch, createdAtGt, createdAtGte, createdAtLt,
createdAtLte, dagId, dagIdPattern, dagIdPrefixPattern, dagRunId, limit,
mapIndex, offset, orderBy, respondedByUserId, respondedByUserName,
responseReceived, state, subjectSearch, taskId, taskIdPattern,
taskIdPrefixPattern, teams }])];
export type ImportErrorServiceGetImportErrorDefaultResponse =
Awaited<ReturnType<typeof ImportErrorService.getImportError>>;
export type ImportErrorServiceGetImportErrorQueryResult<TData =
ImportErrorServiceGetImportErrorDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
export const useImportErrorServiceGetImportErrorKey =
"ImportErrorServiceGetImportError";
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 58c12d2a670..768999e3216 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -1471,6 +1471,7 @@ export const
ensureUseTaskInstanceServiceGetHitlDetailTryDetailData = (queryClie
* @param data.taskIdPrefixPattern Case-sensitive, index-friendly prefix match.
See "Filtering with pattern parameters".
* @param data.mapIndex
* @param data.state
+* @param data.teams
* @param data.responseReceived
* @param data.respondedByUserId
* @param data.respondedByUserName
@@ -1483,7 +1484,7 @@ export const
ensureUseTaskInstanceServiceGetHitlDetailTryDetailData = (queryClie
* @returns HITLDetailCollection Successful Response
* @throws ApiError
*/
-export const ensureUseTaskInstanceServiceGetHitlDetailsData = (queryClient:
QueryClient, { bodySearch, createdAtGt, createdAtGte, createdAtLt,
createdAtLte, dagId, dagIdPattern, dagIdPrefixPattern, dagRunId, limit,
mapIndex, offset, orderBy, respondedByUserId, respondedByUserName,
responseReceived, state, subjectSearch, taskId, taskIdPattern,
taskIdPrefixPattern }: {
+export const ensureUseTaskInstanceServiceGetHitlDetailsData = (queryClient:
QueryClient, { bodySearch, createdAtGt, createdAtGte, createdAtLt,
createdAtLte, dagId, dagIdPattern, dagIdPrefixPattern, dagRunId, limit,
mapIndex, offset, orderBy, respondedByUserId, respondedByUserName,
responseReceived, state, subjectSearch, taskId, taskIdPattern,
taskIdPrefixPattern, teams }: {
bodySearch?: string;
createdAtGt?: string;
createdAtGte?: string;
@@ -1505,7 +1506,8 @@ export const
ensureUseTaskInstanceServiceGetHitlDetailsData = (queryClient: Quer
taskId?: string;
taskIdPattern?: string;
taskIdPrefixPattern?: string;
-}) => queryClient.ensureQueryData({ queryKey:
Common.UseTaskInstanceServiceGetHitlDetailsKeyFn({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern }), queryFn: () =>
TaskInstanceService.getHitlDetails({ bodySearch, createdAtGt, createdAtGte,
createdAtLt, createdAtLte, [...]
+ teams?: string[];
+}) => queryClient.ensureQueryData({ queryKey:
Common.UseTaskInstanceServiceGetHitlDetailsKeyFn({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern, teams }), queryFn: () =>
TaskInstanceService.getHitlDetails({ bodySearch, createdAtGt, createdAtGte,
createdAtLt, create [...]
/**
* Get Import Error
* Get an import error.
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 b04f0e549d1..0b991dce2bd 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -1471,6 +1471,7 @@ export const
prefetchUseTaskInstanceServiceGetHitlDetailTryDetail = (queryClient
* @param data.taskIdPrefixPattern Case-sensitive, index-friendly prefix match.
See "Filtering with pattern parameters".
* @param data.mapIndex
* @param data.state
+* @param data.teams
* @param data.responseReceived
* @param data.respondedByUserId
* @param data.respondedByUserName
@@ -1483,7 +1484,7 @@ export const
prefetchUseTaskInstanceServiceGetHitlDetailTryDetail = (queryClient
* @returns HITLDetailCollection Successful Response
* @throws ApiError
*/
-export const prefetchUseTaskInstanceServiceGetHitlDetails = (queryClient:
QueryClient, { bodySearch, createdAtGt, createdAtGte, createdAtLt,
createdAtLte, dagId, dagIdPattern, dagIdPrefixPattern, dagRunId, limit,
mapIndex, offset, orderBy, respondedByUserId, respondedByUserName,
responseReceived, state, subjectSearch, taskId, taskIdPattern,
taskIdPrefixPattern }: {
+export const prefetchUseTaskInstanceServiceGetHitlDetails = (queryClient:
QueryClient, { bodySearch, createdAtGt, createdAtGte, createdAtLt,
createdAtLte, dagId, dagIdPattern, dagIdPrefixPattern, dagRunId, limit,
mapIndex, offset, orderBy, respondedByUserId, respondedByUserName,
responseReceived, state, subjectSearch, taskId, taskIdPattern,
taskIdPrefixPattern, teams }: {
bodySearch?: string;
createdAtGt?: string;
createdAtGte?: string;
@@ -1505,7 +1506,8 @@ export const prefetchUseTaskInstanceServiceGetHitlDetails
= (queryClient: QueryC
taskId?: string;
taskIdPattern?: string;
taskIdPrefixPattern?: string;
-}) => queryClient.prefetchQuery({ queryKey:
Common.UseTaskInstanceServiceGetHitlDetailsKeyFn({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern }), queryFn: () =>
TaskInstanceService.getHitlDetails({ bodySearch, createdAtGt, createdAtGte,
createdAtLt, createdAtLte, d [...]
+ teams?: string[];
+}) => queryClient.prefetchQuery({ queryKey:
Common.UseTaskInstanceServiceGetHitlDetailsKeyFn({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern, teams }), queryFn: () =>
TaskInstanceService.getHitlDetails({ bodySearch, createdAtGt, createdAtGte,
createdAtLt, createdA [...]
/**
* Get Import Error
* Get an import error.
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 df1286bea25..92593cc4a6d 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -1471,6 +1471,7 @@ export const useTaskInstanceServiceGetHitlDetailTryDetail
= <TData = Common.Task
* @param data.taskIdPrefixPattern Case-sensitive, index-friendly prefix match.
See "Filtering with pattern parameters".
* @param data.mapIndex
* @param data.state
+* @param data.teams
* @param data.responseReceived
* @param data.respondedByUserId
* @param data.respondedByUserName
@@ -1483,7 +1484,7 @@ export const useTaskInstanceServiceGetHitlDetailTryDetail
= <TData = Common.Task
* @returns HITLDetailCollection Successful Response
* @throws ApiError
*/
-export const useTaskInstanceServiceGetHitlDetails = <TData =
Common.TaskInstanceServiceGetHitlDetailsDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern }: {
+export const useTaskInstanceServiceGetHitlDetails = <TData =
Common.TaskInstanceServiceGetHitlDetailsDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern, teams }: {
bodySearch?: string;
createdAtGt?: string;
createdAtGte?: string;
@@ -1505,7 +1506,8 @@ export const useTaskInstanceServiceGetHitlDetails =
<TData = Common.TaskInstance
taskId?: string;
taskIdPattern?: string;
taskIdPrefixPattern?: string;
-}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseTaskInstanceServiceGetHitlDetailsKeyFn({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern }, queryKey), queryFn: () => Ta [...]
+ teams?: string[];
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseTaskInstanceServiceGetHitlDetailsKeyFn({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern, teams }, queryKey), queryFn: ( [...]
/**
* Get Import Error
* Get an import error.
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 31da0a3e582..a7c41344ba9 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -1471,6 +1471,7 @@ export const
useTaskInstanceServiceGetHitlDetailTryDetailSuspense = <TData = Com
* @param data.taskIdPrefixPattern Case-sensitive, index-friendly prefix match.
See "Filtering with pattern parameters".
* @param data.mapIndex
* @param data.state
+* @param data.teams
* @param data.responseReceived
* @param data.respondedByUserId
* @param data.respondedByUserName
@@ -1483,7 +1484,7 @@ export const
useTaskInstanceServiceGetHitlDetailTryDetailSuspense = <TData = Com
* @returns HITLDetailCollection Successful Response
* @throws ApiError
*/
-export const useTaskInstanceServiceGetHitlDetailsSuspense = <TData =
Common.TaskInstanceServiceGetHitlDetailsDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern }: {
+export const useTaskInstanceServiceGetHitlDetailsSuspense = <TData =
Common.TaskInstanceServiceGetHitlDetailsDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern, teams }: {
bodySearch?: string;
createdAtGt?: string;
createdAtGte?: string;
@@ -1505,7 +1506,8 @@ export const useTaskInstanceServiceGetHitlDetailsSuspense
= <TData = Common.Task
taskId?: string;
taskIdPattern?: string;
taskIdPrefixPattern?: string;
-}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseTaskInstanceServiceGetHitlDetailsKeyFn({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern }, queryKey), queryFn: [...]
+ teams?: string[];
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseTaskInstanceServiceGetHitlDetailsKeyFn({ bodySearch, createdAtGt,
createdAtGte, createdAtLt, createdAtLte, dagId, dagIdPattern,
dagIdPrefixPattern, dagRunId, limit, mapIndex, offset, orderBy,
respondedByUserId, respondedByUserName, responseReceived, state, subjectSearch,
taskId, taskIdPattern, taskIdPrefixPattern, teams }, queryKey), qu [...]
/**
* Get Import Error
* Get an import error.
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 86596b1b890..a5925040182 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
@@ -3506,6 +3506,7 @@ export class TaskInstanceService {
* @param data.taskIdPrefixPattern Case-sensitive, index-friendly prefix
match. See "Filtering with pattern parameters".
* @param data.mapIndex
* @param data.state
+ * @param data.teams
* @param data.responseReceived
* @param data.respondedByUserId
* @param data.respondedByUserName
@@ -3537,6 +3538,7 @@ export class TaskInstanceService {
task_id_prefix_pattern: data.taskIdPrefixPattern,
map_index: data.mapIndex,
state: data.state,
+ teams: data.teams,
response_received: data.responseReceived,
responded_by_user_id: data.respondedByUserId,
responded_by_user_name: data.respondedByUserName,
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 6dcc310a469..189c9f182f7 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
@@ -4500,6 +4500,7 @@ export type GetHitlDetailsData = {
* Case-sensitive, index-friendly prefix match. See "Filtering with
pattern parameters".
*/
taskIdPrefixPattern?: string | null;
+ teams?: Array<(string)>;
};
export type GetHitlDetailsResponse = HITLDetailCollection;
diff --git
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLFilters.tsx
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLFilters.tsx
index 9e1850c48e9..e9f66632b18 100644
--- a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLFilters.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLFilters.tsx
@@ -21,10 +21,12 @@ import { useParams } from "react-router-dom";
import { FilterBar } from "src/components/FilterBar";
import { SearchParamsKeys } from "src/constants/searchParams";
+import { useConfig } from "src/queries/useConfig";
import { useFiltersHandler, type FilterableSearchParamsKeys } from "src/utils";
export const HITLFilters = ({ onResponseChange }: { readonly onResponseChange:
() => void }) => {
const { dagId = "~", taskId = "~" } = useParams();
+ const multiTeamEnabled = Boolean(useConfig("multi_team"));
const fixedKeys: Array<FilterableSearchParamsKeys> = [
SearchParamsKeys.RESPONSE_RECEIVED,
@@ -45,6 +47,10 @@ export const HITLFilters = ({ onResponseChange }: { readonly
onResponseChange: (
dynamicKeys.push(SearchParamsKeys.TASK_ID_PATTERN);
}
+ if (multiTeamEnabled) {
+ dynamicKeys.push(SearchParamsKeys.TEAMS);
+ }
+
const searchParamKeys = [...dynamicKeys, ...fixedKeys];
const { filterConfigs, handleFiltersChange, initialValues } =
useFiltersHandler(searchParamKeys);
diff --git
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.test.tsx
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.test.tsx
index 766d2c8f1a8..1440662aa07 100644
---
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.test.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.test.tsx
@@ -34,6 +34,10 @@ import { HITLTaskInstances } from "./HITLTaskInstances";
// `useSearchParams` is replaced with a function that returns a mutable
// reference; tests update `mockSearchParams` before each `render` call.
let mockSearchParams = new URLSearchParams();
+const mockConfig: Record<string, unknown> = { multi_team: false };
+// Populated by the `DataTable` stub on every render, so the column set the
page
+// builds can be asserted without rendering a real table.
+let renderedColumns: Array<{ accessorKey?: string }> = [];
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof ReactI18Next>();
@@ -89,7 +93,15 @@ vi.mock("./HITLFilters", () => ({
}));
vi.mock("src/components/DataTable", () => ({
- DataTable: () => null,
+ DataTable: ({ columns }: { columns: Array<{ accessorKey?: string }> }) => {
+ renderedColumns = columns;
+
+ return null;
+ },
+}));
+
+vi.mock("src/queries/useConfig", () => ({
+ useConfig: (key: string) => mockConfig[key],
}));
const { useTaskInstanceServiceGetHitlDetails } = await
import("openapi/queries");
@@ -107,6 +119,8 @@ const lastListingCall = () => {
};
beforeEach(() => {
+ mockConfig.multi_team = false;
+ renderedColumns = [];
vi.mocked(useTaskInstanceServiceGetHitlDetails).mockReturnValue(
emptyHitlResponse as ReturnType<typeof
useTaskInstanceServiceGetHitlDetails>,
);
@@ -271,3 +285,49 @@ describe("HITLTaskInstances – auto-refresh predicate", ()
=> {
expect(result).toBe(false);
});
});
+
+const columnKeys = () => renderedColumns.map((column) => column.accessorKey);
+
+describe("HITLTaskInstances – team column", () => {
+ it("shows the team right after the Dag it belongs to when multi-team is
enabled", () => {
+ mockConfig.multi_team = true;
+ mockSearchParams = new URLSearchParams();
+
+ render(<HITLTaskInstances />, { wrapper: Wrapper });
+
+ const keys = columnKeys();
+
+ expect(keys).toContain("team_name");
+
expect(keys.indexOf("team_name")).toBe(keys.indexOf("task_instance.dag_id") +
1);
+ });
+
+ it("omits the team column when multi-team is disabled", () => {
+ mockSearchParams = new URLSearchParams();
+
+ render(<HITLTaskInstances />, { wrapper: Wrapper });
+
+ expect(columnKeys()).not.toContain("team_name");
+ });
+});
+
+describe("HITLTaskInstances – team filter", () => {
+ it("sends the selected teams when the teams URL param is set", () => {
+ mockSearchParams = new URLSearchParams("teams=team-a&teams=team-b");
+
+ render(<HITLTaskInstances />, { wrapper: Wrapper });
+
+ const args = lastListingCall()?.[0] as { teams?: Array<string> } |
undefined;
+
+ expect(args?.teams).toEqual(["team-a", "team-b"]);
+ });
+
+ it("does not send a teams filter when the teams URL param is absent", () => {
+ mockSearchParams = new URLSearchParams();
+
+ render(<HITLTaskInstances />, { wrapper: Wrapper });
+
+ const args = lastListingCall()?.[0] as { teams?: Array<string> } |
undefined;
+
+ expect(args?.teams).toBeUndefined();
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
index ce40f35981c..5d5536edb70 100644
---
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
@@ -36,11 +36,13 @@ import { useTableURLState } from
"src/components/DataTable/useTableUrlState";
import { ErrorAlert } from "src/components/ErrorAlert";
import { HITLReviewDrawer } from
"src/components/HITLReview/HITLReviewDrawer.tsx";
import { StateBadge } from "src/components/StateBadge";
+import { TeamName } from "src/components/TeamName";
import Time from "src/components/Time";
import { TruncatedText } from "src/components/TruncatedText";
import { SearchParamsKeys, type SearchParamsKeysType } from
"src/constants/searchParams";
import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch";
+import { useConfig } from "src/queries/useConfig";
import { useAutoRefresh, useDocumentTitle } from "src/utils";
import { getHITLState, isHITLPending } from "src/utils/hitl";
import { getTaskInstanceLink } from "src/utils/links";
@@ -60,6 +62,7 @@ const {
RESPONSE_RECEIVED: RESPONSE_RECEIVED_PARAM,
SUBJECT_SEARCH,
TASK_ID_PATTERN,
+ TEAMS,
}: SearchParamsKeysType = SearchParamsKeys;
const HITLReviewDrawerButton = ({
@@ -99,12 +102,14 @@ const useHITLReviewDrawer = () => {
const taskInstanceColumns = ({
dagId,
+ multiTeam,
renderHITLReviewDrawerButton,
runId,
taskId,
translate,
}: {
dagId?: string;
+ multiTeam: boolean;
renderHITLReviewDrawerButton?: (detail: HITLDetail) => ReactNode;
runId?: string;
taskId?: string;
@@ -149,6 +154,18 @@ const taskInstanceColumns = ({
enableSorting: false,
header: translate("common:dagId"),
},
+ ...(multiTeam
+ ? [
+ {
+ accessorKey: "team_name",
+ cell: ({ row: { original } }: HITLRow) => (
+ <TeamName teamName={original.task_instance.team_name} />
+ ),
+ enableSorting: false,
+ header: translate("common:dagDetails.team"),
+ },
+ ]
+ : []),
]),
...(Boolean(runId)
? []
@@ -226,6 +243,7 @@ export const HITLTaskInstances = ({
}) => {
const { t: translate } = useTranslation("hitl");
const { dagId, runId, taskId } = useParams();
+ const multiTeamEnabled = Boolean(useConfig("multi_team"));
// Only the standalone required-actions page owns the tab title; nested tabs
inherit their parent's.
useDocumentTitle(enableHITLReviewDrawer ?
translate("common:browse.requiredActions") : undefined);
@@ -249,6 +267,7 @@ export const HITLTaskInstances = ({
const filterResponseReceived = searchParams.get(RESPONSE_RECEIVED_PARAM) ??
undefined;
const respondedByUserName = searchParams.get(RESPONDED_BY_USER_NAME) ??
undefined;
const subjectSearch = searchParams.get(SUBJECT_SEARCH) ?? undefined;
+ const teams = searchParams.getAll(TEAMS);
// Use the filter value if available, otherwise fall back to the old
responseReceived param
const effectiveResponseReceived = filterResponseReceived ?? responseReceived;
@@ -287,6 +306,7 @@ export const HITLTaskInstances = ({
subjectSearch,
taskId,
...taskIdArg,
+ teams: teams.length > 0 ? teams : undefined,
},
undefined,
{
@@ -315,6 +335,7 @@ export const HITLTaskInstances = ({
const columns = taskInstanceColumns({
dagId,
+ multiTeam: multiTeamEnabled,
renderHITLReviewDrawerButton: enableHITLReviewDrawer
? (detail) => <HITLReviewDrawerButton detail={detail}
onOpen={openHITLReviewDrawer} />
: undefined,
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
index 0df529b047a..75f7d65ecbd 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
@@ -25,18 +25,24 @@ from unittest import mock
import pytest
import time_machine
-from sqlalchemy import delete, select
+from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session
from airflow._shared.serialization import CLASSNAME, FORBIDDEN_XCOM_KEYS
from airflow._shared.timezones.timezone import utc, utcnow
+from airflow.models.dag import DagModel
+from airflow.models.dagbundle import DagBundleModel
from airflow.models.hitl import HITLDetail
from airflow.models.log import Log
from airflow.models.taskinstance import TaskInstance as TIModel
+from airflow.models.team import Team
from airflow.sdk.execution_time.hitl import HITLUser
+from airflow.utils.session import NEW_SESSION
from airflow.utils.state import TaskInstanceState
from tests_common.test_utils.asserts import assert_queries_count
+from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.db import clear_db_dag_bundles, clear_db_dags,
clear_db_teams
from tests_common.test_utils.format_datetime import
from_datetime_to_zulu_without_ms
if TYPE_CHECKING:
@@ -58,6 +64,16 @@ DEFAULT_CREATED_AT = datetime(2025, 9, 15, 13, 0, 0,
tzinfo=utc)
ANOTHER_CREATED_AT = datetime(2025, 9, 16, 12, 0, 0, tzinfo=utc)
+def _attach_dag_to_team(dag_id: str, team_name: str, *, session: Session =
NEW_SESSION) -> None:
+ """Move a Dag into a team-scoped bundle, which is how a Dag gains a
team."""
+ bundle = DagBundleModel(name=f"team-bundle-{team_name}")
+ bundle.teams.append(Team(name=team_name))
+ session.add(bundle)
+ session.flush()
+ session.execute(update(DagModel).where(DagModel.dag_id ==
dag_id).values(bundle_name=bundle.name))
+ session.commit()
+
+
@pytest.fixture
def sample_ti(
create_task_instance: CreateTaskInstance,
@@ -774,6 +790,36 @@ class TestGetHITLDetailsEndpoint:
assert response.json()["total_entries"] == expected_ti_count
assert len(response.json()["hitl_details"]) == expected_ti_count
+ @conf_vars({("core", "multi_team"): "True"})
+ @pytest.mark.usefixtures("sample_hitl_details")
+ def test_should_respond_200_filtered_by_team(
+ self,
+ test_client: TestClient,
+ session: Session,
+ ) -> None:
+ _attach_dag_to_team("hitl_dag_0", "team-hitl", session=session)
+ try:
+ response = test_client.get("/dags/~/dagRuns/~/hitlDetails",
params={"teams": ["team-hitl"]})
+ assert response.status_code == 200
+ response_data = response.json()
+ assert response_data["total_entries"] == 1
+ assert {detail["task_instance"]["dag_id"] for detail in
response_data["hitl_details"]} == {
+ "hitl_dag_0"
+ }
+ assert {detail["task_instance"]["team_name"] for detail in
response_data["hitl_details"]} == {
+ "team-hitl"
+ }
+
+ response = test_client.get(
+ "/dags/~/dagRuns/~/hitlDetails", params={"teams":
["team-without-dags"]}
+ )
+ assert response.status_code == 200
+ assert response.json()["total_entries"] == 0
+ finally:
+ clear_db_dags()
+ clear_db_dag_bundles()
+ clear_db_teams()
+
@pytest.mark.usefixtures("sample_hitl_details")
def test_should_respond_200_with_existing_response_and_concrete_query(
self,