Skip to content

Commit 68559b8

Browse files
authored
[Infra UI] Migrate route: Infrastructure → Hosts (/app/metrics/hosts) to AppHeader / AppMenu (#287736)
Closes #287801 ## Summary Hosts already lives in the project side nav. This PR moves that page onto the shared Kibana `AppHeader` and stops copying Chrome Next’s compatibility Back. ### What changes - **Title + no Back.** AppHeader title is **Hosts**. There is no “Back to Infrastructure inventory.” Hosts is a side-nav destination; Chrome Next showed Back only because breadcrumbs treated Inventory as a parent. Solution view loses that Back as well — that is the agreed IA, not a Hosts-page regression. - **Search stays in the body.** Filters, date picker, KPI cards, and the table sit under the header. They do not move into the title row. - **Actions move to the AppHeader menu.** Anomaly detection, Alerts, Settings, Inspect, and Add data leave the old chrome strip. - **Onboarding keeps the header.** A cluster with no host metrics still shows AppHeader. The Add-data card is the body. Search and table stay hidden. - **A failed has-data check keeps the table.** Blocking or 5xxing `GET /api/metrics/source/hasData?source=host` must not swap a live page for onboarding. Onboarding is only when that call succeeds and `hasData` is false. The existing “Error while fetching resource” toast still appears. - **Source / remote-cluster errors keep the header.** AppHeader still shows Alerts / More / Add data. The error is a compact centered card in the remaining pane, not a full-width banner. ### Implementation notes - Do not pass `noDataConfig` / `onboardingFlow` into `InfraPageTemplate`. That path unmounts `header`. - `showOnboarding` is `isSuccess(status) && !hasData`. Failure and pending stay on the table path (`hasDataOverride`). - `UnifiedSearchProvider` and `HostsTimeRangeMetadataProvider` wrap the template so a gate flip cannot tear down search subscriptions. - `/hosts` is appended to `METRICS_HEADER_PORTAL_EXCLUDED_PATHS` so the old `HeaderMenuPortal` does not mount a second action menu. - Menu EBT comes from the shared `useMetricsAppHeaderMenu` helper (after #289317). Hosts is the first Add data `detail: host` (`showHostsOnboarding`). Inventory / Explorer stay `detail: infra`. - Inspect moves into AppHeader **More**, still gated by `observability:enableInspectEsQueries` (default off). Overflow shots below were captured with the setting on. ### Solution view | | Before (Chrome Next) | After (App Header) | |:--|:--:|:--:| | No data view | ![Hosts solution no-data before](https://github.com/user-attachments/assets/9520c9f3-a256-4918-a3cf-31eeedacef40) | ![Hosts solution no-data after](https://github.com/user-attachments/assets/befbca5d-f225-4f8e-a462-d684b9fcddc5) | | Hosts view | ![Hosts solution before](https://github.com/user-attachments/assets/bec4d0b5-b279-4d71-991d-44e985584c25) | ![Hosts solution after](https://github.com/user-attachments/assets/a1f69e31-0ea8-45ad-85d3-87e033e432d4) | | Source error | ![Hosts solution source-error before](https://github.com/user-attachments/assets/6d9931ca-17e9-49f5-8b3d-0943b21701eb) | ![Hosts solution source-error after](https://github.com/user-attachments/assets/7445d352-85e5-44e5-ad09-3f62ee87c0e3) | | Has-data failure | ![Hosts solution has-data failure before](https://github.com/user-attachments/assets/078d919d-5463-42d9-a909-065681c18d3d) | ![Hosts solution has-data failure after](https://github.com/user-attachments/assets/6a0bd8a3-a2d0-4359-a4e2-74d63e1af211) | Overflow after (Settings, Inspect, Feedback; `observability:enableInspectEsQueries` on): ![Hosts solution overflow after](https://github.com/user-attachments/assets/054acb06-e536-41d6-a065-c94162b1f847) ### Classic view | | Before (Chrome Next) | After (App Header) | |:--|:--:|:--:| | No data view | ![Hosts classic no-data before](https://github.com/user-attachments/assets/b9c62f48-6743-48aa-a837-ba054936ee5b) | ![Hosts classic no-data after](https://github.com/user-attachments/assets/3cfbd2e1-ea30-4e98-8f72-fecd190132a7) | | Hosts view | ![Hosts classic before](https://github.com/user-attachments/assets/a89318c5-e3a0-472e-a51b-938e16fcf278) | ![Hosts classic after](https://github.com/user-attachments/assets/5abeee71-bb32-47aa-8bbc-7704010407f2) | | Source error | ![Hosts classic source-error before](https://github.com/user-attachments/assets/f40918d4-2ee4-4814-b97c-c03b5518de75) | ![Hosts classic source-error after](https://github.com/user-attachments/assets/e7eda3fc-4ef9-4ebb-8774-82393290ea2c) | | Has-data failure | ![Hosts classic has-data failure before](https://github.com/user-attachments/assets/eadc99bc-9059-49c2-9407-9b2180893a96) | ![Hosts classic has-data failure after](https://github.com/user-attachments/assets/86eccd60-34ea-48ea-88cc-ab8169a325f0) | Overflow after (Settings, Inspect, Feedback; `observability:enableInspectEsQueries` on): ![Hosts classic overflow after](https://github.com/user-attachments/assets/d879b232-56de-4acc-bc7c-409249584be6) ## How to test Repeat **every** step below in **both**: - **Solution view**: `/s/oblt/app/metrics/hosts` (create a space `oblt` with **Solution view: Observability** if it does not exist) - **Classic view**: Default space `/app/metrics/hosts` 1. Start with the **no-data** screen. Confirm one Hosts header (AppHeader title **Hosts**, no back) and the onboarding card in the body (“Detect and resolve problems with your hosts”, **Add data**). Search bar and table stay hidden. 2. Then check **AppHeader menu** items: - **Anomaly detection** - **Alerts** tray - **Settings** (under More) - **Inspect** (under More) after enabling **Inspect ES queries** in Advanced Settings (`observability:enableInspectEsQueries`). Same gate as the old chrome `InspectorHeaderLink`; default is off. Do not treat a missing inline Inspect as a drop. - **Add data** (onboarding category **host**; after #289317, `data-ebt-detail="host"`) 3. Scan for layout discrepancies or distortion: doubled chrome menus, clipped title, header not full-width, overlapping breadcrumbs, missing border. 4. With data: search, filters, KPI cards, and the hosts table stay **under** the header, not in the title. If the table is empty after ingest, set the date picker to **Last 24 hours** and **Schema** to **Elastic System Integration** (synthtrace host metrics are ECS). 5. **Do not expect** linked pages to already use AppHeader unless their PR has merged. Metrics Explorer, Settings, and Host details (`/app/metrics/detail/:type/:node`) are **separate PRs**. Opening those destinations from this page is expected to still show classic chrome until their PR lands. Host details and nested tabs are tested only in the Detail PR. 6. Confirm the **source-error** state (block the `/api/metrics/source/default` request URL segment in DevTools → Network → Request blocking to simulate a failed data-source load; use a wildcard such as `*/api/metrics/source/default*` so it also matches `/s/oblt/...`, and do not block `hasData`). AppHeader still shows Alerts / More / Add data. The error callout is a compact centered card in the remaining pane, not a full-width banner under the header. 7. Confirm a failed **has-data** call (block `*/api/metrics/source/hasData*`) keeps search + table. Do not show the onboarding card. On `main` that failure currently swaps in onboarding. <details> <summary>Checklist</summary> - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/docs/extend/kibana/contributing/workflow/how-we-use-github#release-notes) - [ ] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. </details> <details> <summary>Identify risks</summary> Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. - **Risk**: People used to the compatibility Back will look for it. **Mitigation**: called out here; Hosts remains in the side nav. - **Risk**: Reviewers open Inventory / Settings / Detail from Hosts and think those pages should already show AppHeader. **Mitigation**: testing disclaimer above. - **Risk**: Passing `noDataConfig` unmounts AppHeader. **Mitigation**: onboarding is body-only (`HostsOnboardingPage`); source-error keeps `header` on `InfraPageTemplate`. - **Risk**: A failed has-data refresh used to replace a live table with onboarding. **Mitigation**: `showOnboarding` is success && !hasData; search providers wrap the template. - **Risk**: Reviewers miss Inspect because it is overflow + setting-gated. **Mitigation**: how-to-test step 2 and overflow recapture with the setting on. - [ ] [See some risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) </details> ## Release note The Hosts page now uses the shared Kibana app header.
1 parent 20cbdc5 commit 68559b8

6 files changed

Lines changed: 325 additions & 29 deletions

File tree

x-pack/solutions/observability/plugins/infra/public/pages/metrics/header/is_metrics_header_portal_excluded.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import { isMetricsHeaderPortalExcluded } from './is_metrics_header_portal_exclud
99
import { METRICS_INVENTORY_PATH, METRICS_DETAIL_PATH } from './metrics_header_paths';
1010

1111
describe('isMetricsHeaderPortalExcluded', () => {
12-
it('excludes Inventory once that route consumes AppHeader', () => {
12+
it('excludes Inventory and Hosts once those routes consume AppHeader', () => {
1313
expect(isMetricsHeaderPortalExcluded('/inventory')).toBe(true);
1414
expect(isMetricsHeaderPortalExcluded('/inventory/extra')).toBe(true);
15-
expect(isMetricsHeaderPortalExcluded('/hosts')).toBe(false);
15+
expect(isMetricsHeaderPortalExcluded('/hosts')).toBe(true);
16+
expect(isMetricsHeaderPortalExcluded('/hosts/extra')).toBe(true);
1617
expect(isMetricsHeaderPortalExcluded('/detail/host/web-01')).toBe(false);
1718
});
1819

@@ -21,7 +22,7 @@ describe('isMetricsHeaderPortalExcluded', () => {
2122
expect(isMetricsHeaderPortalExcluded('/snapshot')).toBe(true);
2223
expect(isMetricsHeaderPortalExcluded('/snapshot/x')).toBe(true);
2324
expect(isMetricsHeaderPortalExcluded('/snapshot-other')).toBe(false);
24-
expect(isMetricsHeaderPortalExcluded('/hosts')).toBe(false);
25+
expect(isMetricsHeaderPortalExcluded('/explorer')).toBe(false);
2526
});
2627

2728
it('matches a parent path and its nested segments', () => {

x-pack/solutions/observability/plugins/infra/public/pages/metrics/header/is_metrics_header_portal_excluded.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* 2.0.
66
*/
77

8-
import { METRICS_INVENTORY_PATH } from './metrics_header_paths';
8+
import { METRICS_HOSTS_PATH, METRICS_INVENTORY_PATH } from './metrics_header_paths';
99

1010
/**
1111
* Metrics parent paths that already render AppHeader and must not also mount HeaderMenuPortal.
@@ -15,6 +15,7 @@ import { METRICS_INVENTORY_PATH } from './metrics_header_paths';
1515
*/
1616
export const METRICS_HEADER_PORTAL_EXCLUDED_PATHS: readonly string[] = [
1717
METRICS_INVENTORY_PATH,
18+
METRICS_HOSTS_PATH,
1819
'/snapshot',
1920
'/',
2021
];
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { NoDataPage } from '@kbn/shared-ux-page-no-data';
10+
import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana';
11+
import {
12+
getNoDataConfig,
13+
OnboardingFlow,
14+
} from '../../../../components/shared/templates/no_data_config';
15+
16+
/** Renders the Hosts onboarding card as page body so AppHeader can stay mounted. */
17+
export const HostsOnboardingPage = (): React.ReactElement | null => {
18+
const {
19+
services: { share, docLinks },
20+
} = useKibanaContextForPlugin();
21+
22+
const noDataConfig = getNoDataConfig({
23+
hasData: false,
24+
loading: false,
25+
onboardingFlow: OnboardingFlow.Hosts,
26+
docsLink: docLinks.links.observability.guide,
27+
locators: share.url.locators,
28+
});
29+
30+
if (!noDataConfig) {
31+
return null;
32+
}
33+
34+
return <NoDataPage {...noDataConfig} />;
35+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { GetHasDataResponse } from '../../../../../common/metrics_sources/get_has_data';
9+
import { isPending, isSuccess, useFetcher } from '../../../../hooks/use_fetcher';
10+
11+
export interface HostsHasData {
12+
hasData: boolean;
13+
loading: boolean;
14+
showOnboarding: boolean;
15+
}
16+
17+
/** Cluster-level host metrics existence (`source=host`), not the current table time range. */
18+
export const useHostsHasData = (): HostsHasData => {
19+
const { data, status } = useFetcher(async (callApi) => {
20+
return await callApi<GetHasDataResponse>('/api/metrics/source/hasData', {
21+
method: 'GET',
22+
query: { source: 'host' },
23+
});
24+
}, []);
25+
26+
const hasData = Boolean(data?.hasData);
27+
28+
return {
29+
hasData,
30+
loading: isPending(status),
31+
showOnboarding: isSuccess(status) && !hasData,
32+
};
33+
};
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { render, screen } from '@testing-library/react';
10+
import { EuiProvider } from '@elastic/eui';
11+
import { APP_HEADER_TEST_SUBJECTS } from '@kbn/app-header';
12+
import { MockAppHeaderProvider } from '@kbn/app-header/mocks';
13+
import { HostsPage } from '.';
14+
import { hostsTitle } from '../../../translations';
15+
16+
jest.mock('@kbn/core/public', () => ({
17+
APP_WRAPPER_CLASS: 'kbnAppWrapper',
18+
}));
19+
20+
type MockFetchStatus = 'loading' | 'success' | 'failure' | 'not_initiated' | 'pending';
21+
22+
const mockFetcherState: { hasData: boolean; status: MockFetchStatus } = {
23+
hasData: true,
24+
status: 'success',
25+
};
26+
27+
jest.mock('@kbn/observability-shared-plugin/public', () => ({
28+
useTrackPageview: jest.fn(),
29+
}));
30+
31+
jest.mock('../../../hooks/use_metrics_breadcrumbs', () => ({
32+
useMetricsBreadcrumbs: jest.fn(),
33+
}));
34+
35+
jest.mock('../../../hooks/use_fetcher', () => ({
36+
FETCH_STATUS: {
37+
LOADING: 'loading',
38+
SUCCESS: 'success',
39+
FAILURE: 'failure',
40+
NOT_INITIATED: 'not_initiated',
41+
PENDING: 'pending',
42+
},
43+
isPending: (status: string) =>
44+
status === 'loading' || status === 'not_initiated' || status === 'pending',
45+
isSuccess: (status: string) => status === 'success',
46+
useFetcher: () => ({
47+
data: mockFetcherState.status === 'failure' ? undefined : { hasData: mockFetcherState.hasData },
48+
status: mockFetcherState.status,
49+
}),
50+
}));
51+
52+
jest.mock('../../../hooks/use_kibana', () => ({
53+
useKibanaContextForPlugin: () => ({
54+
services: {
55+
share: {
56+
url: {
57+
locators: {
58+
get: () => ({ getRedirectUrl: () => '/app/observabilityOnboarding' }),
59+
},
60+
},
61+
},
62+
docLinks: { links: { observability: { guide: 'https://docs.elastic.co' } } },
63+
},
64+
}),
65+
}));
66+
67+
jest.mock('@kbn/shared-ux-page-no-data', () => ({
68+
NoDataPage: () => <div data-test-subj="kbnNoDataPage" />,
69+
}));
70+
71+
let lastInfraPageTemplateProps: { hasDataOverride?: boolean } = {};
72+
73+
jest.mock('../../../components/shared/templates/infra_page_template', () => ({
74+
InfraPageTemplate: ({
75+
children,
76+
hasDataOverride,
77+
header,
78+
}: {
79+
children: React.ReactNode;
80+
hasDataOverride?: boolean;
81+
header?: React.ReactNode;
82+
}) => {
83+
lastInfraPageTemplateProps = { hasDataOverride };
84+
return (
85+
<div data-test-subj="infraPageTemplate">
86+
{header}
87+
{children}
88+
</div>
89+
);
90+
},
91+
}));
92+
93+
jest.mock('./components/hosts_container', () => ({
94+
HostsContainer: () => <div data-test-subj="hostsContainer" />,
95+
}));
96+
97+
jest.mock('./components/search_bar/search_bar', () => ({
98+
SearchBar: () => <div data-test-subj="hostsSearchBar" />,
99+
}));
100+
101+
jest.mock('./hooks/use_unified_search', () => ({
102+
UnifiedSearchProvider: ({ children }: { children: React.ReactNode }) => children,
103+
}));
104+
105+
jest.mock('./hooks/use_hosts_metadata_provider', () => ({
106+
HostsTimeRangeMetadataProvider: ({ children }: { children: React.ReactNode }) => children,
107+
}));
108+
109+
jest.mock('../header/use_metrics_app_header_menu', () => ({
110+
useMetricsAppHeaderMenu: () => ({
111+
menu: { items: [] },
112+
flyouts: null,
113+
}),
114+
}));
115+
116+
const renderHostsPage = () =>
117+
render(
118+
<EuiProvider>
119+
<MockAppHeaderProvider>
120+
<HostsPage />
121+
</MockAppHeaderProvider>
122+
</EuiProvider>
123+
);
124+
125+
describe('HostsPage', () => {
126+
beforeEach(() => {
127+
mockFetcherState.hasData = true;
128+
mockFetcherState.status = 'success';
129+
lastInfraPageTemplateProps = {};
130+
});
131+
132+
it('renders AppHeader with the hosts title and no back control when host data exists', async () => {
133+
renderHostsPage();
134+
135+
expect(await screen.findByTestId(APP_HEADER_TEST_SUBJECTS.title)).toHaveTextContent(hostsTitle);
136+
expect(screen.queryByTestId(APP_HEADER_TEST_SUBJECTS.back)).not.toBeInTheDocument();
137+
expect(screen.getByTestId('hostsSearchBar')).toBeInTheDocument();
138+
expect(screen.getByTestId('hostsContainer')).toBeInTheDocument();
139+
expect(screen.queryByTestId('kbnNoDataPage')).not.toBeInTheDocument();
140+
expect(lastInfraPageTemplateProps.hasDataOverride).toBe(true);
141+
});
142+
143+
it('keeps AppHeader and shows onboarding instead of search and table when there is no host data', async () => {
144+
mockFetcherState.hasData = false;
145+
146+
renderHostsPage();
147+
148+
expect(await screen.findByTestId(APP_HEADER_TEST_SUBJECTS.title)).toHaveTextContent(hostsTitle);
149+
expect(screen.queryByTestId(APP_HEADER_TEST_SUBJECTS.back)).not.toBeInTheDocument();
150+
expect(screen.getByTestId('kbnNoDataPage')).toBeInTheDocument();
151+
expect(screen.queryByTestId('hostsSearchBar')).not.toBeInTheDocument();
152+
expect(screen.queryByTestId('hostsContainer')).not.toBeInTheDocument();
153+
expect(lastInfraPageTemplateProps.hasDataOverride).toBe(false);
154+
});
155+
156+
it('does not show onboarding while host data is loading', async () => {
157+
mockFetcherState.hasData = false;
158+
mockFetcherState.status = 'loading';
159+
160+
renderHostsPage();
161+
162+
expect(await screen.findByTestId(APP_HEADER_TEST_SUBJECTS.title)).toHaveTextContent(hostsTitle);
163+
expect(screen.queryByTestId('kbnNoDataPage')).not.toBeInTheDocument();
164+
expect(screen.getByTestId('hostsSearchBar')).toBeInTheDocument();
165+
expect(screen.getByTestId('hostsContainer')).toBeInTheDocument();
166+
expect(lastInfraPageTemplateProps.hasDataOverride).toBe(true);
167+
});
168+
169+
it('keeps search and table mounted when the has-data check fails', async () => {
170+
mockFetcherState.hasData = false;
171+
mockFetcherState.status = 'failure';
172+
173+
renderHostsPage();
174+
175+
expect(await screen.findByTestId(APP_HEADER_TEST_SUBJECTS.title)).toHaveTextContent(hostsTitle);
176+
expect(screen.queryByTestId('kbnNoDataPage')).not.toBeInTheDocument();
177+
expect(screen.getByTestId('hostsSearchBar')).toBeInTheDocument();
178+
expect(screen.getByTestId('hostsContainer')).toBeInTheDocument();
179+
expect(lastInfraPageTemplateProps.hasDataOverride).toBe(true);
180+
});
181+
});

0 commit comments

Comments
 (0)