This is an automated email from the ASF dual-hosted git repository.
kaxilnaik 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 62969b6b652 Add asset expression schedule popover (#47645)
62969b6b652 is described below
commit 62969b6b65208cd062dd949b0abdc302715031fa
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Mar 12 03:27:41 2025 -0400
Add asset expression schedule popover (#47645)
For asset triggered dags, add a popover to the Schedule component to see a
visualization of the asset conditions to trigger the dag.
- For regularly scheduled dags, the schedule is just text with a tooltip.
For Asset-triggered dags, it is a button to open a popover
- Added `asset_expression` to `ui/dags`
- To Do: Add event info and links to the asset details page when we add
`asset_id` to `asset_expression`. Right now there is no good way to connect an
expression to its actual asset. We can use this to show the queue for the next
dag run, or if a dag run is selected, what events caused that dag to trigger.
We could also add the Asset Event Creation trigger inside this popover.
<img width="399" alt="Screenshot 2025-03-11 at 5 12 25 PM"
src="https://github.com/user-attachments/assets/bb46c5eb-79a8-46fb-baf0-16f8650c32f0"
/>
<img width="798" alt="Screenshot 2025-03-11 at 5 12 36 PM"
src="https://github.com/user-attachments/assets/ed7af4aa-4106-423b-9a40-a1aa80b3e899"
/>
---
airflow/api_fastapi/core_api/datamodels/ui/dags.py | 1 +
.../api_fastapi/core_api/openapi/v1-generated.yaml | 6 ++
airflow/api_fastapi/core_api/routes/ui/dags.py | 2 +
airflow/ui/openapi-gen/requests/schemas.gen.ts | 12 ++++
airflow/ui/openapi-gen/requests/types.gen.ts | 3 +
.../AssetExpression/AndGateNode.tsx} | 48 +++++++++----
.../components/AssetExpression/AssetExpression.tsx | 79 ++++++++++++++++++++++
.../src/components/AssetExpression/AssetNode.tsx | 59 ++++++++++++++++
.../AssetExpression/OrGateNode.tsx} | 23 +++----
.../AssetExpression/index.ts} | 16 +----
.../AssetExpression/types.ts} | 23 +++----
airflow/ui/src/pages/Dag/Header.tsx | 13 +---
airflow/ui/src/pages/DagsList/DagCard.test.tsx | 1 +
airflow/ui/src/pages/DagsList/Schedule.tsx | 35 ++++++++--
airflow/ui/src/queries/useDags.tsx | 2 +
15 files changed, 252 insertions(+), 71 deletions(-)
diff --git a/airflow/api_fastapi/core_api/datamodels/ui/dags.py
b/airflow/api_fastapi/core_api/datamodels/ui/dags.py
index 991f5096b3d..08588ef7856 100644
--- a/airflow/api_fastapi/core_api/datamodels/ui/dags.py
+++ b/airflow/api_fastapi/core_api/datamodels/ui/dags.py
@@ -25,6 +25,7 @@ from airflow.api_fastapi.core_api.datamodels.dags import
DAGResponse
class DAGWithLatestDagRunsResponse(DAGResponse):
"""DAG with latest dag runs response serializer."""
+ asset_expression: dict | None
latest_dag_runs: list[DAGRunResponse]
diff --git a/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
b/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
index 58b65bca378..0eab45da497 100644
--- a/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
+++ b/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
@@ -9050,6 +9050,11 @@ components:
type: string
type: array
title: Owners
+ asset_expression:
+ anyOf:
+ - type: object
+ - type: 'null'
+ title: Asset Expression
latest_dag_runs:
items:
$ref: '#/components/schemas/DAGRunResponse'
@@ -9083,6 +9088,7 @@ components:
- next_dagrun_data_interval_end
- next_dagrun_run_after
- owners
+ - asset_expression
- latest_dag_runs
- file_token
title: DAGWithLatestDagRunsResponse
diff --git a/airflow/api_fastapi/core_api/routes/ui/dags.py
b/airflow/api_fastapi/core_api/routes/ui/dags.py
index 11258f590f9..1c2282cb4e4 100644
--- a/airflow/api_fastapi/core_api/routes/ui/dags.py
+++ b/airflow/api_fastapi/core_api/routes/ui/dags.py
@@ -148,9 +148,11 @@ def recent_dag_runs(
dag_run_response = DAGRunResponse.model_validate(dag_run)
if dag_id not in dag_runs_by_dag_id:
dag_response = DAGResponse.model_validate(dag)
+ dag_model: DagModel = session.get(DagModel, dag.dag_id)
dag_runs_by_dag_id[dag_id] =
DAGWithLatestDagRunsResponse.model_validate(
{
**dag_response.model_dump(),
+ "asset_expression": dag_model.asset_expression,
"latest_dag_runs": [dag_run_response],
}
)
diff --git a/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 6b67a04c0be..6756181f8f8 100644
--- a/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -2878,6 +2878,17 @@ export const $DAGWithLatestDagRunsResponse = {
type: "array",
title: "Owners",
},
+ asset_expression: {
+ anyOf: [
+ {
+ type: "object",
+ },
+ {
+ type: "null",
+ },
+ ],
+ title: "Asset Expression",
+ },
latest_dag_runs: {
items: {
$ref: "#/components/schemas/DAGRunResponse",
@@ -2915,6 +2926,7 @@ export const $DAGWithLatestDagRunsResponse = {
"next_dagrun_data_interval_end",
"next_dagrun_run_after",
"owners",
+ "asset_expression",
"latest_dag_runs",
"file_token",
],
diff --git a/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow/ui/openapi-gen/requests/types.gen.ts
index 5a6a61057ec..1ed2a13c959 100644
--- a/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -750,6 +750,9 @@ export type DAGWithLatestDagRunsResponse = {
next_dagrun_data_interval_end: string | null;
next_dagrun_run_after: string | null;
owners: Array<string>;
+ asset_expression: {
+ [key: string]: unknown;
+ } | null;
latest_dag_runs: Array<DAGRunResponse>;
/**
* Return file token.
diff --git a/airflow/ui/src/pages/DagsList/Schedule.tsx
b/airflow/ui/src/components/AssetExpression/AndGateNode.tsx
similarity index 51%
copy from airflow/ui/src/pages/DagsList/Schedule.tsx
copy to airflow/ui/src/components/AssetExpression/AndGateNode.tsx
index 9f0ff07da82..37fd8ee8b6c 100644
--- a/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow/ui/src/components/AssetExpression/AndGateNode.tsx
@@ -16,18 +16,38 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Text } from "@chakra-ui/react";
+import { Box, VStack, Badge } from "@chakra-ui/react";
+import type { PropsWithChildren } from "react";
+import { TbLogicAnd } from "react-icons/tb";
-import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
-import { Tooltip } from "src/components/ui";
-
-type Props = {
- readonly dag: DAGWithLatestDagRunsResponse;
-};
-
-export const Schedule = ({ dag }: Props) =>
- Boolean(dag.timetable_summary) && dag.timetable_description !== "Never,
external triggers only" ? (
- <Tooltip content={dag.timetable_description}>
- <Text fontSize="sm">{dag.timetable_summary}</Text>
- </Tooltip>
- ) : undefined;
+export const AndGateNode = ({ children }: PropsWithChildren) => (
+ <Box
+ bg="bg.emphasized"
+ border="2px dashed"
+ borderRadius="lg"
+ display="inline-block"
+ minW="fit-content"
+ p={4}
+ position="relative"
+ >
+ <Badge
+ alignItems="center"
+ borderRadius="full"
+ display="flex"
+ fontSize="sm"
+ gap={1}
+ left="50%"
+ position="absolute"
+ px={3}
+ py={1}
+ top="-3"
+ transform="translateX(-50%)"
+ >
+ <TbLogicAnd size={18} />
+ AND
+ </Badge>
+ <VStack align="center" gap={4} mt={3}>
+ {children}
+ </VStack>
+ </Box>
+);
diff --git a/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
b/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
new file mode 100644
index 00000000000..0c4f9c7caf8
--- /dev/null
+++ b/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
@@ -0,0 +1,79 @@
+/*!
+ * 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 { Box, Badge } from "@chakra-ui/react";
+import { Fragment } from "react";
+import { TbLogicOr } from "react-icons/tb";
+
+import type { QueuedEventResponse } from "openapi/requests/types.gen";
+
+import { AndGateNode } from "./AndGateNode";
+import { AssetNode } from "./AssetNode";
+import { OrGateNode } from "./OrGateNode";
+import type { ExpressionType } from "./types";
+
+export const AssetExpression = ({
+ events,
+ expression,
+}: {
+ readonly events?: Array<QueuedEventResponse>;
+ readonly expression: ExpressionType | null;
+}) => {
+ if (expression === null) {
+ return undefined;
+ }
+
+ return (
+ <>
+ {expression.any ? (
+ <OrGateNode>
+ {expression.any.map((item, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+ <Fragment key={`any-${index}`}>
+ {"asset" in item ? (
+ <AssetNode asset={item.asset} />
+ ) : (
+ <AssetExpression events={events} expression={item} />
+ )}
+ {expression.any && index === expression.any.length - 1 ?
undefined : (
+ <Badge alignItems="center" borderRadius="full" fontSize="sm"
px={3} py={1}>
+ <TbLogicOr size={18} />
+ OR
+ </Badge>
+ )}
+ </Fragment>
+ ))}
+ </OrGateNode>
+ ) : undefined}
+ {expression.all ? (
+ <AndGateNode>
+ {expression.all.map((item, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+ <Box display="inline-block" key={`all-${index}`}>
+ {"asset" in item ? (
+ <AssetNode asset={item.asset} />
+ ) : (
+ <AssetExpression events={events} expression={item} />
+ )}
+ </Box>
+ ))}
+ </AndGateNode>
+ ) : undefined}
+ </>
+ );
+};
diff --git a/airflow/ui/src/components/AssetExpression/AssetNode.tsx
b/airflow/ui/src/components/AssetExpression/AssetNode.tsx
new file mode 100644
index 00000000000..accec69af6a
--- /dev/null
+++ b/airflow/ui/src/components/AssetExpression/AssetNode.tsx
@@ -0,0 +1,59 @@
+/*!
+ * 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 { Box, Text, VStack, HStack } from "@chakra-ui/react";
+import { FiDatabase } from "react-icons/fi";
+
+import type { QueuedEventResponse } from "openapi/requests/types.gen";
+
+import Time from "../Time";
+import type { AssetSummary } from "./types";
+
+export const AssetNode = ({
+ asset,
+ event,
+}: {
+ readonly asset: AssetSummary["asset"];
+ readonly event?: QueuedEventResponse;
+}) => (
+ <Box
+ bg="bg.muted"
+ border="1px solid"
+ borderRadius="md"
+ borderWidth={Boolean(event?.created_at) ? 3 : 1}
+ display="inline-block"
+ minW="fit-content"
+ p={3}
+ position="relative"
+ >
+ <VStack align="start" gap={2}>
+ <HStack gap={2}>
+ <FiDatabase />
+ {/* TODO add events back in when asset_expression contains asset_id */}
+ {/* {event?.id === undefined ? ( */}
+ <Text fontSize="sm">{asset.uri}</Text>
+ {/* ) : (
+ <Link asChild color="fg.info" display="block" py={2}>
+ <RouterLink to={`/assets/${event.id}`}>{asset.uri}</RouterLink>
+ </Link>
+ )} */}
+ </HStack>
+ <Time datetime={event?.created_at} />
+ </VStack>
+ </Box>
+);
diff --git a/airflow/ui/src/pages/DagsList/Schedule.tsx
b/airflow/ui/src/components/AssetExpression/OrGateNode.tsx
similarity index 61%
copy from airflow/ui/src/pages/DagsList/Schedule.tsx
copy to airflow/ui/src/components/AssetExpression/OrGateNode.tsx
index 9f0ff07da82..1b6a7065cca 100644
--- a/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow/ui/src/components/AssetExpression/OrGateNode.tsx
@@ -16,18 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Text } from "@chakra-ui/react";
+import { Box, HStack } from "@chakra-ui/react";
+import type { PropsWithChildren } from "react";
-import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
-import { Tooltip } from "src/components/ui";
-
-type Props = {
- readonly dag: DAGWithLatestDagRunsResponse;
-};
-
-export const Schedule = ({ dag }: Props) =>
- Boolean(dag.timetable_summary) && dag.timetable_description !== "Never,
external triggers only" ? (
- <Tooltip content={dag.timetable_description}>
- <Text fontSize="sm">{dag.timetable_summary}</Text>
- </Tooltip>
- ) : undefined;
+export const OrGateNode = ({ children }: PropsWithChildren) => (
+ <Box bg="bg.emphasized" border="2px dashed" borderRadius="lg"
minW="fit-content" p={4} position="relative">
+ <HStack align="center" gap={4} mt={3}>
+ {children}
+ </HStack>
+ </Box>
+);
diff --git a/airflow/ui/src/pages/DagsList/Schedule.tsx
b/airflow/ui/src/components/AssetExpression/index.ts
similarity index 61%
copy from airflow/ui/src/pages/DagsList/Schedule.tsx
copy to airflow/ui/src/components/AssetExpression/index.ts
index 9f0ff07da82..c54f53ab6c5 100644
--- a/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow/ui/src/components/AssetExpression/index.ts
@@ -16,18 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Text } from "@chakra-ui/react";
-import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
-import { Tooltip } from "src/components/ui";
-
-type Props = {
- readonly dag: DAGWithLatestDagRunsResponse;
-};
-
-export const Schedule = ({ dag }: Props) =>
- Boolean(dag.timetable_summary) && dag.timetable_description !== "Never,
external triggers only" ? (
- <Tooltip content={dag.timetable_description}>
- <Text fontSize="sm">{dag.timetable_summary}</Text>
- </Tooltip>
- ) : undefined;
+export { AssetExpression } from "./AssetExpression";
+export type { ExpressionType, AssetSummary } from "./types";
diff --git a/airflow/ui/src/pages/DagsList/Schedule.tsx
b/airflow/ui/src/components/AssetExpression/types.ts
similarity index 61%
copy from airflow/ui/src/pages/DagsList/Schedule.tsx
copy to airflow/ui/src/components/AssetExpression/types.ts
index 9f0ff07da82..512fcece5df 100644
--- a/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow/ui/src/components/AssetExpression/types.ts
@@ -16,18 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Text } from "@chakra-ui/react";
-import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
-import { Tooltip } from "src/components/ui";
-
-type Props = {
- readonly dag: DAGWithLatestDagRunsResponse;
+export type AssetSummary = {
+ asset: {
+ group: string;
+ name: string;
+ timestamp?: string;
+ uri: string;
+ };
};
-export const Schedule = ({ dag }: Props) =>
- Boolean(dag.timetable_summary) && dag.timetable_description !== "Never,
external triggers only" ? (
- <Tooltip content={dag.timetable_description}>
- <Text fontSize="sm">{dag.timetable_summary}</Text>
- </Tooltip>
- ) : undefined;
+export type ExpressionType = {
+ all?: Array<AssetSummary | ExpressionType>;
+ any?: Array<AssetSummary | ExpressionType>;
+};
diff --git a/airflow/ui/src/pages/Dag/Header.tsx
b/airflow/ui/src/pages/Dag/Header.tsx
index 80c21ed8fa1..f5c10d71c86 100644
--- a/airflow/ui/src/pages/Dag/Header.tsx
+++ b/airflow/ui/src/pages/Dag/Header.tsx
@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Text } from "@chakra-ui/react";
-import { FiBookOpen, FiCalendar } from "react-icons/fi";
+import { FiBookOpen } from "react-icons/fi";
import { useParams } from "react-router-dom";
import type { DAGDetailsResponse, DAGWithLatestDagRunsResponse } from
"openapi/requests/types.gen";
@@ -27,9 +26,9 @@ import DisplayMarkdownButton from
"src/components/DisplayMarkdownButton";
import { HeaderCard } from "src/components/HeaderCard";
import MenuButton from "src/components/Menu/MenuButton";
import { TogglePause } from "src/components/TogglePause";
-import { Tooltip } from "src/components/ui";
import { DagTags } from "../DagsList/DagTags";
+import { Schedule } from "../DagsList/Schedule";
export const Header = ({
dag,
@@ -47,13 +46,7 @@ export const Header = ({
const stats = [
{
label: "Schedule",
- value: Boolean(dag?.timetable_summary) ? (
- <Tooltip content={dag?.timetable_description}>
- <Text fontSize="sm">
- <FiCalendar style={{ display: "inline" }} />
{dag?.timetable_summary}
- </Text>
- </Tooltip>
- ) : undefined,
+ value: dag === undefined ? undefined : <Schedule dag={dag} />,
},
{
label: "Latest Run",
diff --git a/airflow/ui/src/pages/DagsList/DagCard.test.tsx
b/airflow/ui/src/pages/DagsList/DagCard.test.tsx
index 67c25f0929d..e2360517806 100644
--- a/airflow/ui/src/pages/DagsList/DagCard.test.tsx
+++ b/airflow/ui/src/pages/DagsList/DagCard.test.tsx
@@ -27,6 +27,7 @@ import { Wrapper } from "src/utils/Wrapper";
import { DagCard } from "./DagCard";
const mockDag = {
+ asset_expression: null,
dag_display_name: "nested_groups",
dag_id: "nested_groups",
description: null,
diff --git a/airflow/ui/src/pages/DagsList/Schedule.tsx
b/airflow/ui/src/pages/DagsList/Schedule.tsx
index 9f0ff07da82..3c3946a32ee 100644
--- a/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow/ui/src/pages/DagsList/Schedule.tsx
@@ -17,17 +17,38 @@
* under the License.
*/
import { Text } from "@chakra-ui/react";
+import { FiCalendar } from "react-icons/fi";
-import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
-import { Tooltip } from "src/components/ui";
+import type { DAGDetailsResponse, DAGWithLatestDagRunsResponse } from
"openapi/requests/types.gen";
+import { AssetExpression, type ExpressionType } from
"src/components/AssetExpression";
+import { Button, Popover, Tooltip } from "src/components/ui";
type Props = {
- readonly dag: DAGWithLatestDagRunsResponse;
+ readonly dag: DAGDetailsResponse | DAGWithLatestDagRunsResponse;
};
export const Schedule = ({ dag }: Props) =>
- Boolean(dag.timetable_summary) && dag.timetable_description !== "Never,
external triggers only" ? (
- <Tooltip content={dag.timetable_description}>
- <Text fontSize="sm">{dag.timetable_summary}</Text>
- </Tooltip>
+ Boolean(dag.timetable_summary) ? (
+ dag.asset_expression === null ? (
+ <Tooltip content={dag.timetable_description}>
+ <Text fontSize="sm">
+ <FiCalendar style={{ display: "inline" }} /> {dag.timetable_summary}
+ </Text>
+ </Tooltip>
+ ) : (
+ // eslint-disable-next-line jsx-a11y/no-autofocus
+ <Popover.Root autoFocus={false} lazyMount positioning={{ placement:
"bottom-end" }} unmountOnExit>
+ <Popover.Trigger asChild>
+ <Button size="sm" variant="ghost">
+ <FiCalendar style={{ display: "inline" }} />
{dag.timetable_summary}
+ </Button>
+ </Popover.Trigger>
+ <Popover.Content width="fit-content">
+ <Popover.Arrow />
+ <Popover.Body>
+ <AssetExpression expression={dag.asset_expression as
ExpressionType} />
+ </Popover.Body>
+ </Popover.Content>
+ </Popover.Root>
+ )
) : undefined;
diff --git a/airflow/ui/src/queries/useDags.tsx
b/airflow/ui/src/queries/useDags.tsx
index b3a5af8f0a3..d4f58dc985e 100644
--- a/airflow/ui/src/queries/useDags.tsx
+++ b/airflow/ui/src/queries/useDags.tsx
@@ -68,6 +68,8 @@ export const useDags = (
const dagWithRuns = runsData?.dags.find((runsDag) => runsDag.dag_id ===
dag.dag_id);
return {
+ // eslint-disable-next-line unicorn/no-null
+ asset_expression: null,
latest_dag_runs: [],
...dagWithRuns,
...dag,