Skip to content

Commit c19ed52

Browse files
authored
[APM] [Controls Anywhere] Fix rendering pinned control group in Service dashboards (elastic#250960)
## Summary Closes elastic#250125 Fixes rendering the `ControlsRenderer` in Service dashboards, and by extension, any future contexts which just embed the `DashboardRenderer` by itself without any of the rest of the Dashboard app: <img width="1650" height="857" alt="Screenshot 2026-01-29 at 12 16 20 PM" src="https://github.com/user-attachments/assets/1e711deb-dcbc-4305-ab50-a671c7914307" /> Note that I confirmed that this is how the Control Group was displayed in 9.3, underneath the dashboard title with a bit of whitespace like this. Here's a screenshot from a 9.3 cluster: <img width="1559" height="852" alt="Screenshot 2026-01-29 at 12 21 21 PM" src="https://github.com/user-attachments/assets/0f444718-285b-49a6-be67-85b7bb81a09a" /> ### Testing Services are difficult to generate locally. This feature is most easily tested by connecting to an `edge-oblt-lite` CCS cluster using [this guide](https://studious-disco-k66oojq.pages.github.io/user-guide/cluster-create-ccs/). Note that Service dashboards filter all controls by `service.name: [current service]`, so there will be less available options and data in controls than you'd see in the main Dashboard view. ### Implementation details - Controls Anywhere implemented two components, a `ControlGroupRenderer` meant to render controls outside of a Dashboard (e.g. at the top of Alerts tables), and a `ControlsRenderer` which is used internally by `ControlGroupRenderer` AND by Dashboard control groups. `ControlGroupRenderer` provides a lot of APIs which are already provided by the Dashboard API, which is why we have this distinction - The Dashboard was generating a slice of the Dashboard API and passing it to `ControlsRenderer` inside the `InternalDashboardTopNav`. This PR abstracts this logic out of `InternalDashboardTopNav` and into a `DashboardControlsRenderer`. - `DashboardRenderer` will now, by default, display `DashboardControlsRenderer` above the `DashboardViewport`, *unless* it is passed `showControlGroup={false}`. This allows the Service Dashboard to import only `DashboardRenderer` and successfully render both the dashboard and its control group. Any future uses of `DashboardRenderer` in this way will also work correctly. - The Dashboard App *does* pass `showControlGroup={false}` and displays `DashboardControlsRenderer` in its original place in `InternalDashboardTopNav`. ### Checklist - [x] [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
1 parent 0890244 commit c19ed52

11 files changed

Lines changed: 305 additions & 52 deletions

File tree

src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api/load_dashboard_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,5 +116,6 @@ export async function loadDashboardApi({
116116
performanceSubscription.unsubscribe();
117117
},
118118
internalApi,
119+
useControlsIntegration: creationOptions?.useControlsIntegration,
119120
};
120121
}

