This is an automated email from the ASF dual-hosted git repository.
weilee 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 ea3cb6ba58e Add UI for human in the loop operators (#53035)
ea3cb6ba58e is described below
commit ea3cb6ba58eebe8d377a79a727f11792357084da
Author: Guan Ming(Wesley) Chiu <[email protected]>
AuthorDate: Mon Aug 4 19:20:26 2025 +0800
Add UI for human in the loop operators (#53035)
Co-authored-by: Wei Lee <[email protected]>
---
.../src/airflow/api_fastapi/common/types.py | 1 +
.../api_fastapi/core_api/openapi/_private_ui.yaml | 1 +
airflow-core/src/airflow/security/permissions.py | 1 +
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 2 +-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 2 +-
.../airflow/ui/public/i18n/locales/en/common.json | 1 +
.../src/airflow/ui/public/i18n/locales/en/dag.json | 1 +
.../ui/public/i18n/locales/en/dashboard.json | 1 +
.../airflow/ui/public/i18n/locales/en/hitl.json | 23 +++
.../components/FlexibleForm/FieldAdvancedArray.tsx | 3 +-
.../ui/src/components/FlexibleForm/FieldBool.tsx | 3 +-
.../src/components/FlexibleForm/FieldDateTime.tsx | 4 +-
.../src/components/FlexibleForm/FieldDropdown.tsx | 3 +-
.../components/FlexibleForm/FieldMultiSelect.tsx | 5 +-
.../components/FlexibleForm/FieldMultilineText.tsx | 3 +-
.../ui/src/components/FlexibleForm/FieldNumber.tsx | 3 +-
.../ui/src/components/FlexibleForm/FieldObject.tsx | 3 +-
.../src/components/FlexibleForm/FieldSelector.tsx | 2 +-
.../ui/src/components/FlexibleForm/FieldString.tsx | 3 +-
.../components/FlexibleForm/FieldStringArray.tsx | 3 +-
.../src/components/FlexibleForm/FlexibleForm.tsx | 153 +++++++++++--------
airflow-core/src/airflow/ui/src/i18n/config.ts | 11 +-
.../airflow/ui/src/layouts/Nav/BrowseButton.tsx | 5 +
airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx | 33 +++-
.../airflow/ui/src/pages/Dashboard/Stats/Stats.tsx | 27 +++-
.../pages/HITLTaskInstances/HITLResponseForm.tsx | 138 +++++++++++++++++
.../pages/HITLTaskInstances/HITLTaskInstances.tsx | 170 +++++++++++++++++++++
.../HITLTaskInstances/index.ts} | 27 +---
airflow-core/src/airflow/ui/src/pages/Run/Run.tsx | 28 +++-
.../src/airflow/ui/src/pages/Task/Task.tsx | 33 +++-
.../TaskInstance/HITLResponse.tsx} | 44 +++---
.../ui/src/pages/TaskInstance/TaskInstance.tsx | 34 ++++-
.../src/airflow/ui/src/queries/useDeleteDagRun.ts | 9 +-
.../ui/src/queries/useDeleteTaskInstance.ts | 2 +
.../src/airflow/ui/src/queries/useParamStore.ts | 5 +
...eleteTaskInstance.ts => useUpdateHITLDetail.ts} | 82 +++++-----
airflow-core/src/airflow/ui/src/router.tsx | 11 ++
airflow-core/src/airflow/ui/src/utils/hitl.ts | 144 +++++++++++++++++
.../providers/fab/auth_manager/fab_auth_manager.py | 7 +
.../fab/auth_manager/security_manager/override.py | 3 +
.../providers/fab/www/security/permissions.py | 1 +
.../tests/unit/fab/auth_manager/test_security.py | 1 +
providers/fab/www-hash.txt | 2 +-
43 files changed, 867 insertions(+), 171 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/common/types.py
b/airflow-core/src/airflow/api_fastapi/common/types.py
index 7e965d5f99f..c5df6259f4d 100644
--- a/airflow-core/src/airflow/api_fastapi/common/types.py
+++ b/airflow-core/src/airflow/api_fastapi/common/types.py
@@ -88,6 +88,7 @@ class ExtraMenuItem:
class MenuItem(Enum):
"""Define all menu items defined in the menu."""
+ REQUIRED_ACTIONS = "Required Actions"
ASSETS = "Assets"
AUDIT_LOG = "Audit Log"
CONFIG = "Config"
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 742f4dafbc8..39b272fd4ad 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
@@ -1872,6 +1872,7 @@ components:
MenuItem:
type: string
enum:
+ - Required Actions
- Assets
- Audit Log
- Config
diff --git a/airflow-core/src/airflow/security/permissions.py
b/airflow-core/src/airflow/security/permissions.py
index 008d7855ef9..6ae47faefbe 100644
--- a/airflow-core/src/airflow/security/permissions.py
+++ b/airflow-core/src/airflow/security/permissions.py
@@ -49,6 +49,7 @@ RESOURCE_ASSET = "Assets"
RESOURCE_ASSET_ALIAS = "Asset Aliases"
RESOURCE_DOCS = "Documentation"
RESOURCE_DOCS_MENU = "Docs"
+RESOURCE_HITL_DETAIL = "HITL Detail"
RESOURCE_IMPORT_ERROR = "ImportError"
RESOURCE_JOB = "Jobs"
RESOURCE_MY_PASSWORD = "My Password"
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 4a9683afdc9..33058f09082 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
@@ -7152,7 +7152,7 @@ export const $LightGridTaskInstanceSummary = {
export const $MenuItem = {
type: 'string',
- enum: ['Assets', 'Audit Log', 'Config', 'Connections', 'Dags', 'Docs',
'Plugins', 'Pools', 'Providers', 'Variables', 'XComs'],
+ enum: ['Required Actions', 'Assets', 'Audit Log', 'Config', 'Connections',
'Dags', 'Docs', 'Plugins', 'Pools', 'Providers', 'Variables', 'XComs'],
title: 'MenuItem',
description: 'Define all menu items defined in the menu.'
} as const;
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 563b35ca3d7..a99c634e14c 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
@@ -1849,7 +1849,7 @@ export type LightGridTaskInstanceSummary = {
/**
* Define all menu items defined in the menu.
*/
-export type MenuItem = 'Assets' | 'Audit Log' | 'Config' | 'Connections' |
'Dags' | 'Docs' | 'Plugins' | 'Pools' | 'Providers' | 'Variables' | 'XComs';
+export type MenuItem = 'Required Actions' | 'Assets' | 'Audit Log' | 'Config'
| 'Connections' | 'Dags' | 'Docs' | 'Plugins' | 'Pools' | 'Providers' |
'Variables' | 'XComs';
/**
* Menu Item Collection serializer for responses.
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index fe8b605dc93..8dda576063e 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -15,6 +15,7 @@
"backfill_other": "Backfills",
"browse": {
"auditLog": "Audit Log",
+ "requiredActions": "Required Actions",
"xcoms": "XComs"
},
"collapseDetailsPanel": "Collapse Details Panel",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index cdc1aa3a289..0ea61a8b353 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -103,6 +103,7 @@
"mappedTaskInstances_other": "Task Instances [{{count}}]",
"overview": "Overview",
"renderedTemplates": "Rendered Templates",
+ "requiredActions": "Required Actions",
"runs": "Runs",
"taskInstances": "Task Instances",
"tasks": "Tasks",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json
index 8b208cf41df..1d90ff0b777 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json
@@ -36,6 +36,7 @@
"activeDags": "Active Dags",
"failedDags": "Failed Dags",
"queuedDags": "Queued Dags",
+ "requiredActions": "Required Actions",
"runningDags": "Running Dags",
"stats": "Stats"
},
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/hitl.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/hitl.json
new file mode 100644
index 00000000000..067aa40a5cc
--- /dev/null
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/hitl.json
@@ -0,0 +1,23 @@
+{
+ "requiredAction_one": "Required Action",
+ "requiredAction_other": "Required Actions",
+ "requiredActionState": "Required Action State",
+ "response": {
+ "error": "Response failed",
+ "optionsDescription": "Choose your options for this task instance",
+ "optionsLabel": "Options",
+ "received": "Response received at ",
+ "respond": "Respond",
+ "success": "{{taskId}} response successful",
+ "title": "Human Task Instance - {{taskId}}"
+ },
+ "state": {
+ "approvalReceived": "Approval Received",
+ "approvalRequired": "Approval Required",
+ "choiceReceived": "Choice Received",
+ "choiceRequired": "Choice Required",
+ "rejectionReceived": "Rejection Received",
+ "responseReceived": "Response Received",
+ "responseRequired": "Response Required"
+ }
+}
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
index 1020b700afd..79ba9fbfe53 100644
---
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
@@ -25,7 +25,7 @@ import { JsonEditor } from "../JsonEditor";
export const FieldAdvancedArray = ({ name, onUpdate }:
FlexibleFormElementProps) => {
const { t: translate } = useTranslation("components");
- const { paramsDict, setParamsDict } = useParamStore();
+ const { disabled, paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
// Determine the expected type based on schema
const expectedType = param.schema.items?.type ?? "object";
@@ -71,6 +71,7 @@ export const FieldAdvancedArray = ({ name, onUpdate }:
FlexibleFormElementProps)
return (
<JsonEditor
+ editable={!Boolean(disabled)}
id={`element_${name}`}
onChange={handleChange}
value={JSON.stringify(param.value ?? [], undefined, 2)}
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
index 1d48249f7ea..a8b193ad729 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
@@ -22,7 +22,7 @@ import type { FlexibleFormElementProps } from ".";
import { Switch } from "../ui";
export const FieldBool = ({ name }: FlexibleFormElementProps) => {
- const { paramsDict, setParamsDict } = useParamStore();
+ const { disabled, paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
const onCheck = (value: boolean) => {
if (paramsDict[name]) {
@@ -36,6 +36,7 @@ export const FieldBool = ({ name }: FlexibleFormElementProps)
=> {
<Switch
checked={Boolean(param.value)}
colorPalette="blue"
+ disabled={disabled}
id={`element_${name}`}
name={`element_${name}`}
onCheckedChange={(event) => onCheck(event.checked)}
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
index cc8d23184b3..f26b06f3dc1 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
@@ -24,7 +24,7 @@ import type { FlexibleFormElementProps } from ".";
import { DateTimeInput } from "../DateTimeInput";
export const FieldDateTime = ({ name, onUpdate, ...rest }:
FlexibleFormElementProps & InputProps) => {
- const { paramsDict, setParamsDict } = useParamStore();
+ const { disabled, paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
const handleChange = (value: string) => {
if (paramsDict[name]) {
@@ -46,6 +46,7 @@ export const FieldDateTime = ({ name, onUpdate, ...rest }:
FlexibleFormElementPr
if (rest.type === "datetime-local") {
return (
<DateTimeInput
+ disabled={disabled}
id={`element_${name}`}
name={`element_${name}`}
onChange={(event) => handleChange(event.target.value)}
@@ -57,6 +58,7 @@ export const FieldDateTime = ({ name, onUpdate, ...rest }:
FlexibleFormElementPr
return (
<Input
+ disabled={disabled}
id={`element_${name}`}
name={`element_${name}`}
onChange={(event) => handleChange(event.target.value)}
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
index d1ac1819ef7..503116427b7 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
@@ -36,7 +36,7 @@ const enumTypes = ["string", "number", "integer"];
export const FieldDropdown = ({ name, onUpdate }: FlexibleFormElementProps) =>
{
const { t: translate } = useTranslation("components");
- const { paramsDict, setParamsDict } = useParamStore();
+ const { disabled, paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
const selectOptions = createListCollection({
@@ -63,6 +63,7 @@ export const FieldDropdown = ({ name, onUpdate }:
FlexibleFormElementProps) => {
return (
<Select.Root
collection={selectOptions}
+ disabled={disabled}
id={`element_${name}`}
name={`element_${name}`}
onValueChange={(event) => handleChange(event.value)}
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
index 20c3fd92369..257e5a01876 100644
---
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
@@ -34,7 +34,7 @@ const labelLookup = (key: string, valuesDisplay:
Record<string, string> | undefi
export const FieldMultiSelect = ({ name, onUpdate }: FlexibleFormElementProps)
=> {
const { t: translate } = useTranslation("components");
- const { paramsDict, setParamsDict } = useParamStore();
+ const { disabled, paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
// Initialize `selectedOptions` directly from `paramsDict`
@@ -74,11 +74,12 @@ export const FieldMultiSelect = ({ name, onUpdate }:
FlexibleFormElementProps) =
aria-label={translate("flexibleForm.placeholderMulti")}
id={`element_${name}`}
isClearable
+ isDisabled={disabled}
isMulti
name={`element_${name}`}
onChange={handleChange}
options={
- param.schema.examples?.map((value) => ({
+ (param.schema.examples ?? param.schema.enum)?.map((value) => ({
label: labelLookup(value, param.schema.values_display),
value,
})) ?? []
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx
index 93c7410bb82..019cc7dd61c 100644
---
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx
@@ -23,7 +23,7 @@ import { paramPlaceholder, useParamStore } from
"src/queries/useParamStore";
import type { FlexibleFormElementProps } from ".";
export const FieldMultilineText = ({ name, onUpdate }:
FlexibleFormElementProps) => {
- const { paramsDict, setParamsDict } = useParamStore();
+ const { disabled, paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
const handleChange = (value: string) => {
if (paramsDict[name]) {
@@ -38,6 +38,7 @@ export const FieldMultilineText = ({ name, onUpdate }:
FlexibleFormElementProps)
return (
<Textarea
+ disabled={disabled}
id={`element_${name}`}
name={`element_${name}`}
onChange={(event) => handleChange(event.target.value)}
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx
index 635f461335c..67fab981079 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx
@@ -22,7 +22,7 @@ import type { FlexibleFormElementProps } from ".";
import { NumberInputField, NumberInputRoot } from "../ui/NumberInput";
export const FieldNumber = ({ name, onUpdate }: FlexibleFormElementProps) => {
- const { paramsDict, setParamsDict } = useParamStore();
+ const { disabled, paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
const handleChange = (value: string) => {
if (value === "") {
@@ -46,6 +46,7 @@ export const FieldNumber = ({ name, onUpdate }:
FlexibleFormElementProps) => {
return (
<NumberInputRoot
allowMouseWheel
+ disabled={disabled}
id={`element_${name}`}
max={param.schema.maximum ?? undefined}
min={param.schema.minimum ?? undefined}
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
index ce5bf307311..9f6bd41d1ce 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
@@ -22,7 +22,7 @@ import type { FlexibleFormElementProps } from ".";
import { JsonEditor } from "../JsonEditor";
export const FieldObject = ({ name, onUpdate }: FlexibleFormElementProps) => {
- const { paramsDict, setParamsDict } = useParamStore();
+ const { disabled, paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
const handleChange = (value: string) => {
@@ -44,6 +44,7 @@ export const FieldObject = ({ name, onUpdate }:
FlexibleFormElementProps) => {
return (
<JsonEditor
+ editable={!disabled}
id={`element_${name}`}
onChange={handleChange}
value={JSON.stringify(param.value ?? [], undefined, 2)}
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
index cd0061987a6..19c1359d231 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
@@ -70,7 +70,7 @@ const isFieldMultilineText = (fieldType: string, fieldSchema:
ParamSchema) =>
fieldType === "string" && fieldSchema.format === "multiline";
const isFieldMultiSelect = (fieldType: string, fieldSchema: ParamSchema) =>
- fieldType === "array" && Array.isArray(fieldSchema.examples);
+ fieldType === "array" && (Array.isArray(fieldSchema.examples) ||
Array.isArray(fieldSchema.enum));
const isFieldNumber = (fieldType: string) => {
const numberTypes = ["integer", "number"];
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx
index bb7951ced9d..04ee952e7c3 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx
@@ -25,7 +25,7 @@ import type { FlexibleFormElementProps } from ".";
export const FieldString = ({ name, onUpdate }: FlexibleFormElementProps) => {
const { t: translate } = useTranslation("components");
- const { paramsDict, setParamsDict } = useParamStore();
+ const { disabled, paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
const handleChange = (value: string) => {
if (paramsDict[name]) {
@@ -41,6 +41,7 @@ export const FieldString = ({ name, onUpdate }:
FlexibleFormElementProps) => {
return (
<>
<Input
+ disabled={disabled}
id={`element_${name}`}
list={param.schema.examples ? `list_${name}` : undefined}
maxLength={param.schema.maxLength ?? undefined}
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
index 3708411af3d..52ab2f7614a 100644
---
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
@@ -25,7 +25,7 @@ import type { FlexibleFormElementProps } from ".";
export const FieldStringArray = ({ name, onUpdate }: FlexibleFormElementProps)
=> {
const { t: translate } = useTranslation("components");
- const { paramsDict, setParamsDict } = useParamStore();
+ const { disabled, paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
const handleChange = (newValue: string) => {
@@ -57,6 +57,7 @@ export const FieldStringArray = ({ name, onUpdate }:
FlexibleFormElementProps) =
return (
<Textarea
+ disabled={disabled}
id={`element_${name}`}
name={`element_${name}`}
onBlur={handleBlur}
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
index 1841c7dcfac..9cdfc58dce6 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
@@ -23,25 +23,32 @@ import { MdError } from "react-icons/md";
import type { ParamsSpec } from "src/queries/useDagParams";
import { useParamStore } from "src/queries/useParamStore";
+import ReactMarkdown from "../ReactMarkdown";
import { Accordion } from "../ui";
import { Row } from "./Row";
import { isRequired } from "./isParamRequired";
export type FlexibleFormProps = {
- flexibleFormDefaultSection: string;
- initialParamsDict: { paramsDict: ParamsSpec };
- key?: string;
- setError: (error: boolean) => void;
- subHeader?: string;
+ readonly disabled?: boolean;
+ readonly flexFormDescription?: string;
+ readonly flexibleFormDefaultSection: string;
+ readonly initialParamsDict: { paramsDict: ParamsSpec };
+ readonly isHITL?: boolean;
+ readonly key?: string;
+ readonly setError: (error: boolean) => void;
+ readonly subHeader?: string;
};
export const FlexibleForm = ({
+ disabled,
+ flexFormDescription,
flexibleFormDefaultSection,
initialParamsDict,
+ isHITL,
setError,
subHeader,
}: FlexibleFormProps) => {
- const { paramsDict: params, setInitialParamDict, setParamsDict } =
useParamStore();
+ const { paramsDict: params, setDisabled, setInitialParamDict, setParamsDict
} = useParamStore();
const processedSections = new Map();
const [sectionError, setSectionError] = useState<Map<string, boolean>>(new
Map());
@@ -86,6 +93,10 @@ export const FlexibleForm = ({
}
}, [params, setError, recheckSection, sectionError]);
+ useEffect(() => {
+ setDisabled(disabled ?? false);
+ }, [disabled, setDisabled]);
+
const onUpdate = (_value?: string, error?: unknown) => {
recheckSection();
if (!Boolean(error) && sectionError.size === 0) {
@@ -95,60 +106,82 @@ export const FlexibleForm = ({
}
};
- return Object.entries(params).some(([, param]) => typeof
param.schema.section !== "string")
- ? Object.entries(params).map(([, secParam]) => {
- const currentSection = secParam.schema.section ??
flexibleFormDefaultSection;
-
- if (processedSections.has(currentSection)) {
- return undefined;
- } else {
- processedSections.set(currentSection, true);
-
- return (
- <Accordion.Item
- // We need to make the item content overflow visible for
dropdowns to work, but directly applying the style does not work
- css={{
- "& > div:nth-of-type(1)": {
- overflow: "visible",
- },
- }}
- key={currentSection}
- value={currentSection}
- >
- <Accordion.ItemTrigger cursor="button">
- <Text color={sectionError.get(currentSection) ? "fg.error" :
undefined}>
- {currentSection}
- </Text>
- {sectionError.get(currentSection) ? (
- <Icon color="fg.error" margin="-1">
- <MdError />
- </Icon>
+ return Object.entries(params).some(([, param]) => typeof
param.schema.section !== "string") ? (
+ Object.entries(params).map(([, secParam]) => {
+ const currentSection = secParam.schema.section ??
flexibleFormDefaultSection;
+
+ if (processedSections.has(currentSection)) {
+ return undefined;
+ } else {
+ processedSections.set(currentSection, true);
+
+ return (
+ <Accordion.Item
+ // We need to make the item content overflow visible for dropdowns
to work, but directly applying the style does not work
+ css={{
+ "& > div:nth-of-type(1)": {
+ overflow: "visible",
+ },
+ }}
+ key={currentSection}
+ value={currentSection}
+ >
+ <Accordion.ItemTrigger cursor="button">
+ <Text color={sectionError.get(currentSection) ? "fg.error" :
undefined}>{currentSection}</Text>
+ {sectionError.get(currentSection) ? (
+ <Icon color="fg.error" margin="-1">
+ <MdError />
+ </Icon>
+ ) : undefined}
+ </Accordion.ItemTrigger>
+
+ <Accordion.ItemContent pt={0}>
+ <Accordion.ItemBody>
+ {Boolean(subHeader) ? (
+ <Text color="fg.muted" fontSize="xs" mb={2}>
+ {subHeader}
+ </Text>
) : undefined}
- </Accordion.ItemTrigger>
-
- <Accordion.ItemContent pt={0}>
- <Accordion.ItemBody>
- {Boolean(subHeader) ? (
- <Text color="fg.muted" fontSize="xs" mb={2}>
- {subHeader}
- </Text>
+ <Stack separator={<StackSeparator py={2} />}>
+ {Boolean(flexFormDescription) ? (
+ <ReactMarkdown>{flexFormDescription}</ReactMarkdown>
) : undefined}
- <Stack separator={<StackSeparator />}>
- {Object.entries(params)
- .filter(
- ([, param]) =>
- param.schema.section === currentSection ||
- (currentSection === flexibleFormDefaultSection &&
!Boolean(param.schema.section)),
- )
- .map(([name]) => (
- <Row key={name} name={name} onUpdate={onUpdate} />
- ))}
- </Stack>
- </Accordion.ItemBody>
- </Accordion.ItemContent>
- </Accordion.Item>
- );
- }
- })
- : undefined;
+ {Object.entries(params)
+ .filter(
+ ([, param]) =>
+ param.schema.section === currentSection ||
+ (currentSection === flexibleFormDefaultSection &&
!Boolean(param.schema.section)),
+ )
+ .map(([name]) => (
+ <Row key={name} name={name} onUpdate={onUpdate} />
+ ))}
+ </Stack>
+ </Accordion.ItemBody>
+ </Accordion.ItemContent>
+ </Accordion.Item>
+ );
+ }
+ })
+ ) : isHITL ? (
+ <Accordion.Item key={flexibleFormDefaultSection}
value={flexibleFormDefaultSection}>
+ <Accordion.ItemTrigger cursor="button">
+ <Text color={sectionError.get(flexibleFormDefaultSection) ? "fg.error"
: undefined}>
+ {flexibleFormDefaultSection}
+ </Text>
+ {sectionError.get(flexibleFormDefaultSection) ? (
+ <Icon color="fg.error" margin="-1">
+ <MdError />
+ </Icon>
+ ) : undefined}
+ </Accordion.ItemTrigger>
+
+ <Accordion.ItemContent pt={0}>
+ <Accordion.ItemBody>
+ <Stack separator={<StackSeparator py={2} />}>
+ {Boolean(flexFormDescription) ?
<ReactMarkdown>{flexFormDescription}</ReactMarkdown> : undefined}
+ </Stack>
+ </Accordion.ItemBody>
+ </Accordion.ItemContent>
+ </Accordion.Item>
+ ) : undefined;
};
diff --git a/airflow-core/src/airflow/ui/src/i18n/config.ts
b/airflow-core/src/airflow/ui/src/i18n/config.ts
index 86f25740bfc..33ffb377f19 100644
--- a/airflow-core/src/airflow/ui/src/i18n/config.ts
+++ b/airflow-core/src/airflow/ui/src/i18n/config.ts
@@ -35,7 +35,16 @@ export const supportedLanguages = [
] as const;
export const defaultLanguage = "en";
-export const namespaces = ["common", "dashboard", "dags", "admin", "browse",
"assets", "components"] as const;
+export const namespaces = [
+ "common",
+ "dashboard",
+ "dags",
+ "admin",
+ "browse",
+ "assets",
+ "components",
+ "hitl",
+] as const;
void i18n
.use(Backend)
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
index 3df43b9fbac..e24700e0451 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
@@ -38,6 +38,11 @@ const links = [
key: "xcoms",
title: "XComs",
},
+ {
+ href: "/required_actions",
+ key: "requiredActions",
+ title: "Required Actions",
+ },
];
export const BrowseButton = ({
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
index 2857f4abd98..455dbc2e794 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
@@ -19,13 +19,17 @@
import { ReactFlowProvider } from "@xyflow/react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
-import { FiBarChart, FiCode } from "react-icons/fi";
+import { FiBarChart, FiCode, FiUser } from "react-icons/fi";
import { LuChartColumn } from "react-icons/lu";
import { MdDetails, MdOutlineEventNote } from "react-icons/md";
import { RiArrowGoBackFill } from "react-icons/ri";
import { useParams } from "react-router-dom";
-import { useDagServiceGetDagDetails, useDagServiceGetLatestRunInfo } from
"openapi/queries";
+import {
+ useDagServiceGetDagDetails,
+ useDagServiceGetLatestRunInfo,
+ useHumanInTheLoopServiceGetHitlDetails,
+} from "openapi/queries";
import { TaskIcon } from "src/assets/TaskIcon";
import { usePluginTabs } from "src/hooks/usePluginTabs";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
@@ -45,6 +49,7 @@ export const Dag = () => {
{ icon: <LuChartColumn />, label: translate("tabs.overview"), value: "" },
{ icon: <FiBarChart />, label: translate("tabs.runs"), value: "runs" },
{ icon: <TaskIcon />, label: translate("tabs.tasks"), value: "tasks" },
+ { icon: <FiUser />, label: translate("tabs.requiredActions"), value:
"required_actions" },
{ icon: <RiArrowGoBackFill />, label: translate("tabs.backfills"), value:
"backfills" },
{ icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value:
"events" },
{ icon: <FiCode />, label: translate("tabs.code"), value: "code" },
@@ -67,7 +72,29 @@ export const Dag = () => {
// pending state and new runs are initiated from other page
useRefreshOnNewDagRuns(dagId, hasPendingRuns);
- const displayTabs = tabs.filter((tab) => !(dag?.timetable_summary === null
&& tab.value === "backfills"));
+ const { data: hitlData } = useHumanInTheLoopServiceGetHitlDetails(
+ {
+ dagIdPattern: dagId,
+ },
+ undefined,
+ {
+ enabled: Boolean(dagId),
+ },
+ );
+
+ const hasHitlTasks = (hitlData?.total_entries ?? 0) > 0;
+
+ const displayTabs = tabs.filter((tab) => {
+ if (dag?.timetable_summary === null && tab.value === "backfills") {
+ return false;
+ }
+
+ if (tab.value === "required_actions" && !hasHitlTasks) {
+ return false;
+ }
+
+ return true;
+ });
const {
data: latestRun,
diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
index 70ae3025c79..1d54a3f9454 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
@@ -18,9 +18,9 @@
*/
import { Box, Flex, Heading, HStack } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
-import { FiClipboard, FiZap } from "react-icons/fi";
+import { FiClipboard, FiZap, FiClock } from "react-icons/fi";
-import { useDashboardServiceDagStats } from "openapi/queries";
+import { useDashboardServiceDagStats, useHumanInTheLoopServiceGetHitlDetails }
from "openapi/queries";
import { useAutoRefresh } from "src/utils";
import { DAGImportErrors } from "./DAGImportErrors";
@@ -32,10 +32,22 @@ export const Stats = () => {
const { data: statsData, isLoading: isStatsLoading } =
useDashboardServiceDagStats(undefined, {
refetchInterval,
});
+
+ const { data: hitlStatsData } = useHumanInTheLoopServiceGetHitlDetails(
+ {
+ responseReceived: false,
+ },
+ undefined,
+ {
+ refetchInterval,
+ },
+ );
+
const failedDagsCount = statsData?.failed_dag_count ?? 0;
const queuedDagsCount = statsData?.queued_dag_count ?? 0;
const runningDagsCount = statsData?.running_dag_count ?? 0;
const activeDagsCount = statsData?.active_dag_count ?? 0;
+ const hitlTIsCount = hitlStatsData?.hitl_details.length ?? 0;
const { t: translate } = useTranslation("dashboard");
return (
@@ -48,6 +60,17 @@ export const Stats = () => {
</Flex>
<HStack gap={4}>
+ {hitlTIsCount > 0 ? (
+ <StatsCard
+ colorScheme="failed"
+ count={hitlTIsCount}
+ icon={<FiClock />}
+ isLoading={isStatsLoading}
+ label={translate("stats.requiredActions")}
+ link="required_actions"
+ />
+ ) : undefined}
+
<StatsCard
colorScheme="failed"
count={failedDagsCount}
diff --git
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
new file mode 100644
index 00000000000..2563d3ce721
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
@@ -0,0 +1,138 @@
+/*!
+ * 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 { Button, Box, Spacer, HStack, Accordion, Text } from
"@chakra-ui/react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { FiSend } from "react-icons/fi";
+
+import type { HITLDetail } from "openapi/requests/types.gen";
+import { FlexibleForm } from "src/components/FlexibleForm/FlexibleForm";
+import Time from "src/components/Time";
+import { useParamStore } from "src/queries/useParamStore";
+import { useUpdateHITLDetail } from "src/queries/useUpdateHITLDetail";
+import { getHITLParamsDict, getHITLFormData } from "src/utils/hitl";
+
+type HITLResponseFormProps = {
+ readonly hitlDetail?: HITLDetail;
+};
+
+const isHighlightOption = (option: string, hitlDetail: HITLDetail) => {
+ const isSelected = hitlDetail.chosen_options?.includes(option) &&
Boolean(hitlDetail.response_received);
+ const isDefault = hitlDetail.defaults?.includes(option) &&
!Boolean(hitlDetail.response_received);
+
+ // highlight if:
+ // 1. the option is selected and the response is received
+ // 2. the option is in default options and the response is not received
+ // 3. the option is not selected and the response is not received and there
is no default options
+ return isSelected ?? isDefault ?? !Boolean(hitlDetail.defaults);
+};
+
+export const HITLResponseForm = ({ hitlDetail }: HITLResponseFormProps) => {
+ const { t: translate } = useTranslation();
+ const [errors, setErrors] = useState<boolean>(false);
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+ const { paramsDict } = useParamStore();
+ const { updateHITLResponse } = useUpdateHITLDetail({
+ dagId: hitlDetail?.task_instance.dag_id ?? "",
+ dagRunId: hitlDetail?.task_instance.dag_run_id ?? "",
+ mapIndex: hitlDetail?.task_instance.map_index ?? -1,
+ taskId: hitlDetail?.task_instance.task_id ?? "",
+ });
+
+ const handleSubmit = (option?: string) => {
+ if (errors || isSubmitting) {
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ const formData = getHITLFormData(paramsDict, option);
+
+ updateHITLResponse(formData);
+ } catch {
+ setErrors(true);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (!hitlDetail) {
+ return undefined;
+ }
+
+ return (
+ <Box mt={4}>
+ {hitlDetail.response_received ? (
+ <Text color="fg.muted" fontSize="sm">
+ {translate("hitl:response.received")}
+ <Time datetime={hitlDetail.response_at} format="YYYY-MM-DD,
HH:mm:ss" />
+ </Text>
+ ) : undefined}
+ <Accordion.Root
+ collapsible
+ defaultValue={[hitlDetail.subject]}
+ mb={4}
+ mt={4}
+ size="lg"
+ variant="enclosed"
+ >
+ <FlexibleForm
+ disabled={hitlDetail.response_received}
+ flexFormDescription={hitlDetail.body ?? undefined}
+ flexibleFormDefaultSection={hitlDetail.subject}
+ initialParamsDict={{
+ paramsDict: getHITLParamsDict(hitlDetail, translate),
+ }}
+ isHITL
+ key={hitlDetail.subject}
+ setError={setErrors}
+ />
+ </Accordion.Root>
+
+ <Box as="footer" display="flex" justifyContent="flex-end" mt={4}>
+ <HStack w="full">
+ <Spacer />
+ {hitlDetail.options.length < 4 && !hitlDetail.multiple ? (
+ hitlDetail.options.map((option) => (
+ <Button
+ colorPalette={isHighlightOption(option, hitlDetail) ? "blue" :
"gray"}
+ disabled={(hitlDetail.response_received ?? errors) ||
isSubmitting}
+ key={option}
+ onClick={() => handleSubmit(option)}
+ variant={isHighlightOption(option, hitlDetail) ? "solid" :
"subtle"}
+ >
+ {option}
+ </Button>
+ ))
+ ) : hitlDetail.response_received ? undefined : (
+ <Button
+ colorPalette="blue"
+ disabled={errors || isSubmitting}
+ loading={isSubmitting}
+ onClick={() => handleSubmit()}
+ >
+ <FiSend /> {translate("hitl:response.respond")}
+ </Button>
+ )}
+ </HStack>
+ </Box>
+ </Box>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
new file mode 100644
index 00000000000..896e481ca63
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
@@ -0,0 +1,170 @@
+/*!
+ * 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 { Link } from "@chakra-ui/react";
+import type { ColumnDef } from "@tanstack/react-table";
+import type { TFunction } from "i18next";
+import { useTranslation } from "react-i18next";
+import { Link as RouterLink, useParams } from "react-router-dom";
+
+import { useHumanInTheLoopServiceGetHitlDetails } from "openapi/queries";
+import type { HITLDetail } from "openapi/requests/types.gen";
+import { DataTable } from "src/components/DataTable";
+import { useTableURLState } from "src/components/DataTable/useTableUrlState";
+import { ErrorAlert } from "src/components/ErrorAlert";
+import { StateBadge } from "src/components/StateBadge";
+import Time from "src/components/Time";
+import { TruncatedText } from "src/components/TruncatedText";
+import { useAutoRefresh } from "src/utils";
+import { getHITLState } from "src/utils/hitl";
+import { getTaskInstanceLink } from "src/utils/links";
+
+type TaskInstanceRow = { row: { original: HITLDetail } };
+
+const taskInstanceColumns = ({
+ dagId,
+ runId,
+ taskId,
+ translate,
+}: {
+ dagId?: string;
+ runId?: string;
+ taskId?: string;
+ translate: TFunction;
+}): Array<ColumnDef<HITLDetail>> => [
+ {
+ accessorKey: "task_instance.operator",
+ cell: ({ row: { original } }: TaskInstanceRow) => (
+ <StateBadge
state={original.task_instance.state}>{getHITLState(translate,
original)}</StateBadge>
+ ),
+ header: translate("Required Action State"),
+ },
+ {
+ accessorKey: "subject",
+ cell: ({ row: { original } }: TaskInstanceRow) => (
+ <Link asChild color="fg.info" fontWeight="bold">
+ <RouterLink
to={`${getTaskInstanceLink(original.task_instance)}/required_actions`}>
+ <TruncatedText text={original.subject} />
+ </RouterLink>
+ </Link>
+ ),
+ header: translate("Subject"),
+ },
+ ...(Boolean(dagId)
+ ? []
+ : [
+ {
+ accessorKey: "task_instance.dag_id",
+ enableSorting: false,
+ header: translate("dagId"),
+ },
+ ]),
+ ...(Boolean(runId)
+ ? []
+ : [
+ {
+ accessorKey: "run_after",
+ // If we don't show the taskId column, make the dag run a link to
the task instance
+ cell: ({ row: { original } }: TaskInstanceRow) =>
+ Boolean(taskId) ? (
+ <Link asChild color="fg.info" fontWeight="bold">
+ <RouterLink to={getTaskInstanceLink(original.task_instance)}>
+ <Time datetime={original.task_instance.run_after} />
+ </RouterLink>
+ </Link>
+ ) : (
+ <Time datetime={original.task_instance.run_after} />
+ ),
+ header: translate("dagRun_one"),
+ },
+ ]),
+ ...(Boolean(taskId)
+ ? []
+ : [
+ {
+ accessorKey: "task_display_name",
+ cell: ({ row: { original } }: TaskInstanceRow) => (
+ <TruncatedText text={original.task_instance.task_display_name} />
+ ),
+ enableSorting: false,
+ header: translate("taskId"),
+ },
+ ]),
+ {
+ accessorKey: "rendered_map_index",
+ header: translate("mapIndex"),
+ },
+ {
+ accessorKey: "response_received",
+ header: translate("Response Received"),
+ },
+ {
+ accessorKey: "response_at",
+ cell: ({ row: { original } }) => <Time datetime={original.response_at} />,
+ header: translate("Response At"),
+ },
+];
+
+export const HITLTaskInstances = () => {
+ const { t: translate } = useTranslation();
+ const { dagId, groupId, runId, taskId } = useParams();
+ const { setTableURLState, tableURLState } = useTableURLState();
+ const { pagination } = tableURLState;
+
+ const refetchInterval = useAutoRefresh({});
+
+ const { data, error, isLoading } = useHumanInTheLoopServiceGetHitlDetails(
+ {
+ dagIdPattern: dagId,
+ dagRunId: runId,
+ },
+ undefined,
+ {
+ enabled: !isNaN(pagination.pageSize),
+ refetchInterval,
+ },
+ );
+
+ const filteredData = data?.hitl_details.filter((hitl) => {
+ if (taskId !== undefined) {
+ return hitl.task_instance.task_id === taskId;
+ } else if (groupId !== undefined) {
+ return hitl.task_instance.task_id.includes(groupId);
+ }
+
+ return true;
+ });
+
+ return (
+ <DataTable
+ columns={taskInstanceColumns({
+ dagId,
+ runId,
+ taskId: Boolean(groupId) ? undefined : taskId,
+ translate,
+ })}
+ data={filteredData ?? []}
+ errorMessage={<ErrorAlert error={error} />}
+ initialState={tableURLState}
+ isLoading={isLoading}
+ modelName={translate("hitl:requiredAction_other")}
+ onStateChange={setTableURLState}
+ total={filteredData?.length}
+ />
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/index.ts
similarity index 53%
copy from airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
copy to airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/index.ts
index 1d48249f7ea..98d257769f0 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/index.ts
@@ -16,29 +16,4 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
-
-import type { FlexibleFormElementProps } from ".";
-import { Switch } from "../ui";
-
-export const FieldBool = ({ name }: FlexibleFormElementProps) => {
- const { paramsDict, setParamsDict } = useParamStore();
- const param = paramsDict[name] ?? paramPlaceholder;
- const onCheck = (value: boolean) => {
- if (paramsDict[name]) {
- paramsDict[name].value = value;
- }
-
- setParamsDict(paramsDict);
- };
-
- return (
- <Switch
- checked={Boolean(param.value)}
- colorPalette="blue"
- id={`element_${name}`}
- name={`element_${name}`}
- onCheckedChange={(event) => onCheck(event.checked)}
- />
- );
-};
+export { HITLTaskInstances } from "./HITLTaskInstances";
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
index 776e4e379cd..79dd15a307a 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
@@ -18,11 +18,11 @@
*/
import { ReactFlowProvider } from "@xyflow/react";
import { useTranslation } from "react-i18next";
-import { FiCode, FiDatabase } from "react-icons/fi";
+import { FiCode, FiDatabase, FiUser } from "react-icons/fi";
import { MdDetails, MdOutlineEventNote, MdOutlineTask } from "react-icons/md";
import { useParams } from "react-router-dom";
-import { useDagRunServiceGetDagRun } from "openapi/queries";
+import { useDagRunServiceGetDagRun, useHumanInTheLoopServiceGetHitlDetails }
from "openapi/queries";
import { usePluginTabs } from "src/hooks/usePluginTabs";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
import { isStatePending, useAutoRefresh } from "src/utils";
@@ -38,6 +38,7 @@ export const Run = () => {
const tabs = [
{ icon: <MdOutlineTask />, label: translate("tabs.taskInstances"), value:
"" },
+ { icon: <FiUser />, label: translate("tabs.requiredActions"), value:
"required_actions" },
{ icon: <FiDatabase />, label: translate("tabs.assetEvents"), value:
"asset_events" },
{ icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value:
"events" },
{ icon: <FiCode />, label: translate("tabs.code"), value: "code" },
@@ -62,9 +63,30 @@ export const Run = () => {
},
);
+ const { data: hitlData } = useHumanInTheLoopServiceGetHitlDetails(
+ {
+ dagIdPattern: dagId,
+ dagRunId: runId,
+ },
+ undefined,
+ {
+ enabled: Boolean(dagId && runId),
+ },
+ );
+
+ const hasHitlTasksForRun = Boolean(hitlData?.hitl_details.length);
+
+ const displayTabs = tabs.filter((tab) => {
+ if (tab.value === "required_actions" && !hasHitlTasksForRun) {
+ return false;
+ }
+
+ return true;
+ });
+
return (
<ReactFlowProvider>
- <DetailsLayout error={error} isLoading={isLoading} tabs={tabs}>
+ <DetailsLayout error={error} isLoading={isLoading} tabs={displayTabs}>
{dagRun === undefined ? undefined : (
<Header
dagRun={dagRun}
diff --git a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
index d37e91fe9a9..282f5df4176 100644
--- a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
@@ -18,11 +18,12 @@
*/
import { ReactFlowProvider } from "@xyflow/react";
import { useTranslation } from "react-i18next";
+import { FiUser } from "react-icons/fi";
import { LuChartColumn } from "react-icons/lu";
import { MdOutlineEventNote, MdOutlineTask } from "react-icons/md";
import { useParams } from "react-router-dom";
-import { useTaskServiceGetTask } from "openapi/queries";
+import { useTaskServiceGetTask, useHumanInTheLoopServiceGetHitlDetails } from
"openapi/queries";
import { usePluginTabs } from "src/hooks/usePluginTabs";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
import { useGridStructure } from "src/queries/useGridStructure.ts";
@@ -33,7 +34,7 @@ import { Header } from "./Header";
export const Task = () => {
const { t: translate } = useTranslation("dag");
- const { dagId = "", groupId, taskId } = useParams();
+ const { dagId = "", groupId, runId, taskId } = useParams();
// Get external views with task destination
const externalTabs = usePluginTabs("task");
@@ -41,12 +42,11 @@ export const Task = () => {
const tabs = [
{ icon: <LuChartColumn />, label: translate("tabs.overview"), value: "" },
{ icon: <MdOutlineTask />, label: translate("tabs.taskInstances"), value:
"task_instances" },
+ { icon: <FiUser />, label: translate("tabs.requiredActions"), value:
"required_actions" },
{ icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value:
"events" },
...externalTabs,
];
- const displayTabs = groupId === undefined ? tabs : tabs.filter((tab) =>
tab.value !== "events");
-
const {
data: task,
error,
@@ -59,6 +59,31 @@ export const Task = () => {
const groupTask = getGroupTask(dagStructure, groupId);
+ // Check if this task has any HITL details
+ const { data: hitlData } = useHumanInTheLoopServiceGetHitlDetails(
+ {
+ dagIdPattern: dagId,
+ dagRunId: runId,
+ },
+ undefined,
+ {
+ enabled: Boolean(dagId && (groupId !== undefined || taskId !==
undefined)),
+ },
+ );
+
+ const hasHitlForTask =
+ (hitlData?.hitl_details.filter((hitl) => hitl.task_instance.task_id ===
taskId).length ?? 0) > 0;
+
+ const displayTabs = (groupId === undefined ? tabs : tabs.filter((tab) =>
tab.value !== "events")).filter(
+ (tab) => {
+ if (tab.value === "required_actions" && !hasHitlForTask) {
+ return false;
+ }
+
+ return true;
+ },
+ );
+
return (
<ReactFlowProvider>
<DetailsLayout error={error} isLoading={isLoading} tabs={displayTabs}>
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/HITLResponse.tsx
similarity index 51%
copy from airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
copy to airflow-core/src/airflow/ui/src/pages/TaskInstance/HITLResponse.tsx
index 1d48249f7ea..442954d4614 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/HITLResponse.tsx
@@ -16,29 +16,35 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
+import { Box } from "@chakra-ui/react";
+import { useParams } from "react-router-dom";
-import type { FlexibleFormElementProps } from ".";
-import { Switch } from "../ui";
+import { useHumanInTheLoopServiceGetMappedTiHitlDetail } from
"openapi/queries";
+import { ProgressBar } from "src/components/ui";
-export const FieldBool = ({ name }: FlexibleFormElementProps) => {
- const { paramsDict, setParamsDict } = useParamStore();
- const param = paramsDict[name] ?? paramPlaceholder;
- const onCheck = (value: boolean) => {
- if (paramsDict[name]) {
- paramsDict[name].value = value;
- }
+import { HITLResponseForm } from "../HITLTaskInstances/HITLResponseForm";
- setParamsDict(paramsDict);
- };
+export const HITLResponse = () => {
+ const { dagId, mapIndex, runId, taskId } = useParams();
+
+ const { data: hitlDetail } = useHumanInTheLoopServiceGetMappedTiHitlDetail({
+ dagId: dagId ?? "~",
+ dagRunId: runId ?? "~",
+ mapIndex: Number(mapIndex ?? -1),
+ taskId: taskId ?? "~",
+ });
+
+ if (!hitlDetail) {
+ return (
+ <Box flexGrow={1}>
+ <ProgressBar />
+ </Box>
+ );
+ }
return (
- <Switch
- checked={Boolean(param.value)}
- colorPalette="blue"
- id={`element_${name}`}
- name={`element_${name}`}
- onCheckedChange={(event) => onCheck(event.checked)}
- />
+ <Box px={4}>
+ <HITLResponseForm hitlDetail={hitlDetail} />
+ </Box>
);
};
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
index f1a7c490d28..9a432a1f571 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
@@ -19,12 +19,15 @@
import { ReactFlowProvider } from "@xyflow/react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
-import { FiCode, FiDatabase } from "react-icons/fi";
+import { FiCode, FiDatabase, FiUser } from "react-icons/fi";
import { MdDetails, MdOutlineEventNote, MdOutlineTask, MdReorder, MdSyncAlt }
from "react-icons/md";
import { PiBracketsCurlyBold } from "react-icons/pi";
import { useParams } from "react-router-dom";
-import { useTaskInstanceServiceGetMappedTaskInstance } from "openapi/queries";
+import {
+ useHumanInTheLoopServiceGetHitlDetails,
+ useTaskInstanceServiceGetMappedTaskInstance,
+} from "openapi/queries";
import { usePluginTabs } from "src/hooks/usePluginTabs";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts";
@@ -41,6 +44,7 @@ export const TaskInstance = () => {
const tabs = [
{ icon: <MdReorder />, label: translate("tabs.logs"), value: "" },
+ { icon: <FiUser />, label: translate("tabs.requiredActions"), value:
"required_actions" },
{
icon: <PiBracketsCurlyBold />,
label: translate("tabs.renderedTemplates"),
@@ -75,6 +79,22 @@ export const TaskInstance = () => {
const { data: gridTISummaries } = useGridTiSummaries({ dagId, runId });
+ const { data: hitlDetails } = useHumanInTheLoopServiceGetHitlDetails(
+ {
+ dagIdPattern: dagId,
+ dagRunId: runId,
+ },
+ undefined,
+ {
+ enabled: Boolean(dagId && runId),
+ refetchInterval,
+ },
+ );
+
+ const hasHitlForTask = Boolean(
+ hitlDetails?.hitl_details.find((hitl) => hitl.task_instance.task_id ===
taskId),
+ );
+
const taskInstanceSummary = gridTISummaries?.task_instances.find((ti) =>
ti.task_id === taskId);
const taskCount = useMemo(
() =>
@@ -101,9 +121,17 @@ export const TaskInstance = () => {
];
}
+ const displayTabs = newTabs.filter((tab) => {
+ if (tab.value === "required_actions" && !hasHitlForTask) {
+ return false;
+ }
+
+ return true;
+ });
+
return (
<ReactFlowProvider>
- <DetailsLayout error={error} isLoading={isLoading} tabs={newTabs}>
+ <DetailsLayout error={error} isLoading={isLoading} tabs={displayTabs}>
{taskInstance === undefined ? undefined : (
<Header
isRefreshing={Boolean(isStatePending(taskInstance.state) &&
Boolean(refetchInterval))}
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
b/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
index 8e83952815c..9535d31ae48 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
@@ -23,6 +23,8 @@ import {
useDagRunServiceDeleteDagRun,
useDagRunServiceGetDagRunsKey,
UseDagRunServiceGetDagRunKeyFn,
+ useTaskInstanceServiceGetTaskInstancesKey,
+ useHumanInTheLoopServiceGetHitlDetailsKey,
} from "openapi/queries";
import { toaster } from "src/components/ui";
@@ -45,7 +47,12 @@ export const useDeleteDagRun = ({ dagId, dagRunId,
onSuccessConfirm }: DeleteDag
};
const onSuccess = async () => {
- const queryKeys = [UseDagRunServiceGetDagRunKeyFn({ dagId, dagRunId }),
[useDagRunServiceGetDagRunsKey]];
+ const queryKeys = [
+ UseDagRunServiceGetDagRunKeyFn({ dagId, dagRunId }),
+ [useDagRunServiceGetDagRunsKey],
+ [useTaskInstanceServiceGetTaskInstancesKey],
+ [useHumanInTheLoopServiceGetHitlDetailsKey],
+ ];
await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({
queryKey: key })));
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
b/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
index d2ec715a011..abf22e0d787 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
@@ -25,6 +25,7 @@ import {
useTaskInstanceServiceGetTaskInstancesKey,
useDagRunServiceGetDagRunsKey,
UseDagRunServiceGetDagRunKeyFn,
+ useHumanInTheLoopServiceGetHitlDetailsKey,
} from "openapi/queries";
import { toaster } from "src/components/ui";
@@ -60,6 +61,7 @@ export const useDeleteTaskInstance = ({
[useDagRunServiceGetDagRunsKey],
[useTaskInstanceServiceGetTaskInstancesKey],
[useTaskInstanceServiceGetTaskInstanceKey, { dagId, dagRunId, mapIndex,
taskId }],
+ [useHumanInTheLoopServiceGetHitlDetailsKey],
];
await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({
queryKey: key })));
diff --git a/airflow-core/src/airflow/ui/src/queries/useParamStore.ts
b/airflow-core/src/airflow/ui/src/queries/useParamStore.ts
index 749bf2b34f4..6008985797d 100644
--- a/airflow-core/src/airflow/ui/src/queries/useParamStore.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useParamStore.ts
@@ -44,15 +44,18 @@ export const paramPlaceholder: ParamSpec = {
type FormStore = {
conf: string;
+ disabled: boolean;
initialParamDict: ParamsSpec;
paramsDict: ParamsSpec;
setConf: (confString: string) => void;
+ setDisabled: (disabled: boolean) => void;
setInitialParamDict: (newParamsDict: ParamsSpec) => void;
setParamsDict: (newParamsDict: ParamsSpec) => void;
};
export const useParamStore = create<FormStore>((set) => ({
conf: "{}",
+ disabled: false,
initialParamDict: {},
paramsDict: {},
@@ -83,6 +86,8 @@ export const useParamStore = create<FormStore>((set) => ({
return { conf: confString, paramsDict: updatedParamsDict };
}),
+ setDisabled: (disabled: boolean) => set(() => ({ disabled })),
+
setInitialParamDict: (newParamsDict: ParamsSpec) => set(() => ({
initialParamDict: newParamsDict })),
setParamsDict: (newParamsDict: ParamsSpec) =>
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
similarity index 53%
copy from airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
copy to airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
index d2ec715a011..3740bdba7c4 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
@@ -17,68 +17,82 @@
* under the License.
*/
import { useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
- useTaskInstanceServiceDeleteTaskInstance,
+ UseDagRunServiceGetDagRunKeyFn,
+ useDagRunServiceGetDagRunsKey,
+ useHumanInTheLoopServiceGetHitlDetailsKey,
+ useHumanInTheLoopServiceGetMappedTiHitlDetailKey,
+ useHumanInTheLoopServiceUpdateMappedTiHitlDetail,
useTaskInstanceServiceGetTaskInstanceKey,
useTaskInstanceServiceGetTaskInstancesKey,
- useDagRunServiceGetDagRunsKey,
- UseDagRunServiceGetDagRunKeyFn,
} from "openapi/queries";
-import { toaster } from "src/components/ui";
+import { toaster } from "src/components/ui/Toaster";
+import type { HITLResponseParams } from "src/utils/hitl";
-type DeleteTaskInstanceParams = {
- dagId: string;
- dagRunId: string;
- mapIndex?: number;
- onSuccessConfirm: () => void;
- taskId: string;
-};
-
-export const useDeleteTaskInstance = ({
+export const useUpdateHITLDetail = ({
dagId,
dagRunId,
mapIndex,
- onSuccessConfirm,
taskId,
-}: DeleteTaskInstanceParams) => {
+}: {
+ dagId: string;
+ dagRunId: string;
+ mapIndex: number | undefined;
+ taskId: string;
+}) => {
const queryClient = useQueryClient();
- const { t: translate } = useTranslation(["common", "dags"]);
-
- const onError = (error: Error) => {
- toaster.create({
- description: error.message,
- title: translate("dags:runAndTaskActions.delete.error", { type:
translate("taskInstance_one") }),
- type: "error",
- });
- };
-
+ const [error, setError] = useState<unknown>(undefined);
+ const { t: translate } = useTranslation(["common", "hitl"]);
const onSuccess = async () => {
const queryKeys = [
UseDagRunServiceGetDagRunKeyFn({ dagId, dagRunId }),
[useDagRunServiceGetDagRunsKey],
- [useTaskInstanceServiceGetTaskInstancesKey],
+ [useTaskInstanceServiceGetTaskInstancesKey, { dagId, dagRunId }],
[useTaskInstanceServiceGetTaskInstanceKey, { dagId, dagRunId, mapIndex,
taskId }],
+ [useHumanInTheLoopServiceGetHitlDetailsKey, { dagIdPattern: dagId,
dagRunId }],
+ [useHumanInTheLoopServiceGetMappedTiHitlDetailKey, { dagId, dagRunId }],
];
await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({
queryKey: key })));
toaster.create({
- description:
translate("dags:runAndTaskActions.delete.success.description", {
- type: translate("taskInstance_one"),
- }),
- title: translate("dags:runAndTaskActions.delete.success.title", {
- type: translate("taskInstance_one"),
- }),
+ title: translate("hitl:response.success", { taskId }),
type: "success",
});
+ };
- onSuccessConfirm();
+ const onError = (_error: Error) => {
+ toaster.create({
+ description: _error.message,
+ title: translate("hitl:response.error"),
+ type: "error",
+ });
};
- return useTaskInstanceServiceDeleteTaskInstance({
+ const { isPending, mutate } =
useHumanInTheLoopServiceUpdateMappedTiHitlDetail({
onError,
onSuccess,
});
+
+ const updateHITLResponse = (updateHITLResponseRequestBody:
HITLResponseParams) => {
+ try {
+ mutate({
+ dagId,
+ dagRunId,
+ mapIndex: mapIndex ?? -1,
+ requestBody: {
+ chosen_options: updateHITLResponseRequestBody.chosen_options ?? [],
+ params_input: updateHITLResponseRequestBody.params_input ?? {},
+ },
+ taskId,
+ });
+ } catch (parseError) {
+ setError(parseError);
+ }
+ };
+
+ return { error, isPending, setError, updateHITLResponse };
};
diff --git a/airflow-core/src/airflow/ui/src/router.tsx
b/airflow-core/src/airflow/ui/src/router.tsx
index fca1828e8ba..07ac8b7dd56 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -40,6 +40,7 @@ import { ErrorPage } from "src/pages/Error";
import { Events } from "src/pages/Events";
import { ExternalView } from "src/pages/ExternalView";
import { GroupTaskInstance } from "src/pages/GroupTaskInstance";
+import { HITLTaskInstances } from "src/pages/HITLTaskInstances";
import { MappedTaskInstance } from "src/pages/MappedTaskInstance";
import { Plugins } from "src/pages/Plugins";
import { Pools } from "src/pages/Pools";
@@ -53,6 +54,7 @@ import { Overview as TaskOverview } from
"src/pages/Task/Overview";
import { TaskInstance, Logs } from "src/pages/TaskInstance";
import { AssetEvents as TaskInstanceAssetEvents } from
"src/pages/TaskInstance/AssetEvents";
import { Details as TaskInstanceDetails } from
"src/pages/TaskInstance/Details";
+import { HITLResponse } from "src/pages/TaskInstance/HITLResponse";
import { RenderedTemplates } from "src/pages/TaskInstance/RenderedTemplates";
import { TaskInstances } from "src/pages/TaskInstances";
import { Variables } from "src/pages/Variables";
@@ -74,6 +76,7 @@ const taskInstanceRoutes = [
{ element: <RenderedTemplates />, path: "rendered_templates" },
{ element: <TaskInstances />, path: "task_instances" },
{ element: <TaskInstanceAssetEvents />, path: "asset_events" },
+ { element: <HITLResponse />, path: "required_actions" },
pluginRoute,
];
@@ -84,6 +87,10 @@ export const routerConfig = [
element: <Dashboard />,
index: true,
},
+ {
+ element: <HITLTaskInstances />,
+ path: "required_actions",
+ },
{
element: <DagsList />,
path: "dags",
@@ -154,6 +161,7 @@ export const routerConfig = [
{ element: <Overview />, index: true },
{ element: <DagRuns />, path: "runs" },
{ element: <Tasks />, path: "tasks" },
+ { element: <HITLTaskInstances />, path: "required_actions" },
{ element: <Backfills />, path: "backfills" },
{ element: <Events />, path: "events" },
{ element: <Code />, path: "code" },
@@ -166,6 +174,7 @@ export const routerConfig = [
{
children: [
{ element: <TaskInstances />, index: true },
+ { element: <HITLTaskInstances />, path: "required_actions" },
{ element: <Events />, path: "events" },
{ element: <Code />, path: "code" },
{ element: <DagRunDetails />, path: "details" },
@@ -194,6 +203,7 @@ export const routerConfig = [
children: [
{ element: <TaskOverview />, index: true },
{ element: <TaskInstances />, path: "task_instances" },
+ { element: <HITLTaskInstances />, path: "required_actions" },
pluginRoute,
],
element: <Task />,
@@ -208,6 +218,7 @@ export const routerConfig = [
children: [
{ element: <TaskOverview />, index: true },
{ element: <TaskInstances />, path: "task_instances" },
+ { element: <HITLTaskInstances />, path: "required_actions" },
{ element: <Events />, path: "events" },
pluginRoute,
],
diff --git a/airflow-core/src/airflow/ui/src/utils/hitl.ts
b/airflow-core/src/airflow/ui/src/utils/hitl.ts
new file mode 100644
index 00000000000..8577ccd8911
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/hitl.ts
@@ -0,0 +1,144 @@
+/*!
+ * 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 type { TFunction } from "i18next";
+
+import type { HITLDetail } from "openapi/requests/types.gen";
+import type { ParamsSpec } from "src/queries/useDagParams";
+
+export type HITLResponseParams = {
+ chosen_options?: Array<string>;
+ params_input?: Record<string, unknown>;
+};
+
+const getChosenOptionsValue = (hitlDetail: HITLDetail) => {
+ // if response_received is true, display the chosen_options, otherwise
display the defaults
+ const sourceValues = hitlDetail.response_received ?
hitlDetail.chosen_options : hitlDetail.defaults;
+
+ return hitlDetail.multiple ? sourceValues : sourceValues?.[0];
+};
+
+export const getHITLParamsDict = (hitlDetail: HITLDetail, translate:
TFunction): ParamsSpec => {
+ const paramsDict: ParamsSpec = {};
+
+ if (hitlDetail.options.length > 4 || hitlDetail.multiple) {
+ paramsDict.chosen_options = {
+ description: translate("hitl:response.optionsDescription"),
+ schema: {
+ const: undefined,
+ description_md: translate("hitl:response.optionsDescription"),
+ enum: hitlDetail.options.length > 0 ? hitlDetail.options : undefined,
+ examples: undefined,
+ format: undefined,
+ items: hitlDetail.multiple ? { type: "string" } : undefined,
+ maximum: undefined,
+ maxLength: undefined,
+ minimum: undefined,
+ minLength: undefined,
+ section: undefined,
+ title: translate("hitl:response.optionsLabel"),
+ type: hitlDetail.multiple ? "array" : "string",
+ values_display: undefined,
+ },
+
+ value: getChosenOptionsValue(hitlDetail),
+ };
+ }
+
+ if (hitlDetail.params) {
+ const sourceParams = hitlDetail.response_received ?
hitlDetail.params_input : hitlDetail.params;
+
+ Object.entries(sourceParams ?? {}).forEach(([key, value]) => {
+ const valueType = typeof value === "number" ? "number" : "string";
+
+ paramsDict[key] = {
+ description: "",
+ schema: {
+ const: undefined,
+ description_md: "",
+ enum: undefined,
+ examples: undefined,
+ format: undefined,
+ items: undefined,
+ maximum: undefined,
+ maxLength: undefined,
+ minimum: undefined,
+ minLength: undefined,
+ section: undefined,
+ title: key,
+ type: valueType,
+ values_display: undefined,
+ },
+ value,
+ };
+ });
+ }
+
+ return paramsDict;
+};
+
+export const getHITLFormData = (paramsDict: ParamsSpec, option?: string):
HITLResponseParams => {
+ const chosenOptionsValue = paramsDict.chosen_options?.value;
+ let chosenOptions: Array<string> = [];
+
+ if (option === undefined) {
+ if (typeof chosenOptionsValue === "string" && chosenOptionsValue) {
+ chosenOptions = [chosenOptionsValue];
+ } else if (Array.isArray(chosenOptionsValue) && chosenOptionsValue.length
> 0) {
+ chosenOptions = chosenOptionsValue.filter(
+ (value): value is string => value !== null && value !== undefined,
+ );
+ }
+ } else {
+ chosenOptions = [option];
+ }
+
+ const paramsInput = Object.keys(paramsDict)
+ .filter((key) => key !== "chosen_options")
+ .reduce<Record<string, unknown>>((acc, key) => {
+ acc[key] = paramsDict[key]?.value;
+
+ return acc;
+ }, {});
+
+ return {
+ chosen_options: chosenOptions,
+ params_input: paramsInput,
+ };
+};
+
+export const getHITLState = (translate: TFunction, hitlDetail: HITLDetail) => {
+ const { chosen_options: chosenOptions, options, params, response_received:
responseReceived } = hitlDetail;
+
+ let stateType: [string, string] = ["responseRequired", "responseReceived"];
+
+ if (options.length === 2 && options.includes("Approve") &&
options.includes("Reject")) {
+ // If options contain only "Approve" and "Reject" -> approval task
+ stateType = [
+ "approvalRequired",
+ responseReceived && chosenOptions?.includes("Approve") ?
"approvalReceived" : "rejectionReceived",
+ ];
+ } else if (params && Object.keys(params).length === 0) {
+ // If it's not an approval task and params are empty -> choice task
+ stateType = ["choiceRequired", "choiceReceived"];
+ }
+
+ const [required, received] = stateType;
+
+ return translate(`hitl:state.${responseReceived ? received : required}`);
+};
diff --git
a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
index b8db2507cc2..b560205298a 100644
--- a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
+++ b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
@@ -66,6 +66,7 @@ from
airflow.providers.fab.auth_manager.cli_commands.definition import (
)
from airflow.providers.fab.auth_manager.models import Permission, Role, User
from airflow.providers.fab.auth_manager.models.anonymous_user import
AnonymousUser
+from airflow.providers.fab.version_compat import AIRFLOW_V_3_1_PLUS
from airflow.providers.fab.www.app import create_app
from airflow.providers.fab.www.constants import SWAGGER_BUNDLE, SWAGGER_ENABLED
from airflow.providers.fab.www.extensions.init_views import (
@@ -170,6 +171,12 @@ _MAP_MENU_ITEM_TO_FAB_RESOURCE_TYPE = {
}
+if AIRFLOW_V_3_1_PLUS:
+ from airflow.providers.fab.www.security.permissions import
RESOURCE_HITL_DETAIL
+
+ _MAP_MENU_ITEM_TO_FAB_RESOURCE_TYPE[MenuItem.REQUIRED_ACTIONS] =
RESOURCE_HITL_DETAIL
+
+
class FabAuthManager(BaseAuthManager[User]):
"""
Flask-AppBuilder auth manager.
diff --git
a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
index b242e5547d8..1366f45dee4 100644
---
a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
+++
b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
@@ -233,6 +233,7 @@ class
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
(permissions.ACTION_CAN_READ, permissions.RESOURCE_TASK_INSTANCE),
(permissions.ACTION_CAN_READ, permissions.RESOURCE_TASK_LOG),
(permissions.ACTION_CAN_READ, permissions.RESOURCE_XCOM),
+ (permissions.ACTION_CAN_READ, permissions.RESOURCE_HITL_DETAIL),
(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE),
(permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_BROWSE_MENU),
(permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_DAG),
@@ -258,6 +259,7 @@ class
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
(permissions.ACTION_CAN_CREATE, permissions.RESOURCE_DAG_RUN),
(permissions.ACTION_CAN_EDIT, permissions.RESOURCE_DAG_RUN),
(permissions.ACTION_CAN_DELETE, permissions.RESOURCE_DAG_RUN),
+ (permissions.ACTION_CAN_EDIT, permissions.RESOURCE_HITL_DETAIL),
(permissions.ACTION_CAN_CREATE, RESOURCE_ASSET),
]
# [END security_user_perms]
@@ -272,6 +274,7 @@ class
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
(permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_VARIABLE),
(permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_PROVIDER),
(permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_XCOM),
+ (permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_HITL_DETAIL),
(permissions.ACTION_CAN_READ, permissions.RESOURCE_CONFIG),
(permissions.ACTION_CAN_CREATE, permissions.RESOURCE_CONNECTION),
(permissions.ACTION_CAN_READ, permissions.RESOURCE_CONNECTION),
diff --git
a/providers/fab/src/airflow/providers/fab/www/security/permissions.py
b/providers/fab/src/airflow/providers/fab/www/security/permissions.py
index 35a66222fce..43a9f4628cd 100644
--- a/providers/fab/src/airflow/providers/fab/www/security/permissions.py
+++ b/providers/fab/src/airflow/providers/fab/www/security/permissions.py
@@ -39,6 +39,7 @@ RESOURCE_ASSET = "Assets"
RESOURCE_ASSET_ALIAS = "Asset Aliases"
RESOURCE_DOCS = "Documentation"
RESOURCE_DOCS_MENU = "Docs"
+RESOURCE_HITL_DETAIL = "HITL Detail"
RESOURCE_IMPORT_ERROR = "ImportError"
RESOURCE_JOB = "Jobs"
RESOURCE_MY_PASSWORD = "My Password"
diff --git a/providers/fab/tests/unit/fab/auth_manager/test_security.py
b/providers/fab/tests/unit/fab/auth_manager/test_security.py
index fe16650e3c2..adc94b4b7bd 100644
--- a/providers/fab/tests/unit/fab/auth_manager/test_security.py
+++ b/providers/fab/tests/unit/fab/auth_manager/test_security.py
@@ -448,6 +448,7 @@ def test_get_user_roles_for_anonymous_user(app,
security_manager):
(permissions.ACTION_CAN_READ, permissions.RESOURCE_TASK_INSTANCE),
(permissions.ACTION_CAN_READ, permissions.RESOURCE_TASK_LOG),
(permissions.ACTION_CAN_READ, permissions.RESOURCE_XCOM),
+ (permissions.ACTION_CAN_READ, permissions.RESOURCE_HITL_DETAIL),
(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE),
(permissions.ACTION_CAN_READ, permissions.RESOURCE_MY_PASSWORD),
(permissions.ACTION_CAN_EDIT, permissions.RESOURCE_MY_PASSWORD),
diff --git a/providers/fab/www-hash.txt b/providers/fab/www-hash.txt
index f27a39fa77f..c9b88e261fe 100644
--- a/providers/fab/www-hash.txt
+++ b/providers/fab/www-hash.txt
@@ -1 +1 @@
-7c75393001a5280d25bca03d39c13ec8072a12a3628628398379473fa85da21e
+3170cf1d4c18142de4e6821f6cb9be641110623b6ea6b0235f4cb7ee97644e1f