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";

Reply via email to