kaxil commented on code in PR #75229:
URL: https://github.com/apache/airflow/pull/75229#discussion_r4336829330
##########
airflow-core/src/airflow/plugins_manager.py:
##########
@@ -351,7 +352,35 @@ def _is_applies_to_path_known_elsewhere(path: str) -> bool:
return any(_describe_applies_to_path_error(path, root) is None for root in
_applies_to_root_models())
-def _validate_applies_to(plugin_name: str | None, view: ExternalViewDict |
ReactAppDict, kind: str) -> bool:
+_ACTION_TYPES = frozenset({"link", "react"})
+_ACTION_DESTINATIONS = frozenset({"dag", "dag_run", "task_instance"})
+# The field each action kind cannot render without.
+_ACTION_REQUIRED_TARGET = {"link": "href", "react": "bundle_url"}
+
+
+def _describe_ui_action_error(action: Any) -> str | None:
Review Comment:
This only checks four fields, but `BasePluginActionResponse` has
`extra="forbid"` and types `icon` as `str | None`. An action with a key the
model doesn't know, say a `url_route` copied over from a `react_apps` entry,
gets through here and then fails `PluginResponse.model_validate` in
`routes/public/plugins.py`. That skips the whole plugin, including its views
and React apps, and the docstring above says that shouldn't happen.
Separately, `"destination": ["dag", "dag_run"]` raises `TypeError:
unhashable` at the `not in` below. That's inside `_get_ui_plugins`, so `GET
/plugins` returns 500 for every plugin. Could each action go through
`TypeAdapter(PluginActionResponse)` here instead of the hand-written checks?
##########
airflow-core/docs/howto/custom-action-plugin.rst:
##########
@@ -0,0 +1,337 @@
+ .. 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.
+
+
+Add your own actions to Dags, Dag Runs and Task Instances
+=========================================================
+
+Airflow renders a fixed set of actions for each entity — pause, trigger,
clear, mark as,
+delete. ``AirflowPlugin.ui_actions`` lets you add your own beside them,
everywhere Airflow already
+offers actions for that entity:
+
+* the trailing cell of its **table rows**,
+* its **card**, on lists that have a card view, and
+* the action row of its **header card** on the detail page.
+
+An action either opens a URL or mounts a React component of your own. Unlike
+``external_views`` and ``react_apps``, which each take over a whole tab, an
action is a single
+button — the right size for "open the runbook for this Dag" or "show me this
task's payload".
+
+Your first action
+-----------------
+
+Drop this in a file under your :doc:`plugins folder
</administration-and-deployment/plugins>`:
+
+.. code-block:: python
+
+ from airflow.plugins_manager import AirflowPlugin
+
+
+ class RunbookPlugin(AirflowPlugin):
+ name = "runbook_plugin"
+
+ ui_actions = [
+ {
+ "action_type": "link",
+ "name": "Open runbook",
+ "destination": "dag",
+ "href": "https://runbooks.example.com/{DAG_ID}",
+ }
+ ]
+
+Restart the API server. Every Dag now carries an "Open runbook" button — on
each row and card
+of the Dags list, and in the header of each Dag's detail page — pointing at
that Dag's
+runbook.
+
+Four keys are required: ``action_type``, ``name``, ``destination``, and the
target the kind
+needs (``href`` for ``link``, ``bundle_url`` for ``react``). ``name`` is the
button's tooltip
+and its accessible label, so write it as a label rather than an identifier.
+
+``destination`` picks the entity, and with it the surfaces the button appears
on:
+
+.. list-table::
+ :header-rows: 1
+
+ * - ``destination``
+ - Appears on
+ - Template variables
+ * - ``dag``
+ - Dags list rows and cards, Dag detail header
+ - ``{DAG_ID}``
+ * - ``dag_run``
+ - Dag Runs rows, Dag Run detail header
+ - ``{DAG_ID}``, ``{RUN_ID}``
+ * - ``task_instance``
+ - Task Instances rows, Task Instance detail header
+ - ``{DAG_ID}``, ``{RUN_ID}``, ``{TASK_ID}``, ``{MAP_INDEX}``
+
+A variable with no value on the current page is left in the URL as-is rather
than replaced
+with an empty string, so a half-resolved link fails visibly instead of quietly
pointing
+somewhere else. Values are URL-encoded for you.
+
+Internal and external links
+---------------------------
+
+A link that matches an Airflow UI route navigates inside the app, with no page
reload:
+
+.. code-block:: python
+
+ {
+ "action_type": "link",
+ "name": "Jump to runs",
+ "destination": "dag",
+ "href": "/dags/{DAG_ID}/runs",
Review Comment:
With `[api] base_url = http://host/airflow` this example stops being
internal: `getInternalRouteTo` matches `/dags/x/runs` against basename
`/airflow`, gets no match, and renders an external link to
`http://host/dags/x/runs` in a new tab, which is a 404. The base-path note
further down only sits in the React bundle section. Would resolving a
leading-slash href against the UI base path work, so one plugin behaves the
same on both kinds of deployment? If not, the note should move up next to this
example.
##########
airflow-core/tests/unit/plugins/test_plugins_manager.py:
##########
@@ -259,7 +259,7 @@ class TestPluginB(AirflowPlugin):
):
from airflow import plugins_manager
- external_views, react_apps = plugins_manager._get_ui_plugins()
+ external_views, react_apps, _ = plugins_manager._get_ui_plugins()
Review Comment:
The changes here only update the tuple arity. Nothing exercises the
`ui_actions` path itself: the `_describe_ui_action_error` branches,
`applies_to` on an action, the React-name conflict, or an action going through
`/plugins`. A test with one bad action next to a good one in the same plugin
would have caught the problem in `_describe_ui_action_error`.
##########
airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx:
##########
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import "@testing-library/jest-dom/vitest";
+import "@testing-library/jest-dom";
Review Comment:
This file looks like it was rebuilt from an older copy. Against main it
reverts `jest-dom/vitest` to `jest-dom` and deletes the region-coordinates test
and the whole "ReactPlugin loading fallback" block, while the code they cover
is still here (`useTaskInstanceCoordinates` in `useRoutePluginProps`, and the
fallback logic this PR changes). Could those come back, plus a case for
`fallback={null}`?
##########
airflow-core/src/airflow/ui/src/utils/links.ts:
##########
@@ -184,3 +187,100 @@ export const buildTaskInstanceUrl = (params: {
return `${basePath}${additionalPath}`;
};
+
+/**
+ * Where a plugin link should open: in this tab for the UI's own origin, a new
one otherwise.
+ *
+ * A URL that cannot be parsed is treated as external, so a malformed plugin
link never navigates the
+ * app away from the page the user is on.
+ */
+export const getLinkTarget = (url: string): "_blank" | "_self" => {
+ try {
+ return new URL(url, globalThis.location.origin).origin ===
globalThis.location.origin
+ ? "_self"
+ : "_blank";
+ } catch {
+ return "_blank";
+ }
+};
+
+/**
+ * The query string a link should navigate with, keeping the current one when
it stays put.
+ *
+ * A key the link names replaces every value the page had for it, rather than
adding to them,
+ * so a link to `?state=failed` means failed rather than "failed as well as
whatever was
+ * already selected" -- while a link that repeats a key keeps all of its own
values.
+ */
+const mergeSearch = (
+ pathname: string,
+ linkParams: URLSearchParams,
+ currentLocation?: { pathname: string; search: string },
+): string => {
+ if (currentLocation?.pathname !== pathname) {
Review Comment:
This comparison misses on run-scoped pages. `applyPluginTemplate` encodes
the run id, so `manual__2026-10-20T12:00:00+00:00` reaches here as
`...T12%3A00%3A00%2B00%3A00`, while the app's own links build run paths with
the raw id and `location.pathname` keeps the `:` and `+`. The two never compare
equal, so a same-page link drops the query string on any scheduled or manual
run. Comparing decoded paths would fix it. The tests only use ids like
`run_123`, which hide this.
##########
airflow-core/src/airflow/plugins_manager.py:
##########
@@ -525,12 +559,44 @@ def _get_ui_plugins() -> tuple[list[ExternalViewDict],
list[ReactAppDict]]:
continue
react_apps.append(react_app)
seen_url_routes[url_route] = plugin.name
+ if isinstance(react_app.get("name"), str):
+ react_component_owners.setdefault(react_app["name"],
plugin.name)
Review Comment:
The conflict check only fires when the React App is seen before the action
it collides with. If an earlier plugin has a React action named `Foo` and a
later plugin has a React App named `Foo`, this `setdefault` keeps the first
owner, nothing is logged, and both load, so each renders the other's component.
The docs promise a warning and a skip there. Collecting every React App name in
a first pass over the plugins would cover both orders.
##########
providers/common/ai/tests/unit/common/ai/plugins/test_model_panel.py:
##########
@@ -83,6 +83,6 @@ def test_react_app_survives_core_applies_to_validation(self):
plugin.react_apps = copy.deepcopy(ModelPanelPlugin.react_apps)
with mock_plugin_manager(plugins=[plugin]):
- _, react_apps = plugins_manager._get_ui_plugins()
+ _, react_apps, _ = plugins_manager._get_ui_plugins()
Review Comment:
`requires_3_4` is true on 3.4.0b2, where `_get_ui_plugins` still returns two
values, so this unpack raises `ValueError` in a compat run against that version
(and against 3.4.0 if this misses the release).
`plugins_manager._get_ui_plugins()[1]` works on both.
##########
airflow-core/src/airflow/ui/src/components/PluginActions/PluginReactAction.tsx:
##########
@@ -0,0 +1,104 @@
+/*!
+ * 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 { useCallback, useRef, useState } from "react";
+
+import type { PluginReactActionResponse } from "openapi/requests/types.gen";
+
+import { IconButton } from "src/system-components";
+
+import { loadPlugin, type PluginProps, ReactPlugin } from
"src/pages/ReactPlugin";
+
+import { PluginActionIcon } from "./PluginActionIcon";
+import type { PluginActionButtonProps } from "./types";
+
+// A loaded bundle registers its component under this name; see `loadPlugin`.
+const isComponentLoaded = (name: string) =>
+ typeof (globalThis as Record<string, unknown>)[name] === "function";
+
+/**
+ * A plugin action that reveals a component of the plugin's own making.
+ *
+ * Core contributes the trigger button and nothing else: the plugin portals
its own popover,
+ * dialog or drawer, so it is not boxed into one shape. It gets `open` to
follow, `onClose` to
+ * dismiss itself, and `triggerRef` to anchor against -- a popover needs the
button's rect, and
+ * the button is ours.
+ *
+ * The component mounts on first open and then stays mounted, so it can
animate out and keep
+ * whatever state it had. Nothing is loaded for an action nobody clicks, which
matters on a
+ * table where the same action exists on every row.
+ *
+ * The bundle is fetched before mounting rather than through a Suspense
boundary, so no
+ * placeholder is ever rendered beside the button -- one would reflow the
action row every time
+ * someone opened a surface. The wait belongs to the button, which shows it in
place.
+ */
+export const PluginReactAction = ({
+ action,
+ buttonProps,
+ pluginProps,
+}: {
+ readonly action: PluginReactActionResponse;
+ readonly buttonProps?: PluginActionButtonProps;
+ readonly pluginProps: PluginProps;
+}) => {
+ const [open, setOpen] = useState(false);
+ const [hasOpened, setHasOpened] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const triggerRef = useRef<HTMLButtonElement>(null);
+
+ const onClose = useCallback(() => setOpen(false), []);
+
+ const onToggle = async () => {
+ // Resolve the bundle first so `ReactPlugin` finds the component already
registered and
+ // takes its synchronous path, never reaching Suspense.
+ if (!isComponentLoaded(action.name)) {
+ setIsLoading(true);
+ await loadPlugin(action);
+ setIsLoading(false);
+ }
+
+ setHasOpened(true);
+ setOpen((wasOpen) => !wasOpen);
+ };
+
+ return (
+ <>
+ <IconButton
+ label={action.name}
+ loading={isLoading}
+ onClick={() => {
+ void onToggle();
+ }}
+ ref={triggerRef}
+ {...buttonProps}
+ >
+ <PluginActionIcon action={action} />
+ </IconButton>
+ {hasOpened ? (
+ // `fallback={null}` guards the failure path: a bundle that did not
load leaves the
+ // global unset, so `ReactPlugin` lazy-loads instead, and its default
spinner would
+ // land inline in the action row.
+ <ReactPlugin
Review Comment:
Nothing catches a component that throws while rendering, so it goes up to
the route's error element and replaces the whole list or detail page with the
error page. It's easy to hit: an action named `History` or `Image` finds the
browser's constructor on `globalThis`, `isComponentLoaded` skips the load, and
React throws calling it. An error boundary here that renders nothing would keep
the failure in the plugin's slot, the same way `loadPlugin` already handles a
bundle that fails to load.
##########
airflow-core/src/airflow/ui/src/components/PluginActions/pluginActionContext.ts:
##########
@@ -0,0 +1,90 @@
+/*!
+ * 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 { PluginProps } from "src/pages/ReactPlugin";
+
+import type { AppliesToContext } from "src/utils/pluginAppliesTo";
+import type { PluginTemplateTokens } from "src/utils/pluginTemplate";
+
+import type { PluginActionEntity } from "./types";
+
+type PluginActionContext = {
+ /** The record half of the scoping context; the route supplies the rest. */
+ appliesTo: Pick<AppliesToContext, "dag" | "dagRun" | "taskInstance">;
+ pluginProps: PluginProps;
+ tokens: PluginTemplateTokens;
+};
+
+/**
+ * Derive everything a surface needs about its entity: the scoping context,
the props a React
+ * action's component receives, and the tokens a link action's URL
interpolates.
+ *
+ * One pure function for all six surfaces -- three table rows and three header
cards -- so they
+ * cannot drift on what a plugin is told about the row it was clicked from.
Deliberately not a
+ * hook: a table calls this per row, where mounting query observers would be
ruinous.
+ *
+ * Each case supplies the *whole* record it was handed, not a stub, so an
`applies_to` path can
+ * address any field the REST API returns for it -- `state` being the one that
motivated this.
+ * A record the surface does not have stays `undefined`, which is what makes
paths rooted at it
+ * skipped rather than failed.
+ */
+export const pluginActionContext = (entity: PluginActionEntity):
PluginActionContext => {
+ if (entity.destination === "dag") {
+ const { dag, dagDetails } = entity;
+
+ return {
+ appliesTo: { dag },
+ pluginProps: { dag: dagDetails, dagId: dag.dag_id },
+ tokens: { dagId: dag.dag_id },
+ };
+ }
+
+ if (entity.destination === "dag_run") {
+ const { dagRun } = entity;
+
+ return {
+ appliesTo: { dagRun },
+ pluginProps: { dagId: dagRun.dag_id, dagRun, runId: dagRun.dag_run_id },
+ tokens: { dagId: dagRun.dag_id, runId: dagRun.dag_run_id },
+ };
+ }
+
+ // Narrowed to the task instance case. Adding a destination to the union
breaks this
+ // destructuring rather than silently falling through, which is the
exhaustiveness check.
+ const { taskInstance } = entity;
+ // Stringified rather than omitted when -1, so `{MAP_INDEX}` resolves for
unmapped tasks
+ // instead of being left in the URL verbatim.
+ const mapIndex = String(taskInstance.map_index);
+
+ return {
+ appliesTo: { taskInstance },
+ pluginProps: {
+ dagId: taskInstance.dag_id,
+ mapIndex,
+ runId: taskInstance.dag_run_id,
+ taskId: taskInstance.task_id,
+ taskInstance,
+ },
+ tokens: {
Review Comment:
Rows for different loop regions of the same task get identical URLs here,
since `region_id`/`region_index` aren't passed through (`getTaskInstanceLink`
keeps them). Should those be tokens too, so a link action can tell the rows
apart?
##########
airflow-core/newsfragments/75229.improvement.rst:
##########
@@ -0,0 +1 @@
+A plugin ``link`` action that stays on the page the user is already on now
keeps their query string instead of replacing it, so a button that adds one
parameter no longer discards the filters, search and sort they had set.
Parameters the link names replace what the page had for them, and everything
else survives. This applies only when the path does not change: across pages
the parameters belong to the page being left, and are dropped as before.
Review Comment:
`ui_actions` isn't in 3.4.0b2 or on main yet, so there's no earlier link
behaviour for this to improve on. Could this fold into the feature fragment?
##########
airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx:
##########
@@ -460,6 +470,7 @@ export const TaskInstances = () => {
(ti) => ti.loop_iteration !== null && ti.loop_iteration !== undefined,
),
multiTeam: multiTeamEnabled,
+ pluginActions,
Review Comment:
When any `task_instance` action has `applies_to`, `routeContext` carries the
Run page's dag run query, which polls while the run is active, so
`pluginActions` changes on each refresh. That rebuilds the columns, and since
`cell` is an inline arrow, `flexRender` gets a new component type and remounts
the actions cell, closing an open React action surface along with any
Clear/Mark dialog next to it. `DagsListCountsContext` already avoids this by
reading the actions from context. Could Dag Runs and Task Instances do the
same? (Reasoned from the code, I haven't reproduced it in a browser.)
##########
airflow-core/docs/howto/custom-action-plugin.rst:
##########
@@ -0,0 +1,337 @@
+ .. 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.
+
+
+Add your own actions to Dags, Dag Runs and Task Instances
+=========================================================
+
+Airflow renders a fixed set of actions for each entity — pause, trigger,
clear, mark as,
+delete. ``AirflowPlugin.ui_actions`` lets you add your own beside them,
everywhere Airflow already
+offers actions for that entity:
+
+* the trailing cell of its **table rows**,
+* its **card**, on lists that have a card view, and
+* the action row of its **header card** on the detail page.
+
+An action either opens a URL or mounts a React component of your own. Unlike
+``external_views`` and ``react_apps``, which each take over a whole tab, an
action is a single
+button — the right size for "open the runbook for this Dag" or "show me this
task's payload".
+
+Your first action
+-----------------
+
+Drop this in a file under your :doc:`plugins folder
</administration-and-deployment/plugins>`:
+
+.. code-block:: python
+
+ from airflow.plugins_manager import AirflowPlugin
+
+
+ class RunbookPlugin(AirflowPlugin):
+ name = "runbook_plugin"
+
+ ui_actions = [
+ {
+ "action_type": "link",
+ "name": "Open runbook",
+ "destination": "dag",
+ "href": "https://runbooks.example.com/{DAG_ID}",
+ }
+ ]
+
+Restart the API server. Every Dag now carries an "Open runbook" button — on
each row and card
+of the Dags list, and in the header of each Dag's detail page — pointing at
that Dag's
+runbook.
+
+Four keys are required: ``action_type``, ``name``, ``destination``, and the
target the kind
+needs (``href`` for ``link``, ``bundle_url`` for ``react``). ``name`` is the
button's tooltip
+and its accessible label, so write it as a label rather than an identifier.
+
+``destination`` picks the entity, and with it the surfaces the button appears
on:
+
+.. list-table::
+ :header-rows: 1
+
+ * - ``destination``
+ - Appears on
+ - Template variables
+ * - ``dag``
+ - Dags list rows and cards, Dag detail header
+ - ``{DAG_ID}``
+ * - ``dag_run``
+ - Dag Runs rows, Dag Run detail header
+ - ``{DAG_ID}``, ``{RUN_ID}``
+ * - ``task_instance``
+ - Task Instances rows, Task Instance detail header
+ - ``{DAG_ID}``, ``{RUN_ID}``, ``{TASK_ID}``, ``{MAP_INDEX}``
+
+A variable with no value on the current page is left in the URL as-is rather
than replaced
+with an empty string, so a half-resolved link fails visibly instead of quietly
pointing
+somewhere else. Values are URL-encoded for you.
+
+Internal and external links
+---------------------------
+
+A link that matches an Airflow UI route navigates inside the app, with no page
reload:
+
+.. code-block:: python
+
+ {
+ "action_type": "link",
+ "name": "Jump to runs",
+ "destination": "dag",
+ "href": "/dags/{DAG_ID}/runs",
+ }
+
+A link that stays on the page the user is already on keeps their query string,
so a button
+that adds one parameter does not throw away the filters, search and sort they
had set:
+
+.. code-block:: python
+
+ {
+ "action_type": "link",
+ "name": "Show details",
+ "destination": "dag",
+ # On the Dags list this adds `details`; any filters stay applied.
+ "href": "/dags?details={DAG_ID}",
+ }
+
+Parameters the link names replace what the page had for them, so a link
meaning "show failed"
+means failed rather than "failed as well as whatever was already selected".
This applies only
+when the path does not change: across pages the parameters belong to the page
being left, and
+carrying a Dag Run's ``?state=failed`` onto the Task Instances list would
filter something
+nobody asked to filter.
+
+Anything else is external: it opens in a new tab, and a small arrow is badged
onto the corner
+of its button so people can tell before they click.
+
+"External" is wider than "a different host". A page served by your own
``fastapi_apps``
+shares Airflow's origin but is not a UI route, so it counts as external too —
following it in
+the same tab would drop the user out of the UI and lose their place in
whatever list they were
+looking at. You do not configure any of this; it is decided from the URL.
+
+Limiting an action to the Dags that need it
+-------------------------------------------
+
+An action with no scoping appears on every entity of its kind, which gets
noisy fast. The
+optional ``applies_to`` block is the same one external views and React apps
use:
+
+.. code-block:: python
+
+ {
+ "action_type": "link",
+ "name": "Open runbook",
+ "destination": "dag",
+ "href": "https://runbooks.example.com/{DAG_ID}",
+ "applies_to": {"dag.tags.name": ["production"]},
+ }
+
+Keys are dotted field paths into the records the surface has, so anything the
REST API returns
+for the entity is addressable. The case this is most often wanted for is state:
+
+.. code-block:: python
+
+ {
+ "action_type": "link",
+ "name": "Investigate failure",
+ "destination": "task_instance",
+ "href":
"https://ai.example.com/investigate?dag={DAG_ID}&task={TASK_ID}&run={RUN_ID}",
+ # Offered on failed task instances and nowhere else.
+ "applies_to": {"state": ["failed", "upstream_failed"]},
+ }
+
+Values are OR-ed within a path and AND-ed across paths, and a path whose root
record the
+current surface cannot resolve is skipped rather than failing the match. See
+:ref:`Scoping a view to specific Dags and tasks <plugin-scoping>` for the full
semantics.
+
+One consequence is worth knowing before you rely on it. On the deployment-wide
**Dag Runs**
+and **Task Instances** lists, each row is its own record but Airflow has not
loaded the parent
+Dag, so a ``dag.*`` path cannot be judged there and is skipped — the action
shows on every
+row. Paths rooted at the row itself, like ``state`` or ``dag_id``, are
evaluated normally, so
+pair them if that matters:
+
+.. code-block:: python
+
+ "applies_to": {"dag.tags.name": ["production"], "dag_id": ["checkout_etl"]}
+
+Everywhere else — the Dags list and all three header cards — the Dag is
available.
+
+Actions that open a surface
+---------------------------
+
+.. warning::
+ React plugins are experimental. The feature may change in future versions
without warning
+ based on user feedback and errors reported.
+
+|experimental|
+
+A ``react`` action mounts a component you supply. Airflow renders **only the
button** — your
+component renders its own surface, so it can be a popover, a modal, a side
drawer or anything
+else, and it owns its own appearance and accessibility.
+
+.. code-block:: python
+
+ {
+ "action_type": "react",
+ "name": "Inspect payload",
+ "destination": "task_instance",
+ "bundle_url": "https://example.com/static/js/inspect_payload.js",
+ }
+
+Alongside the entity context props — the same ones documented in
+:ref:`React app context props <plugin-react-props>` — your component receives
three props for
+driving its surface:
+
+``open``
+ Whether the button is currently toggled on. Your component is mounted the
first time the
+ button is clicked and stays mounted afterwards, so it can animate out and
keep its state;
+ follow ``open`` rather than assuming you are unmounted when closed.
+
+``onClose``
+ Call it to toggle the button back off — after submitting, or when your
surface is
+ dismissed by a backdrop click or Escape.
+
+``triggerRef``
+ A ref to the button element. A popover needs it to anchor against, since
the button
+ belongs to Airflow rather than to you.
+
+Note the context comes from the **entity whose button was clicked**, not from
the page's URL.
+A row's action lives on a list page whose URL says nothing about that row, so
read ``dagId``,
+``runId`` and ``taskId`` from props rather than from the router.
+
+A minimal component, with no build step, borrowing React and Chakra from the
host — enough to
+see the contract working before you reach for tooling:
+
+.. code-block:: javascript
+
+ const React = globalThis.React;
+ const { Popover, Portal, Text } = globalThis.ChakraUI;
+ const h = React.createElement;
+
+ globalThis["Inspect payload"] = ({ onClose, open, taskId, triggerRef }) =>
+ h(
+ Popover.Root,
+ {
+ onOpenChange: (event) => { if (!event.open) onClose(); },
+ open: Boolean(open),
+ positioning: {
+ getAnchorRect: () => triggerRef?.current?.getBoundingClientRect()
?? null,
+ placement: "bottom-end",
+ },
+ },
+ h(Portal, null,
+ h(Popover.Positioner, null,
+ h(Popover.Content, null,
+ h(Popover.Body, null, h(Text, null, `Task: ${taskId}`)),
+ ),
+ ),
+ ),
+ );
+
+The bundle must register a function component on ``globalThis`` under the
action's ``name``.
+Airflow also accepts the shared ``globalThis.AirflowPlugin`` global, which is
what the
+bootstrap tool below produces.
+
+For anything beyond a demo, use the React plugin bootstrap tool described in
+:doc:`custom-view-plugin` — it generates a Vite project with the bundling
configuration
+Airflow needs, and shares React with the host rather than shipping its own
copy.
+
+Two rules for React actions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+**Give every React action a distinct name.** The component is registered on
the page under
+its ``name``, so two React actions sharing one — or a React action sharing a
name with a
+``react_apps`` entry — would render each other's component. Airflow warns
about this at load
+time and skips the action rather than letting it happen.
+
+**Give every React action its own bundle_url.** Airflow clears the shared
global before each
+load, and a browser will not re-execute a module it has already imported, so a
second action
+pointing at a bundle that is already loaded finds nothing registered under its
name. If you
+genuinely want one bundle to back several actions, register each name
explicitly from it.
+
+Serving a bundle from your own plugin
+-------------------------------------
+
+A ``bundle_url`` can point anywhere the browser can reach, including a CDN. To
serve it from
+Airflow itself, mount a FastAPI app in the same plugin:
+
+.. code-block:: python
+
+ from fastapi import FastAPI
+ from fastapi.staticfiles import StaticFiles
+
+ from airflow.plugins_manager import AirflowPlugin
+
+ app = FastAPI()
+ app.mount("/static", StaticFiles(directory="/path/to/dist"),
name="inspect_static")
+
+
+ class InspectPlugin(AirflowPlugin):
+ name = "inspect_plugin"
+
+ fastapi_apps = [{"name": "Inspect", "app": app, "url_prefix":
"/inspect"}]
+
+ ui_actions = [
+ {
+ "action_type": "react",
+ "name": "Inspect payload",
+ "destination": "task_instance",
+ "bundle_url": "/inspect/static/main.umd.cjs",
+ }
+ ]
+
+If your deployment is served under a base path, or you develop against the
Vite dev server,
+build the URL from ``[api] base_url`` rather than hard-coding a leading slash.
+
+When an action does not appear
+------------------------------
+
+Airflow skips an action it cannot render and logs why, rather than failing the
plugin. Check
+the API server log at startup for a line naming your plugin. An action is
skipped when it:
+
+* is not a dictionary;
+* names an unknown ``action_type`` or ``destination``;
+* omits the ``href`` or ``bundle_url`` its kind requires, or has an empty
``name``;
+* is a ``react`` action whose ``name`` collides with another React component.
+
+``applies_to`` is reported separately, and what happens depends on what is
wrong with it. A
+malformed block — not a dictionary, a non-string path, a value that is not a
list of strings —
+is stripped, and the action loads unscoped. A path the ``destination`` cannot
evaluate is
+warned about and skipped, which is what lets one block be shared across
destinations. But a
+path naming a field *no* record has is a misconfiguration rather than a filter
that never
+matches, so the action is not loaded at all and the reason is logged as an
error: it could
+never scope the way it asks to, and showing it everywhere would be the
opposite of the intent.
+See :doc:`/administration-and-deployment/plugins` for the path syntax.
+
+The rest of the plugin is unaffected by any of this: one bad action does not
cost you the
+plugin's other actions, its views, or its macros.
+
+Security
+--------
+
+.. note::
+ ``applies_to`` decides whether a button is drawn, not what its target will
do. Anyone who
+ can read ``GET /plugins`` sees every action's ``href`` and ``bundle_url``
and can request
+ them directly, for any entity — including ones the scoping would have
hidden the button
+ for. A link action's target must authenticate and authorize every request
itself, and
+ validate the identifiers it is handed rather than trusting them.
+
+.. seealso::
+
+ - :doc:`/administration-and-deployment/plugins` — the full field reference
for
Review Comment:
plugins.rst doesn't mention `ui_actions` anywhere, so this points at a field
reference that doesn't exist yet. `icon` and `icon_dark_mode` aren't documented
on either page either.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]