This is an automated email from the ASF dual-hosted git repository.
vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 3e5b235f572 UI: Show every scheduler, triggerer, and Dag processor in
Health (#73355)
3e5b235f572 is described below
commit 3e5b235f57285f4ab208cbf2cecde1424ca76da2
Author: Vincent <[email protected]>
AuthorDate: Tue Sep 22 09:57:47 2026 -0400
UI: Show every scheduler, triggerer, and Dag processor in Health (#73355)
The Health badges reported only whether one instance of each component was
alive, so an HA deployment running several schedulers or triggerers could not
tell from the Dashboard which replicas were up.
---
.../ui/public/i18n/locales/en/dashboard.json | 17 +-
.../ui/src/pages/Dashboard/Health/Health.test.tsx | 360 +++++++++++++++++++++
.../ui/src/pages/Dashboard/Health/Health.tsx | 41 ++-
.../ui/src/pages/Dashboard/Health/HealthBadge.tsx | 107 ++++--
.../src/pages/Dashboard/Health/HealthInstances.tsx | 89 +++++
.../pages/Dashboard/Health/HealthStateBadge.tsx | 39 +++
.../ui/src/pages/Dashboard/Health/healthStatus.ts | 41 +++
7 files changed, 660 insertions(+), 34 deletions(-)
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 1b131ee7f26..73f488f1651 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
@@ -28,14 +28,29 @@
"group": "Group",
"health": {
"dagProcessor": "Dag Processor",
+ "degraded": "Degraded",
+ "degradedHint": {
+ "dagProcessor": "Some configured Dag bundles have no Dag processor
parsing them.",
+ "triggerer": "Some teams have no triggerer running deferred tasks for
them."
+ },
+ "down": "Down",
"health": "Health",
"healthy": "Healthy",
+ "instances": {
+ "bundles": "Bundles",
+ "hostname": "Host",
+ "team": "Team",
+ "title_one": "{{title}} — {{status}} ({{count}} instance)",
+ "title_other": "{{title}} — {{status}} ({{count}} instances)",
+ "unknownHostname": "Unknown host"
+ },
"lastHeartbeat": "Last Heartbeat",
"metaDatabase": "MetaDatabase",
"scheduler": "Scheduler",
"status": "Status",
"triggerer": "Triggerer",
- "unhealthy": "Unhealthy"
+ "unhealthy": "Unhealthy",
+ "unknownStatus": "Unknown"
},
"history": "History",
"importErrors": {
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/Health.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/Health.test.tsx
new file mode 100644
index 00000000000..c4c8fda2599
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/Health.test.tsx
@@ -0,0 +1,360 @@
+/*!
+ * 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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { DetailedHealthStatus, HealthInfoResponse } from
"openapi/requests/types.gen";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { Health } from "./Health";
+
+const mocks = vi.hoisted(() => ({ useMonitorServiceGetHealth: vi.fn() }));
+
+vi.mock("openapi/queries", () => ({
+ useMonitorServiceGetHealth: mocks.useMonitorServiceGetHealth,
+}));
+
+vi.mock("src/utils", () => ({ useAutoRefresh: () => false }));
+
+const mockConfig: Record<string, unknown> = { multi_team: false };
+
+vi.mock("src/queries/useConfig", () => ({
+ useConfig: (key: string) => mockConfig[key],
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ i18n: { language: "en" },
+ // eslint-disable-next-line id-length
+ t: (key: string, options?: Record<string, unknown>) =>
+ key === "health.instances.title"
+ ? `${options?.title as string} — ${options?.status as string}
(${options?.count as number})`
+ : key,
+ }),
+}));
+
+const health = (overrides: Partial<HealthInfoResponse> = {}):
HealthInfoResponse => ({
+ dag_processor: null,
+ metadatabase: { status: "healthy" },
+ scheduler: {
+ detailed_status: "healthy",
+ instances: null,
+ latest_scheduler_heartbeat: "2026-09-11T10:00:00Z",
+ status: "healthy",
+ },
+ triggerer: {
+ detailed_status: "healthy",
+ instances: null,
+ latest_triggerer_heartbeat: "2026-09-11T10:00:00Z",
+ status: "healthy",
+ },
+ ...overrides,
+});
+
+const withSchedulers = (
+ instances: HealthInfoResponse["scheduler"]["instances"],
+ detailedStatus: DetailedHealthStatus = "healthy",
+) =>
+ health({
+ scheduler: {
+ detailed_status: detailedStatus,
+ instances,
+ latest_scheduler_heartbeat: "2026-09-11T10:00:00Z",
+ status: "healthy",
+ },
+ });
+
+const withDagProcessors = (
+ instances: NonNullable<HealthInfoResponse["dag_processor"]>["instances"],
+ detailedStatus: DetailedHealthStatus = "healthy",
+) =>
+ health({
+ dag_processor: {
+ detailed_status: detailedStatus,
+ instances,
+ latest_dag_processor_heartbeat: "2026-09-11T10:00:00Z",
+ status: "healthy",
+ },
+ });
+
+const renderHealth = (data: HealthInfoResponse) => {
+ mocks.useMonitorServiceGetHealth.mockReturnValue({ data, error: undefined,
isLoading: false });
+
+ return render(<Health />, { wrapper: Wrapper });
+};
+
+const openBadge = async (title: string) => {
+ fireEvent.click(screen.getByRole("button", { name: title }));
+
+ await waitFor(() => expect(screen.getByRole("table")).toBeInTheDocument());
+};
+
+describe("Health", () => {
+ beforeEach(() => {
+ mocks.useMonitorServiceGetHealth.mockReset();
+ });
+
+ afterEach(() => {
+ mockConfig.multi_team = false;
+ });
+
+ it("does not make a component clickable when the endpoint reports no
instances", () => {
+ renderHealth(health());
+
+ expect(screen.getByText("health.scheduler")).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("moves focus into the instance list so it is reachable from the
keyboard", async () => {
+ renderHealth(
+ withSchedulers([
+ {
+ hostname: "scheduler-1.example.com",
+ latest_scheduler_heartbeat: "2026-09-11T10:00:00Z",
+ },
+ ]),
+ );
+
+ await openBadge("health.scheduler");
+
+ await waitFor(() =>
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true));
+ });
+
+ it("lists every running scheduler with its own heartbeat", async () => {
+ renderHealth(
+ withSchedulers([
+ {
+ hostname: "scheduler-1.example.com",
+ latest_scheduler_heartbeat: "2026-09-11T10:00:00Z",
+ },
+ {
+ hostname: "scheduler-2.example.com",
+ latest_scheduler_heartbeat: "2026-09-11T09:00:00Z",
+ },
+ ]),
+ );
+
+ await openBadge("health.scheduler");
+
+ expect(screen.getByText("scheduler-1.example.com")).toBeInTheDocument();
+ expect(screen.getByText("scheduler-2.example.com")).toBeInTheDocument();
+ expect(screen.getAllByTestId("time-display")).toHaveLength(2);
+ });
+
+ it("titles the instance list with detailed_status rather than the legacy
status", async () => {
+ renderHealth(
+ withDagProcessors(
+ [
+ {
+ bundle_names: ["dags-team-a"],
+ hostname: "dag-processor-1.example.com",
+ latest_dag_processor_heartbeat: "2026-09-11T10:00:00Z",
+ },
+ ],
+ "degraded",
+ ),
+ );
+
+ await openBadge("health.dagProcessor");
+
+ expect(screen.getByText("health.dagProcessor — health.degraded
(1)")).toBeInTheDocument();
+ });
+
+ it("explains what degraded means for the component reporting it", async ()
=> {
+ renderHealth(
+ withDagProcessors(
+ [
+ {
+ bundle_names: ["dags-team-a"],
+ hostname: "dag-processor-1.example.com",
+ latest_dag_processor_heartbeat: "2026-09-11T10:00:00Z",
+ },
+ ],
+ "degraded",
+ ),
+ );
+
+ await openBadge("health.dagProcessor");
+
+
expect(screen.getByText("health.degradedHint.dagProcessor")).toBeInTheDocument();
+ });
+
+ it("omits the degraded explanation when every part of the work is covered",
async () => {
+ renderHealth(
+ withDagProcessors([
+ {
+ bundle_names: ["dags-team-a"],
+ hostname: "dag-processor-1.example.com",
+ latest_dag_processor_heartbeat: "2026-09-11T10:00:00Z",
+ },
+ ]),
+ );
+
+ await openBadge("health.dagProcessor");
+
+
expect(screen.queryByText("health.degradedHint.dagProcessor")).not.toBeInTheDocument();
+ });
+
+ it("labels a status the UI does not know as unknown", async () => {
+ renderHealth(
+ withSchedulers(
+ [
+ {
+ hostname: "scheduler-1.example.com",
+ latest_scheduler_heartbeat: "2026-09-11T10:00:00Z",
+ },
+ ],
+ // Cast: a newer API can report a status this UI version has no
mapping for.
+ "sideways" as DetailedHealthStatus,
+ ),
+ );
+
+ await openBadge("health.scheduler");
+
+ expect(screen.getByText("health.scheduler — health.unknownStatus
(1)")).toBeInTheDocument();
+ });
+
+ it("falls back to a placeholder when an instance reports no hostname", async
() => {
+ renderHealth(withSchedulers([{ hostname: null, latest_scheduler_heartbeat:
"2026-09-11T10:00:00Z" }]));
+
+ await openBadge("health.scheduler");
+
+
expect(screen.getByText("health.instances.unknownHostname")).toBeInTheDocument();
+ });
+
+ it("shows the owning team of each triggerer only when multi-team is
enabled", async () => {
+ mockConfig.multi_team = true;
+ renderHealth(
+ health({
+ triggerer: {
+ detailed_status: "healthy",
+ instances: [
+ {
+ hostname: "triggerer-1.example.com",
+ latest_triggerer_heartbeat: "2026-09-11T10:00:00Z",
+ team_name: "team-a",
+ },
+ ],
+ latest_triggerer_heartbeat: "2026-09-11T10:00:00Z",
+ status: "healthy",
+ },
+ }),
+ );
+
+ await openBadge("health.triggerer");
+
+ expect(screen.getByText("health.instances.team")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "team-a"
})).toHaveAttribute("href", "/dags?teams=team-a");
+ });
+
+ it("hides the team column when multi-team is disabled", async () => {
+ renderHealth(
+ health({
+ triggerer: {
+ detailed_status: "healthy",
+ instances: [
+ {
+ hostname: "triggerer-1.example.com",
+ latest_triggerer_heartbeat: "2026-09-11T10:00:00Z",
+ team_name: "team-a",
+ },
+ ],
+ latest_triggerer_heartbeat: "2026-09-11T10:00:00Z",
+ status: "healthy",
+ },
+ }),
+ );
+
+ await openBadge("health.triggerer");
+
+
expect(screen.queryByText("health.instances.team")).not.toBeInTheDocument();
+ expect(screen.queryByText("team-a")).not.toBeInTheDocument();
+ });
+
+ it("hides the team column when every triggerer is unscoped", async () => {
+ mockConfig.multi_team = true;
+ renderHealth(
+ health({
+ triggerer: {
+ detailed_status: "healthy",
+ instances: [
+ {
+ hostname: "triggerer-1.example.com",
+ latest_triggerer_heartbeat: "2026-09-11T10:00:00Z",
+ team_name: null,
+ },
+ ],
+ latest_triggerer_heartbeat: "2026-09-11T10:00:00Z",
+ status: "healthy",
+ },
+ }),
+ );
+
+ await openBadge("health.triggerer");
+
+
expect(screen.queryByText("health.instances.team")).not.toBeInTheDocument();
+ });
+
+ it("lists the bundles each Dag processor instance parses", async () => {
+ renderHealth(
+ withDagProcessors([
+ {
+ bundle_names: ["dags-team-a", "dags-team-b"],
+ hostname: "dag-processor-1.example.com",
+ latest_dag_processor_heartbeat: "2026-09-11T10:00:00Z",
+ },
+ ]),
+ );
+
+ await openBadge("health.dagProcessor");
+
+ expect(screen.getByText("health.instances.bundles")).toBeInTheDocument();
+ expect(screen.getByText("dags-team-a, dags-team-b")).toBeInTheDocument();
+ });
+
+ it("hides the bundles column when no Dag processor reports one", async () =>
{
+ renderHealth(
+ withDagProcessors([
+ {
+ bundle_names: null,
+ hostname: "dag-processor-1.example.com",
+ latest_dag_processor_heartbeat: "2026-09-11T10:00:00Z",
+ },
+ ]),
+ );
+
+ await openBadge("health.dagProcessor");
+
+
expect(screen.queryByText("health.instances.bundles")).not.toBeInTheDocument();
+ });
+
+ it("renders skeletons while the health request is in flight", () => {
+ mocks.useMonitorServiceGetHealth.mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: true,
+ });
+
+ render(<Health />, { wrapper: Wrapper });
+
+ expect(screen.queryByText("health.scheduler")).not.toBeInTheDocument();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/Health.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/Health.tsx
index 4aec18f9aaa..91b68c949bf 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/Health.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/Health.tsx
@@ -21,12 +21,38 @@ import { useTranslation } from "react-i18next";
import { MdOutlineHealthAndSafety } from "react-icons/md";
import { useMonitorServiceGetHealth } from "openapi/queries";
+import type {
+ DagProcessorInstanceInfoResponse,
+ SchedulerInstanceInfoResponse,
+ TriggererInstanceInfoResponse,
+} from "openapi/requests/types.gen";
import { ErrorAlert } from "src/components/ErrorAlert";
import { useAutoRefresh } from "src/utils";
import { HealthBadge } from "./HealthBadge";
+import type { HealthInstance } from "./HealthInstances";
+
+const schedulerInstances = (instances?: Array<SchedulerInstanceInfoResponse> |
null) =>
+ instances?.map((instance): HealthInstance => ({
+ hostname: instance.hostname,
+ latestHeartbeat: instance.latest_scheduler_heartbeat,
+ }));
+
+const triggererInstances = (instances?: Array<TriggererInstanceInfoResponse> |
null) =>
+ instances?.map((instance): HealthInstance => ({
+ hostname: instance.hostname,
+ latestHeartbeat: instance.latest_triggerer_heartbeat,
+ teamName: instance.team_name,
+ }));
+
+const dagProcessorInstances = (instances?:
Array<DagProcessorInstanceInfoResponse> | null) =>
+ instances?.map((instance): HealthInstance => ({
+ bundleNames: instance.bundle_names,
+ hostname: instance.hostname,
+ latestHeartbeat: instance.latest_dag_processor_heartbeat,
+ }));
export const Health = () => {
const refetchInterval = useAutoRefresh({ checkPendingRuns: true });
@@ -51,23 +77,32 @@ export const Health = () => {
status={data?.metadatabase.status}
title={translate("health.metaDatabase")}
/>
+ {/* ``detailed_status`` is preferred over the legacy ``status``: the
latter only reports
+ whether one replica is alive, while the former also reports
"degraded" when the component
+ divides its work up and part of that work has no live replica
covering it. Which work
+ that is differs per component, so each passes its own explanation
of "degraded". */}
<HealthBadge
+ instances={schedulerInstances(data?.scheduler.instances)}
isLoading={isLoading}
latestHeartbeat={data?.scheduler.latest_scheduler_heartbeat}
- status={data?.scheduler.status}
+ status={data?.scheduler.detailed_status ?? data?.scheduler.status}
title={translate("health.scheduler")}
/>
<HealthBadge
+ degradedHint={translate("health.degradedHint.triggerer")}
+ instances={triggererInstances(data?.triggerer.instances)}
isLoading={isLoading}
latestHeartbeat={data?.triggerer.latest_triggerer_heartbeat}
- status={data?.triggerer.status}
+ status={data?.triggerer.detailed_status ?? data?.triggerer.status}
title={translate("health.triggerer")}
/>
{data?.dag_processor ? (
<HealthBadge
+ degradedHint={translate("health.degradedHint.dagProcessor")}
+ instances={dagProcessorInstances(data.dag_processor.instances)}
isLoading={isLoading}
latestHeartbeat={data.dag_processor.latest_dag_processor_heartbeat}
- status={data.dag_processor.status}
+ status={data.dag_processor.detailed_status ??
data.dag_processor.status}
title={translate("health.dagProcessor")}
/>
) : undefined}
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/HealthBadge.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/HealthBadge.tsx
index 1361bb4f478..faf6741ed2b 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/HealthBadge.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/HealthBadge.tsx
@@ -16,53 +16,100 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Skeleton, Text } from "@chakra-ui/react";
+import { Heading, Skeleton, Text } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
-import { Tooltip } from "src/system-components";
+import { Popover, Tooltip } from "src/system-components";
-import { StateBadge } from "src/components/StateBadge";
import Time from "src/components/Time";
+import { type HealthInstance, HealthInstances } from "./HealthInstances";
+import { HealthStateBadge } from "./HealthStateBadge";
+import { DEGRADED, healthTranslationKey } from "./healthStatus";
+
+type Props = {
+ /** Why this component reports "degraded"; components that can never report
it omit it. */
+ readonly degradedHint?: string;
+ readonly instances?: Array<HealthInstance> | null;
+ readonly isLoading: boolean;
+ readonly latestHeartbeat?: string | null;
+ readonly status?: string | null;
+ readonly title: string;
+};
+
export const HealthBadge = ({
+ degradedHint,
+ instances,
isLoading,
latestHeartbeat,
status,
title,
-}: {
- readonly isLoading: boolean;
- readonly latestHeartbeat?: string | null;
- readonly status?: string | null;
- readonly title: string;
-}) => {
+}: Props) => {
const { t: translate } = useTranslation("dashboard");
if (isLoading) {
return <Skeleton borderRadius="full" height={8} width={24} />;
}
- const state = status === "healthy" ? "success" : "failed";
+ const hasInstances = instances !== null && instances !== undefined &&
instances.length > 0;
+
+ // A tooltip trigger cannot double as the popover trigger: both are
``asChild`` and the tooltip
+ // wins the merge of ``id`` and the ``data-scope``/``data-part`` pair, which
leaves the popover
+ // positioner without an anchor and drops the panel in the corner of the
viewport. Components
+ // that report instances therefore rely on the popover alone, which repeats
the status and gives
+ // a per-instance heartbeat, so nothing the tooltip showed is lost.
+ if (!hasInstances) {
+ return (
+ <Tooltip
+ content={
+ <div>
+ <Text>
+ {translate("health.status")}
+ {": "}
+ {translate(healthTranslationKey(status))}
+ </Text>
+ <Text hidden={latestHeartbeat === undefined}>
+ {translate("health.lastHeartbeat")}
+ {": "}
+ <Time datetime={latestHeartbeat} />
+ </Text>
+ </div>
+ }
+ >
+ <HealthStateBadge size="lg" status={status}>
+ {title}
+ </HealthStateBadge>
+ </Tooltip>
+ );
+ }
return (
- <Tooltip
- content={
- <div>
- <Text>
- {translate("health.status")}
- {": "}
- {translate(`health.${status}`)}
- </Text>
- <Text hidden={latestHeartbeat === undefined}>
- {translate("health.lastHeartbeat")}
- {": "}
- <Time datetime={latestHeartbeat} />
- </Text>
- </div>
- }
- >
- <StateBadge size="lg" state={state}>
- {title}
- </StateBadge>
- </Tooltip>
+ <Popover.Root lazyMount unmountOnExit>
+ <Popover.Trigger asChild>
+ <HealthStateBadge as="button" cursor="pointer" size="lg"
status={status}>
+ {title}
+ </HealthStateBadge>
+ </Popover.Trigger>
+ <Popover.Content css={{ "--popover-bg": "colors.bg.emphasized" }}
width="fit-content">
+ <Popover.Arrow />
+ <Popover.Body>
+ <Heading mb={2} size="xs">
+ {translate("health.instances.title", {
+ count: instances.length,
+ status: translate(healthTranslationKey(status)),
+ title,
+ })}
+ </Heading>
+ {/* Every listed replica is running, so a degraded badge is
otherwise unexplained: the
+ missing coverage is work no replica has picked up rather than a
replica that is down. */}
+ {status === DEGRADED && degradedHint !== undefined ? (
+ <Text color="fg.muted" maxWidth="xs" mb={2}>
+ {degradedHint}
+ </Text>
+ ) : undefined}
+ <HealthInstances instances={instances} />
+ </Popover.Body>
+ </Popover.Content>
+ </Popover.Root>
);
};
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/HealthInstances.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/HealthInstances.tsx
new file mode 100644
index 00000000000..537b23b799a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/HealthInstances.tsx
@@ -0,0 +1,89 @@
+/*!
+ * 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 { Table, Text } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import { TeamName } from "src/components/TeamName";
+import Time from "src/components/Time";
+
+import { useConfig } from "src/queries/useConfig";
+
+/**
+ * One live component replica, normalised across the three per-component
instance payloads so the
+ * table does not have to know which ``latest_*_heartbeat`` field the endpoint
used. ``teamName``
+ * and ``bundleNames`` are left undefined for components that do not report
them at all. There is no
+ * per-instance status: the endpoint only lists replicas that are running, and
how healthy the set of
+ * them is together is what the component badge shows.
+ */
+export type HealthInstance = {
+ readonly bundleNames?: Array<string> | null;
+ readonly hostname: string | null;
+ readonly latestHeartbeat: string | null;
+ readonly teamName?: string | null;
+};
+
+type Props = {
+ readonly instances: Array<HealthInstance>;
+};
+
+export const HealthInstances = ({ instances }: Props) => {
+ const { t: translate } = useTranslation("dashboard");
+ const isMultiTeam = Boolean(useConfig("multi_team"));
+ // Both columns only apply to one component each, and even there every
instance may report
+ // nothing — an all-empty column is noise, so it is dropped rather than
rendered blank.
+ const showTeam = isMultiTeam && instances.some((instance) =>
Boolean(instance.teamName));
+ const showBundles = instances.some((instance) =>
Boolean(instance.bundleNames?.length));
+
+ return (
+ <Table.Root size="sm">
+ <Table.Header>
+ <Table.Row>
+
<Table.ColumnHeader>{translate("health.instances.hostname")}</Table.ColumnHeader>
+
<Table.ColumnHeader>{translate("health.lastHeartbeat")}</Table.ColumnHeader>
+ {showTeam ? (
+
<Table.ColumnHeader>{translate("health.instances.team")}</Table.ColumnHeader>
+ ) : undefined}
+ {showBundles ? (
+
<Table.ColumnHeader>{translate("health.instances.bundles")}</Table.ColumnHeader>
+ ) : undefined}
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
+ {instances.map((instance) => (
+ <Table.Row key={`${instance.hostname ??
""}-${instance.latestHeartbeat ?? ""}`}>
+ <Table.Cell>
+ <Text fontFamily="mono">
+ {instance.hostname ??
translate("health.instances.unknownHostname")}
+ </Text>
+ </Table.Cell>
+ <Table.Cell>
+ <Time datetime={instance.latestHeartbeat} />
+ </Table.Cell>
+ {showTeam ? (
+ <Table.Cell>
+ <TeamName teamName={instance.teamName} />
+ </Table.Cell>
+ ) : undefined}
+ {showBundles ? <Table.Cell>{instance.bundleNames?.join(",
")}</Table.Cell> : undefined}
+ </Table.Row>
+ ))}
+ </Table.Body>
+ </Table.Root>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/HealthStateBadge.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/HealthStateBadge.tsx
new file mode 100644
index 00000000000..1cc0fd4c148
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/HealthStateBadge.tsx
@@ -0,0 +1,39 @@
+/*!
+ * 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 { forwardRef } from "react";
+
+import { useTranslation } from "react-i18next";
+
+import { StateBadge, type Props as StateBadgeProps } from
"src/components/StateBadge";
+
+import { healthState, healthTranslationKey } from "./healthStatus";
+
+type Props = {
+ readonly status?: string | null;
+} & Omit<StateBadgeProps, "state">;
+
+export const HealthStateBadge = forwardRef<HTMLDivElement, Props>(({ children,
status, ...rest }, ref) => {
+ const { t: translate } = useTranslation("dashboard");
+
+ return (
+ <StateBadge ref={ref} state={healthState(status)} {...rest}>
+ {children ?? translate(healthTranslationKey(status))}
+ </StateBadge>
+ );
+});
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/healthStatus.ts
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/healthStatus.ts
new file mode 100644
index 00000000000..808f5119b96
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Health/healthStatus.ts
@@ -0,0 +1,41 @@
+/*!
+ * 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 { TaskInstanceState } from "openapi/requests/types.gen";
+
+/** Part of the component's work has no live instance covering it; the rest
does. */
+export const DEGRADED = "degraded";
+
+// The health endpoint reports its own vocabulary ("healthy" / "degraded" /
"down" / "unhealthy"),
+// so it is mapped onto the task-state palette the rest of the UI already
colours badges with:
+// "degraded" borrows the yellow of up_for_retry to distinguish partial
coverage from none at all.
+const HEALTH_STATES: Record<string, TaskInstanceState> = {
+ [DEGRADED]: "up_for_retry",
+ down: "failed",
+ healthy: "success",
+ unhealthy: "failed",
+};
+
+/** A null state paints the neutral palette, for a status this UI version has
no mapping for. */
+export const healthState = (status?: string | null): TaskInstanceState | null
=>
+ HEALTH_STATES[status ?? ""] ?? null;
+
+export const healthTranslationKey = (status?: string | null) =>
+ status !== null && status !== undefined && status in HEALTH_STATES
+ ? `health.${status}`
+ : "health.unknownStatus";