This is an automated email from the ASF dual-hosted git repository.
bbovenzi 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 d1b359bec74 Add Consuming Tasks, Aliases and Watchers to assets pages
(#71468)
d1b359bec74 is described below
commit d1b359bec74ea373b770d340218496c352f65a78
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Aug 12 16:31:42 2026 -0400
Add Consuming Tasks, Aliases and Watchers to assets pages (#71468)
* Add Alises and Watchers to assets pages
* Fix tests
---
.../airflow/ui/public/i18n/locales/en/assets.json | 8 ++-
.../ui/src/components/Assets/ListPopover.tsx | 80 ++++++++++++++++++++++
.../src/airflow/ui/src/mocks/handlers/assets.ts | 50 ++++++++++++++
.../src/airflow/ui/src/mocks/handlers/index.ts | 2 +
.../src/airflow/ui/src/pages/Asset/Header.test.tsx | 44 ++++++++++++
.../src/airflow/ui/src/pages/Asset/Header.tsx | 9 +++
.../ui/src/pages/AssetsList/AssetsList.test.tsx | 43 ++++++++++++
.../airflow/ui/src/pages/AssetsList/AssetsList.tsx | 28 +++++++-
8 files changed, 262 insertions(+), 2 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
index 0b963c05f2a..273a15f57e7 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
@@ -1,5 +1,8 @@
{
"additional_data": "Additional Data",
+ "alias_one": "Alias",
+ "alias_other": "Aliases",
+ "aliases": "Aliases",
"asset_many": "Assets",
"asset_one": "Asset",
"assetStateStore": {
@@ -53,5 +56,8 @@
"scheduledDags": "Scheduled Dags",
"scheduling": "Scheduling",
"searchPlaceholder": "Search Assets",
- "taskDependencies": "Task Dependencies"
+ "taskDependencies": "Task Dependencies",
+ "watcher_one": "Watcher",
+ "watcher_other": "Watchers",
+ "watchers": "Watchers"
}
diff --git a/airflow-core/src/airflow/ui/src/components/Assets/ListPopover.tsx
b/airflow-core/src/airflow/ui/src/components/Assets/ListPopover.tsx
new file mode 100644
index 00000000000..4b5917be6dd
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Assets/ListPopover.tsx
@@ -0,0 +1,80 @@
+/*!
+ * 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, Text } from "@chakra-ui/react";
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+
+import type { AssetAliasResponse, AssetWatcherResponse } from
"openapi/requests/types.gen";
+import Time from "src/components/Time";
+import { Popover } from "src/components/ui";
+
+type ListPopoverProps = {
+ readonly items: Array<{ key: string; label: ReactNode }>;
+ readonly noun: string;
+};
+
+const ListPopover = ({ items, noun }: ListPopoverProps) => (
+ // eslint-disable-next-line jsx-a11y/no-autofocus
+ <Popover.Root autoFocus={false} lazyMount unmountOnExit>
+ <Popover.Trigger asChild disabled={items.length === 0}>
+ <Button variant="outline">
+ {items.length} {noun}
+ </Button>
+ </Popover.Trigger>
+ <Popover.Content css={{ "--popover-bg": "colors.bg.emphasized" }}
width="fit-content">
+ <Popover.Arrow />
+ <Popover.Body>
+ {items.map(({ key, label }) => (
+ <Text key={key} py={2}>
+ {label}
+ </Text>
+ ))}
+ </Popover.Body>
+ </Popover.Content>
+ </Popover.Root>
+);
+
+export const AliasesPopover = ({ aliases }: { readonly aliases:
Array<AssetAliasResponse> }) => {
+ const { t: translate } = useTranslation("assets");
+
+ return (
+ <ListPopover
+ items={aliases.map((alias) => ({ key: String(alias.id), label:
alias.name }))}
+ noun={translate("alias", { count: aliases.length })}
+ />
+ );
+};
+
+export const WatchersPopover = ({ watchers }: { readonly watchers:
Array<AssetWatcherResponse> }) => {
+ const { t: translate } = useTranslation("assets");
+
+ return (
+ <ListPopover
+ items={watchers.map((watcher) => ({
+ key: String(watcher.trigger_id),
+ label: (
+ <>
+ {watcher.name} <Time datetime={watcher.created_date} />
+ </>
+ ),
+ }))}
+ noun={translate("watcher", { count: watchers.length })}
+ />
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts
b/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts
new file mode 100644
index 00000000000..cf668954abd
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts
@@ -0,0 +1,50 @@
+/*!
+ * 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 { http, HttpResponse, type HttpHandler } from "msw";
+
+const assetWithDependencies = {
+ aliases: [{ group: "alias_group", id: 1, name: "my_alias" }],
+ consuming_tasks: [{ created_at: "2025-01-01T00:00:00Z", dag_id:
"consumer_dag", task_id: "consumer_task" }],
+ created_at: "2025-01-01T00:00:00Z",
+ extra: { owner: "data_team" },
+ group: "asset_group",
+ id: 1,
+ last_asset_event: { id: 10, timestamp: "2025-01-15T00:00:00Z" },
+ name: "asset_with_dependencies",
+ producing_tasks: [],
+ scheduled_dags: [],
+ updated_at: "2025-01-02T00:00:00Z",
+ uri: "s3://bucket/asset",
+ watchers: [{ created_date: "2025-01-01T00:00:00Z", name: "my_watcher",
trigger_id: 5 }],
+};
+
+const plainAsset = {
+ ...assetWithDependencies,
+ aliases: [],
+ id: 2,
+ name: "plain_asset",
+ watchers: [],
+};
+
+export const handlers: Array<HttpHandler> = [
+ http.get("/ui/assets", () => HttpResponse.json({ assets:
[assetWithDependencies], total_entries: 1 })),
+ http.get("/api/v2/assets/:assetId", ({ params }) =>
+ HttpResponse.json(params.assetId === "2" ? plainAsset :
assetWithDependencies),
+ ),
+];
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
b/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
index 9f60605f2b6..3d9e36b8602 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { handlers as assetsHandlers } from "./assets";
import { handlers as configHandlers } from "./config";
import { handlers as dagHandlers } from "./dag";
import { handlers as dagRunsHandlers } from "./dag_runs";
@@ -23,6 +24,7 @@ import { handlers as dagsHandlers } from "./dags";
import { handlers as logHandlers } from "./log";
export const handlers = [
+ ...assetsHandlers,
...configHandlers,
...dagHandlers,
...dagRunsHandlers,
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/Header.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Asset/Header.test.tsx
new file mode 100644
index 00000000000..1db8677dfff
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/Header.test.tsx
@@ -0,0 +1,44 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import "@testing-library/jest-dom";
+import { render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { AppWrapper } from "src/utils/AppWrapper";
+
+// The assets mock handler (see src/mocks/handlers/assets.ts) serves asset 1
with one
+// alias and one watcher, and asset 2 with neither.
+describe("Asset header", () => {
+ it("lists the aliases and watchers of an asset", async () => {
+ render(<AppWrapper initialEntries={["/assets/1"]} />);
+
+ await waitFor(() => expect(screen.getByRole("button", { name: "1 alias"
})).toBeInTheDocument());
+
+ expect(screen.getByRole("button", { name: "1 watcher"
})).toBeInTheDocument();
+ });
+
+ it("omits the aliases and watchers stats for an asset that has none", async
() => {
+ render(<AppWrapper initialEntries={["/assets/2"]} />);
+
+ await waitFor(() =>
expect(screen.getAllByText("plain_asset").length).toBeGreaterThan(0));
+
+ expect(screen.queryByRole("button", { name: /alias/iu
})).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /watcher/iu
})).not.toBeInTheDocument();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/Header.tsx
b/airflow-core/src/airflow/ui/src/pages/Asset/Header.tsx
index 6eb0929b580..c9dd67119b0 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/Header.tsx
@@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next";
import { FiDatabase } from "react-icons/fi";
import type { AssetResponse } from "openapi/requests/types.gen";
+import { AliasesPopover, WatchersPopover } from
"src/components/Assets/ListPopover";
import { HeaderCard } from "src/components/HeaderCard";
import { DependencyPopover } from "../AssetsList/DependencyPopover";
@@ -27,6 +28,9 @@ import { DependencyPopover } from
"../AssetsList/DependencyPopover";
export const Header = ({ asset }: { readonly asset?: AssetResponse }) => {
const { t: translate } = useTranslation("assets");
+ const aliases = asset?.aliases ?? [];
+ const watchers = asset?.watchers ?? [];
+
const stats = [
{ label: translate("group"), value: asset?.group },
{
@@ -41,6 +45,11 @@ export const Header = ({ asset }: { readonly asset?:
AssetResponse }) => {
label: translate("scheduledDags"),
value: <DependencyPopover dependencies={asset?.scheduled_dags ?? []}
type="Dag" />,
},
+ // Only assets that actually have them — unlike the dependency stats
above, most assets have neither.
+ ...(aliases.length ? [{ label: translate("aliases"), value:
<AliasesPopover aliases={aliases} /> }] : []),
+ ...(watchers.length
+ ? [{ label: translate("watchers"), value: <WatchersPopover
watchers={watchers} /> }]
+ : []),
];
return <HeaderCard icon={<FiDatabase />} stats={stats} title={asset?.name}
/>;
diff --git
a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
new file mode 100644
index 00000000000..7763a9e92e3
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
@@ -0,0 +1,43 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import "@testing-library/jest-dom";
+import { render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { AppWrapper } from "src/utils/AppWrapper";
+
+// The assets mock handler (see src/mocks/handlers/assets.ts) returns a single
asset
+// with one consuming task, one alias and one watcher.
+describe("AssetsList columns", () => {
+ it("shows the consuming tasks of an asset", async () => {
+ render(<AppWrapper initialEntries={["/assets"]} />);
+
+ await waitFor(() =>
expect(screen.getByText("asset_with_dependencies")).toBeInTheDocument());
+
+ expect(screen.getByRole("button", { name: "1 task" })).toBeInTheDocument();
+ });
+
+ it("lists the aliases and watchers of an asset", async () => {
+ render(<AppWrapper initialEntries={["/assets"]} />);
+
+ await waitFor(() => expect(screen.getByRole("button", { name: "1 alias"
})).toBeInTheDocument());
+
+ expect(screen.getByRole("button", { name: "1 watcher"
})).toBeInTheDocument();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
index 33518105231..0f37d43db4b 100644
--- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
@@ -18,11 +18,13 @@
*/
import { Heading, VStack } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
+import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { useAssetServiceGetAssetsUi } from "openapi/queries";
import type { AssetResponse } from "openapi/requests/types.gen";
+import { AliasesPopover, WatchersPopover } from
"src/components/Assets/ListPopover";
import { DataTable } from "src/components/DataTable";
import { useTableURLState } from "src/components/DataTable/useTableUrlState";
import { ErrorAlert } from "src/components/ErrorAlert";
@@ -44,7 +46,7 @@ const assetsFilterKeys: Array<FilterableSearchParamsKeys> = [
type AssetRow = { row: { original: AssetResponse } };
-const createColumns = (translate: (key: string) => string):
Array<ColumnDef<AssetResponse>> => [
+const createColumns = (translate: TFunction): Array<ColumnDef<AssetResponse>>
=> [
{
accessorKey: "name",
cell: ({ row: { original } }: AssetRow) => (
@@ -90,9 +92,33 @@ const createColumns = (translate: (key: string) => string):
Array<ColumnDef<Asse
enableSorting: false,
header: () => translate("producingTasks"),
},
+ {
+ accessorKey: "consuming_tasks",
+ cell: ({ row: { original } }: AssetRow) =>
+ original.consuming_tasks.length ? (
+ <DependencyPopover dependencies={original.consuming_tasks} type="Task"
/>
+ ) : undefined,
+ enableSorting: false,
+ header: () => translate("consumingTasks"),
+ },
+ {
+ accessorKey: "aliases",
+ cell: ({ row: { original } }: AssetRow) =>
+ original.aliases.length ? <AliasesPopover aliases={original.aliases} />
: undefined,
+ enableSorting: false,
+ header: () => translate("aliases"),
+ },
+ {
+ accessorKey: "watchers",
+ cell: ({ row: { original } }: AssetRow) =>
+ original.watchers.length ? <WatchersPopover watchers={original.watchers}
/> : undefined,
+ enableSorting: false,
+ header: () => translate("watchers"),
+ },
{
accessorKey: "trigger",
cell: ({ row }) => <CreateAssetEvent asset={row.original} />,
+ enableHiding: false,
enableSorting: false,
header: "",
},