src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ export interface DashboardCreationOptions {
100100
/** Settings for unified search integration. */
101101
unifiedSearchSettings?: { kbnUrlStateStorage: IKbnUrlStateStorage };
102102

103+
/** Whether to render the control group above the dashboard viewport. */
104+
useControlsIntegration?: boolean;
105+
103106
/**
104107
* Validates a loaded saved object and determines whether it is valid.
105108
*

src/platform/plugins/shared/dashboard/public/dashboard_app/dashboard_app.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ export function DashboardApp({
181181
// integrations
182182
useSessionStorageIntegration: true,
183183
useUnifiedSearchIntegration: true,
184+
// Hide the control group from the dashboard renderer; the dashboard app handles displaying
185+
// pinned controls in the top nav instead
186+
useControlsIntegration: false,
184187
unifiedSearchSettings: {
185188
kbnUrlStateStorage,
186189
},
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import type { ControlsLayout } from '@kbn/controls-renderer';
11+
import { ControlsRenderer } from '@kbn/controls-renderer';
12+
import React, { useEffect, useCallback, useState } from 'react';
13+
import type { DashboardLayout } from '../dashboard_api/layout_manager';
14+
import { useDashboardApi } from '../dashboard_api/use_dashboard_api';
15+
16+
export const DashboardControlsRenderer = () => {
17+
const dashboardApi = useDashboardApi();
18+
const onControlsLayoutChanged = useCallback(
19+
({ controls }: ControlsLayout) => {
20+
dashboardApi.layout$.next({
21+
...dashboardApi.layout$.getValue(),
22+
pinnedPanels: controls,
23+
});
24+
},
25+
[dashboardApi.layout$]
26+
);
27+
28+
const [controls, setControls] = useState<{ controls: DashboardLayout['pinnedPanels'] }>({
29+
controls: dashboardApi.layout$.getValue().pinnedPanels,
30+
});
31+
useEffect(() => {
32+
const controlLayoutChangedSubscription = dashboardApi.layout$.subscribe(({ pinnedPanels }) => {
33+
setControls({ controls: pinnedPanels });
34+
});
35+
return () => {
36+
controlLayoutChangedSubscription.unsubscribe();
37+
};
38+
}, [dashboardApi.layout$]);
39+
40+
useEffect(() => {
41+
const controlLayoutChangedSubscription = dashboardApi.layout$.subscribe(({ pinnedPanels }) => {
42+
setControls({ controls: pinnedPanels });
43+
});
44+
return () => {
45+
controlLayoutChangedSubscription.unsubscribe();
46+
};
47+
}, [dashboardApi.layout$]);
48+
49+
return (
50+
<ControlsRenderer
51+
parentApi={dashboardApi}
52+
controls={controls} // only controls can currently be pinned
53+
onControlsChanged={onControlsLayoutChanged}
54+
/>
55+
);
56+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
export { DashboardControlsRenderer } from './dashboard_controls_renderer';
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import React from 'react';
11+
import { render, screen, waitFor } from '@testing-library/react';
12+
13+
import { buildMockDashboardApi } from '../mocks';
14+
import { dataService } from '../services/kibana_services';
15+
import { DashboardRenderer } from './dashboard_renderer';
16+
import { loadDashboardApi } from '../dashboard_api/load_dashboard_api';
17+
import type { DashboardPinnedPanelsState } from '../../common';
18+
19+
jest.mock('../dashboard_api/load_dashboard_api');
20+
21+
describe('Dashboard Renderer', () => {
22+
dataService.query.filterManager.getFilters = jest.fn().mockImplementation(() => []);
23+
24+
(loadDashboardApi as jest.Mock).mockImplementation(async ({ getCreationOptions }) => {
25+
const { useControlsIntegration } = await (getCreationOptions?.() ?? Promise.resolve({}));
26+
return {
27+
useControlsIntegration,
28+
...buildMockDashboardApi({
29+
overrides: {
30+
pinned_panels: [
31+
{
32+
type: 'optionsListControl',
33+
},
34+
] as DashboardPinnedPanelsState,
35+
},
36+
}),
37+
};
38+
});
39+
40+
it('renders the dashboard control group and dashboard viewport', async () => {
41+
render(<DashboardRenderer />);
42+
43+
await waitFor(async () => {
44+
expect(await screen.queryByTestId('dshDashboardViewport')).toBeInTheDocument();
45+
expect(await screen.queryByTestId('controls-group-wrapper')).toBeInTheDocument();
46+
});
47+
});
48+
49+
it('renders only the dashboard viewport when useControlsIntegration is false', async () => {
50+
render(
51+
<DashboardRenderer
52+
getCreationOptions={() => Promise.resolve({ useControlsIntegration: false })}
53+
/>
54+
);
55+
56+
await waitFor(async () => {
57+
expect(await screen.queryByTestId('dshDashboardViewport')).toBeInTheDocument();
58+
expect(await screen.queryByTestId('controls-group-wrapper')).not.toBeInTheDocument();
59+
});
60+
});
61+
});

src/platform/plugins/shared/dashboard/public/dashboard_renderer/dashboard_renderer.tsx

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,35 @@
88
*/
99

1010
import classNames from 'classnames';
11-
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
11+
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
1212

13-
import { EuiEmptyPrompt, EuiLoadingElastic, EuiLoadingSpinner } from '@elastic/eui';
13+
import {
14+
EuiEmptyPrompt,
15+
EuiLoadingElastic,
16+
EuiLoadingSpinner,
17+
useEuiPaddingSize,
18+
} from '@elastic/eui';
1419
import { css } from '@emotion/react';
1520
import { i18n } from '@kbn/i18n';
1621
import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common';
1722
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
1823
import type { LocatorPublic } from '@kbn/share-plugin/common';
1924
import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen';
2025

26+
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
2127
import type { DashboardLocatorParams } from '../../common';
2228
import type { DashboardApi, DashboardInternalApi } from '../dashboard_api/types';
2329
import type { DashboardCreationOptions } from '..';
2430
import { loadDashboardApi } from '../dashboard_api/load_dashboard_api';
2531
import { DashboardContext } from '../dashboard_api/use_dashboard_api';
2632
import { DashboardInternalContext } from '../dashboard_api/use_dashboard_internal_api';
2733
import type { DashboardRedirect } from '../dashboard_app/types';
28-
import { coreServices, screenshotModeService } from '../services/kibana_services';
34+
import { coreServices, screenshotModeService, uiActionsService } from '../services/kibana_services';
2935

3036
import { Dashboard404Page } from './dashboard_404';
3137
import { DashboardViewport } from './viewport/dashboard_viewport';
3238
import { GlobalPrintStyles } from './print_styles';
39+
import { DashboardControlsRenderer } from '../dashboard_controls_renderer';
3340

3441
/**
3542
* Props for the {@link DashboardRenderer} component.
@@ -69,6 +76,32 @@ export function DashboardRenderer({
6976
DashboardInternalApi | undefined
7077
>();
7178
const [error, setError] = useState<Error | undefined>();
79+
/** Default showControlGroup to true. This simplifies embedding DashboardRenderer outside of the dashboard app,
80+
* ensuring that DashboardRenderer, by default, will never fail to render a dashboard due to missing controls.
81+
* Other contexts that want to render the control group in a different location in the UI should explicitly set
82+
* `useControlsIntegration` to `false` in their `getCreationOptions` function
83+
*/
84+
const [showControlGroup, setShowControlGroup] = useState<boolean>(true);
85+
86+
const euiPaddingS = useEuiPaddingSize('s');
87+
const styles = useMemo(
88+
() => ({
89+
renderer: css({
90+
display: 'flex',
91+
flex: 'auto',
92+
width: '100%',
93+
flexDirection: 'column',
94+
'&.dashboardViewport--loading': {
95+
justifyContent: 'center',
96+
alignItems: 'center',
97+
},
98+
'& .controlGroup': {
99+
padding: `0 ${euiPaddingS}`,
100+
},
101+
}),
102+
}),
103+
[euiPaddingS]
104+
);
72105

73106
useEffect(() => {
74107
/* In case the locator prop changes, we need to reassign the value in the container */
@@ -103,6 +136,8 @@ export function DashboardRenderer({
103136
setDashboardApi(results.api);
104137
setDashboardInternalApi(results.internalApi);
105138
onApiAvailable?.(results.api, results.internalApi);
139+
if (typeof results.useControlsIntegration !== 'undefined')
140+
setShowControlGroup(results.useControlsIntegration);
106141
})
107142
.catch((err) => {
108143
if (!canceled) setError(err);
@@ -166,11 +201,14 @@ export function DashboardRenderer({
166201
<ExitFullScreenButtonKibanaProvider
167202
coreStart={{ chrome: coreServices.chrome, customBranding: coreServices.customBranding }}
168203
>
169-
<DashboardContext.Provider value={dashboardApi}>
170-
<DashboardInternalContext.Provider value={dashboardInternalApi}>
171-
<DashboardViewport />
172-
</DashboardInternalContext.Provider>
173-
</DashboardContext.Provider>
204+
<KibanaContextProvider services={{ uiActions: uiActionsService }}>
205+
<DashboardContext.Provider value={dashboardApi}>
206+
<DashboardInternalContext.Provider value={dashboardInternalApi}>
207+
{showControlGroup && <DashboardControlsRenderer />}
208+
<DashboardViewport />
209+
</DashboardInternalContext.Provider>
210+
</DashboardContext.Provider>
211+
</KibanaContextProvider>
174212
</ExitFullScreenButtonKibanaProvider>
175213
</div>
176214
) : (
@@ -191,18 +229,6 @@ export function DashboardRenderer({
191229
);
192230
}
193231

194-
const styles = {
195-
renderer: css({
196-
display: 'flex',
197-
flex: 'auto',
198-
width: '100%',
199-
'&.dashboardViewport--loading': {
200-
justifyContent: 'center',
201-
alignItems: 'center',
202-
},
203-
}),
204-
};
205-
206232
/**
207233
* Maximizing a panel in Dashboard only works if the parent div has a certain class. This
208234
* small component listens to the Dashboard's expandedPanelId state and adds and removes

src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ import {
2121
EuiScreenReaderOnly,
2222
} from '@elastic/eui';
2323
import { css } from '@emotion/react';
24-
import { ControlsRenderer } from '@kbn/controls-renderer';
25-
import type { ControlsLayout } from '@kbn/controls-renderer/src/types';
2624
import type { MountPoint } from '@kbn/core/public';
2725
import { useMemoCss } from '@kbn/css-utils/public/use_memo_css';
2826
import type { Query } from '@kbn/es-query';
@@ -36,7 +34,6 @@ import { MountPointPortal } from '@kbn/react-kibana-mount';
3634
import { AppMenu } from '@kbn/core-chrome-app-menu';
3735
import { UI_SETTINGS } from '../../common/constants';
3836
import { DASHBOARD_APP_ID } from '../../common/page_bundle_constants';
39-
import type { DashboardLayout } from '../dashboard_api/layout_manager';
4037
import type { SaveDashboardReturn } from '../dashboard_api/save_modal/types';
4138
import { useDashboardApi } from '../dashboard_api/use_dashboard_api';
4239
import { useDashboardInternalApi } from '../dashboard_api/use_dashboard_internal_api';
@@ -60,6 +57,7 @@ import {
6057
import { getDashboardCapabilities } from '../utils/get_dashboard_capabilities';
6158
import { getFullEditPath } from '../utils/urls';
6259
import { DashboardFavoriteButton } from './dashboard_favorite_button';
60+
import { DashboardControlsRenderer } from '../dashboard_controls_renderer';
6361

6462
export interface InternalDashboardTopNavProps {
6563
customLeadingBreadCrumbs?: EuiBreadcrumb[];
@@ -369,28 +367,6 @@ export function InternalDashboardTopNav({
369367
[]
370368
);
371369

372-
const onControlsLayoutChanged = useCallback(
373-
({ controls }: ControlsLayout) => {
374-
dashboardApi.layout$.next({
375-
...dashboardApi.layout$.getValue(),
376-
pinnedPanels: controls,
377-
});
378-
},
379-
[dashboardApi.layout$]
380-
);
381-
382-
const [controls, setControls] = useState<{ controls: DashboardLayout['pinnedPanels'] }>({
383-
controls: dashboardApi.layout$.getValue().pinnedPanels,
384-
});
385-
useEffect(() => {
386-
const controlLayoutChangedSubscription = dashboardApi.layout$.subscribe(({ pinnedPanels }) => {
387-
setControls({ controls: pinnedPanels });
388-
});
389-
return () => {
390-
controlLayoutChangedSubscription.unsubscribe();
391-
};
392-
}, [dashboardApi.layout$]);
393-
394370
return (
395371
<div css={styles.container}>
396372
<EuiScreenReaderOnly>
@@ -441,13 +417,7 @@ export function InternalDashboardTopNav({
441417
<LabsFlyout solutions={['dashboard']} onClose={() => setIsLabsShown(false)} />
442418
) : null}
443419

444-
{viewMode !== 'print' ? (
445-
<ControlsRenderer
446-
parentApi={dashboardApi}
447-
controls={controls} // only controls can currently be pinned
448-
onControlsChanged={onControlsLayoutChanged}
449-
/>
450-
) : null}
420+
{viewMode !== 'print' ? <DashboardControlsRenderer /> : null}
451421

452422
{showBorderBottom && <EuiHorizontalRule margin="none" />}
453423
<MountPointPortal setMountPoint={setFavoriteButtonMountPoint}>
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 type { KibanaUrl, ScoutPage, Locator } from '@kbn/scout-oblt';
9+
import type { ServiceDetailsPageTabName } from './service_details_tab';
10+
import { ServiceDetailsTab } from './service_details_tab';
11+
import { EXTENDED_TIMEOUT } from '../../constants';
12+
13+
export class DashboardsTab extends ServiceDetailsTab {
14+
public readonly tabName: ServiceDetailsPageTabName = 'dashboards';
15+
public readonly tab: Locator;
16+
17+
public readonly addServiceDashboardButton: Locator;
18+
19+
constructor(page: ScoutPage, kbnUrl: KibanaUrl, defaultServiceName: string) {
20+
super(page, kbnUrl, defaultServiceName);
21+
this.tab = this.page.getByTestId(`${this.tabName}Tab`);
22+
this.addServiceDashboardButton = this.page.getByTestId('apmAddServiceDashboard');
23+
}
24+
25+
protected async waitForTabLoad(): Promise<void> {
26+
await this.addServiceDashboardButton.waitFor({ state: 'visible', timeout: EXTENDED_TIMEOUT });
27+
}
28+
29+
public async linkDashboardByTitle(dashboardTitle: string) {
30+
await this.addServiceDashboardButton.click();
31+
await this.page.getByTestId('apmSelectServiceDashboard').getByTestId('comboBoxInput').click();
32+
await this.page.getByText(dashboardTitle).click();
33+
await this.page.getByTestId('apmSelectDashboardButton').click();
34+
}
35+
}

0 commit comments

Comments
 (0)